diff --git a/panel/interact.py b/panel/interact.py index fdb6e1948d..9dc47606aa 100644 --- a/panel/interact.py +++ b/panel/interact.py @@ -146,8 +146,7 @@ def __init__(self, object, params={}, **kwargs): if self.manual_update: widgets.append(('manual', Button(name=self.manual_name))) self._widgets = OrderedDict(widgets) - self._pane = Pane(self.object(**self.kwargs), name=self.name, - _temporary=True) + self._pane = Pane(self.object(**self.kwargs), name=self.name) self._inner_layout = Row(self._pane) widgets = [widget for _, widget in widgets if isinstance(widget, Widget)] if 'name' in params: @@ -156,35 +155,20 @@ def __init__(self, object, params={}, **kwargs): self.layout.objects = [self.widget_box, self._inner_layout] self._link_widgets() - @property - def kwargs(self): - return {k: widget.value for k, widget in self._widgets.items() - if k != 'manual'} - - def signature(self): - return signature(self.object) - - def find_abbreviations(self, kwargs): - """Find the abbreviations for the given function and kwargs. - Return (name, abbrev, default) tuples. - """ - new_kwargs = [] - try: - sig = self.signature() - except (ValueError, TypeError): - # can't inspect, no info from function; only use kwargs - return [ (key, value, value) for key, value in kwargs.items() ] - - for parameter in sig.parameters.values(): - for name, value, default in _yield_abbreviations_for_parameter(parameter, kwargs): - if value is empty: - raise ValueError('cannot find widget or abbreviation for argument: {!r}'.format(name)) - new_kwargs.append((name, value, default)) - return new_kwargs + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- def _get_model(self, doc, root=None, parent=None, comm=None): return self._inner_layout._get_model(doc, root, parent, comm) + #---------------------------------------------------------------- + # Callback API + #---------------------------------------------------------------- + + def _synced_params(self): + return [] + def _link_widgets(self): if self.manual_update: widgets = [('manual', self._widgets['manual'])] @@ -200,27 +184,52 @@ def update_pane(change): if isinstance(new_object, (PaneBase, Panel)): new_params = {k: v for k, v in new_object.get_param_values() if k != 'name'} - try: - self._pane.set_param(**new_params) - except: - raise - finally: - new_object._cleanup(final=new_object._temporary) + self._pane.set_param(**new_params) else: self._pane.object = new_object return # Replace pane entirely - self._pane = Pane(new_object, _temporary=True) + self._pane = Pane(new_object) self._inner_layout[0] = self._pane pname = 'clicks' if name == 'manual' else 'value' watcher = widget.param.watch(update_pane, pname) - self._callbacks['instance'].append(watcher) + self._callbacks.append(watcher) - def _cleanup(self, root=None, final=False): - self._inner_layout._cleanup(root, final) - super(interactive, self)._cleanup(root, final) + def _cleanup(self, root): + self._inner_layout._cleanup(root) + super(interactive, self)._cleanup(root) + + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + + @property + def kwargs(self): + return {k: widget.value for k, widget in self._widgets.items() + if k != 'manual'} + + def signature(self): + return signature(self.object) + + def find_abbreviations(self, kwargs): + """Find the abbreviations for the given function and kwargs. + Return (name, abbrev, default) tuples. + """ + new_kwargs = [] + try: + sig = self.signature() + except (ValueError, TypeError): + # can't inspect, no info from function; only use kwargs + return [ (key, value, value) for key, value in kwargs.items() ] + + for parameter in sig.parameters.values(): + for name, value, default in _yield_abbreviations_for_parameter(parameter, kwargs): + if value is empty: + raise ValueError('cannot find widget or abbreviation for argument: {!r}'.format(name)) + new_kwargs.append((name, value, default)) + return new_kwargs def widgets_from_abbreviations(self, seq): """Given a sequence of (name, abbrev, default) tuples, return a sequence of Widgets.""" diff --git a/panel/io.py b/panel/io.py index 5da9c2ec9b..5f0cab907d 100644 --- a/panel/io.py +++ b/panel/io.py @@ -82,7 +82,7 @@ def _cleanup_panel(msg_id): """ if msg_id not in state._views: return - viewable, model = state._views.pop(msg_id) + viewable, model, _, _ = state._views.pop(msg_id) viewable._cleanup(model) diff --git a/panel/layout.py b/panel/layout.py index b9419a8b08..417f754b2b 100644 --- a/panel/layout.py +++ b/panel/layout.py @@ -10,8 +10,7 @@ Spacer as BkSpacer) from bokeh.models.widgets import Tabs as BkTabs, Panel as BkPanel -from .io import state -from .util import param_name, param_reprs, push +from .util import param_name, param_reprs from .viewable import Reactive @@ -36,60 +35,42 @@ def __init__(self, *objects, **params): objects = [panel(pane) for pane in objects] super(Panel, self).__init__(objects=objects, **params) - def _link_params(self, model, params, doc, root, comm=None): - def set_value(*events): - msg = {event.name: event.new for event in events} - events = {event.name: event for event in events} - - def update_model(): - if 'objects' in msg: - old = events['objects'].old - msg['objects'] = self._get_objects(model, old, doc, root, comm) - for pane in old: - if pane not in self.objects: - pane._cleanup(root) - self._preprocess(root) #preprocess links between new elements - processed = self._process_param_change(msg) - model.update(**processed) - - if comm: - update_model() - push(doc, comm) - elif state.curdoc: - update_model() - else: - doc.add_next_tick_callback(update_model) - - ref = root.ref['id'] - if ref not in self._callbacks: - watcher = self.param.watch(set_value, params) - self._callbacks[ref].append(watcher) - - def _cleanup(self, root=None, final=False): - super(Panel, self)._cleanup(root, final) - if root is not None: - for p in self.objects: - p._cleanup(root, final) - - def select(self, selector=None): - """ - Iterates over the Viewable and any potential children in the - applying the Selector. - - Arguments - --------- - selector: type or callable or None - The selector allows selecting a subset of Viewables by - declaring a type or callable function to filter by. - - Returns - ------- - viewables: list(Viewable) - """ - objects = super(Panel, self).select(selector) - for obj in self.objects: - objects += obj.select(selector) - return objects + def __repr__(self, depth=0, max_depth=10): + if depth > max_depth: + return '...' + spacer = '\n' + (' ' * (depth+1)) + cls = type(self).__name__ + params = param_reprs(self, ['objects']) + objs = ['[%d] %s' % (i, obj.__repr__(depth+1)) for i, obj in enumerate(self)] + if not params and not objs: + return super(Panel, self).__repr__(depth+1) + elif not params: + template = '{cls}{spacer}{objs}' + elif not objs: + template = '{cls}({params})' + else: + template = '{cls}({params}){spacer}{objs}' + return template.format( + cls=cls, params=', '.join(params), + objs=('%s' % spacer).join(objs), spacer=spacer) + + #---------------------------------------------------------------- + # Callback API + #---------------------------------------------------------------- + + def _update_model(self, events, msg, root, model, doc, comm=None): + if self._rename['objects'] in msg: + old = events['objects'].old + msg[self._rename['objects']] = self._get_objects(model, old, doc, root, comm) + for pane in old: + if pane not in self.objects: + pane._cleanup(root) + model.update(**msg) + self._preprocess(root) #preprocess links between new elements + + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- def _get_objects(self, model, old_objects, doc, root, comm=None): """ @@ -102,10 +83,13 @@ def _get_objects(self, model, old_objects, doc, root, comm=None): pane = panel(pane) self.objects[i] = pane if pane in old_objects: - child = pane._models[root.ref['id']] + child, _ = pane._models[root.ref['id']] else: child = pane._get_model(doc, root, model, comm) new_models.append(child) + for obj in old_objects: + if obj not in self.objects: + obj._cleanup(root) return new_models def _get_model(self, doc, root=None, parent=None, comm=None): @@ -115,12 +99,19 @@ def _get_model(self, doc, root=None, parent=None, comm=None): objects = self._get_objects(model, [], doc, root, comm) props = dict(self._init_properties(), objects=objects) model.update(**self._process_param_change(props)) - params = [p for p in self.param if p != 'name'] - self._models[root.ref['id']] = model - self._link_params(model, params, doc, root, comm) + self._models[root.ref['id']] = (model, parent) self._link_props(model, self._linked_props, doc, root, comm) return model + def _cleanup(self, root): + super(Panel, self)._cleanup(root) + for p in self.objects: + p._cleanup(root) + + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + def __getitem__(self, index): return self.objects[index] @@ -170,25 +161,25 @@ def __setitem__(self, index, panes): new_objects[i] = panel(pane) self.objects = new_objects - def __repr__(self, depth=0, max_depth=10): - if depth > max_depth: - return '...' - spacer = '\n' + (' ' * (depth+1)) - cls = type(self).__name__ - params = param_reprs(self, ['objects']) - objs = ['[%d] %s' % (i, obj.__repr__(depth+1)) for i, obj in enumerate(self)] - if not params and not objs: - return super(Panel, self).__repr__(depth+1) - elif not params: - template = '{cls}{spacer}{objs}' - elif not objs: - template = '{cls}({params})' - else: - template = '{cls}({params}){spacer}{objs}' - return template.format( - cls=cls, params=', '.join(params), - objs=('%s' % spacer).join(objs), spacer=spacer - ) + def select(self, selector=None): + """ + Iterates over the Viewable and any potential children in the + applying the Selector. + + Arguments + --------- + selector: type or callable or None + The selector allows selecting a subset of Viewables by + declaring a type or callable function to filter by. + + Returns + ------- + viewables: list(Viewable) + """ + objects = super(Panel, self).select(selector) + for obj in self.objects: + objects += obj.select(selector) + return objects def append(self, pane): from .pane import panel @@ -270,6 +261,9 @@ def __init__(self, *items, **params): objects, self._names = self._to_objects_and_names(items) super(Tabs, self).__init__(*objects, **params) self.param.watch(self._update_names, 'objects') + # ALERT: Ensure that name update happens first, should be + # replaced by watch precedence support in param + self._param_watchers['objects']['value'].reverse() def _to_object_and_name(self, item): from .pane import panel @@ -289,6 +283,10 @@ def _to_objects_and_names(self, items): names.append(name) return objects, names + #---------------------------------------------------------------- + # Callback API + #---------------------------------------------------------------- + def _update_names(self, event): if len(event.new) == len(self._names): return @@ -302,6 +300,10 @@ def _update_names(self, event): names.append(name) self._names = names + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- + def _get_objects(self, model, old_objects, doc, root, comm=None): """ Returns new child models for the layout while reusing unchanged @@ -318,13 +320,20 @@ def _get_objects(self, model, old_objects, doc, root, comm=None): pane = panel(pane, name=name) self.objects[i] = pane if pane in old_objects: - child = pane._models[root.ref['id']] + child, _ = pane._models[root.ref['id']] else: child = pane._get_model(doc, root, model, comm) child = BkPanel(title=name, name=pane.name, child=child) new_models.append(child) + for obj in old_objects: + if obj not in self.objects: + obj._cleanup(root) return new_models + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + def __setitem__(self, index, panes): new_objects = list(self) if not isinstance(index, slice): @@ -415,11 +424,11 @@ class Spacer(Reactive): _bokeh_model = BkSpacer def _get_model(self, doc, root=None, parent=None, comm=None): - model = self._bokeh_model(**self._process_param_change(self._init_properties())) + properties = self._process_param_change(self._init_properties()) + model = self._bokeh_model(**properties) if root is None: root = model - self._models[root.ref['id']] = model - self._link_params(model, ['width', 'height'], doc, root, comm) + self._models[root.ref['id']] = (model, parent) return model diff --git a/panel/links.py b/panel/links.py index cca215dd88..e106382bc1 100644 --- a/panel/links.py +++ b/panel/links.py @@ -180,7 +180,7 @@ def _resolve_model(cls, root_model, obj, model_spec): model_spec = None model = obj.handles[handle_spec] elif isinstance(obj, Viewable): - model = obj._models[root_model.ref['id']] + model, _ = obj._models[root_model.ref['id']] elif isinstance(obj, BkModel): model = obj if model_spec is not None: diff --git a/panel/models/plotly.ts b/panel/models/plotly.ts index e0fe372f10..f3f824cd16 100644 --- a/panel/models/plotly.ts +++ b/panel/models/plotly.ts @@ -44,6 +44,8 @@ export class PlotlyPlotView extends HTMLBoxView { } _plot(): void { + if (this.model.data == null) + return for (let i = 0; i < this.model.data.data.length; i++) { const trace = this.model.data.data[i] const cds = this.model.data_sources[i] diff --git a/panel/models/vega.ts b/panel/models/vega.ts index 06c5b81032..66f7969d84 100644 --- a/panel/models/vega.ts +++ b/panel/models/vega.ts @@ -76,6 +76,8 @@ export class VegaPlotView extends HTMLBoxView { } _plot(): void { + if (this.model.data == null) + return if (!('datasets' in this.model.data)) { const datasets = this._fetch_datasets() if ('data' in datasets) { diff --git a/panel/pane/base.py b/panel/pane/base.py index b7b8751318..b2b9fc5c2c 100644 --- a/panel/pane/base.py +++ b/panel/pane/base.py @@ -4,6 +4,8 @@ """ from __future__ import absolute_import, division, unicode_literals +from functools import partial + import param from ..io import state @@ -55,55 +57,26 @@ class PaneBase(Reactive): # Whether the Pane layout can be safely unpacked _unpack = True - __abstract = True - - @classmethod - def applies(cls, obj): - """ - Given the object return a boolean indicating whether the Pane - can render the object. If the priority of the pane is set to - None, this method may also be used to define a priority - depending on the object being rendered. - """ - return None + # List of parameters that trigger a rerender of the Bokeh model + _rerender_params = ['object'] - @classmethod - def get_pane_type(cls, obj): - if isinstance(obj, Viewable): - return type(obj) - descendents = [] - for p in param.concrete_descendents(PaneBase).values(): - priority = p.applies(obj) if p.priority is None else p.priority - if isinstance(priority, bool) and priority: - raise ValueError('If a Pane declares no priority ' - 'the applies method should return a ' - 'priority value specific to the ' - 'object type or False, but the %s pane ' - 'declares no priority.' % p.__name__) - elif priority is None or priority is False: - continue - descendents.append((priority, p)) - pane_types = reversed(sorted(descendents, key=lambda x: x[0])) - for _, pane_type in pane_types: - applies = pane_type.applies(obj) - if isinstance(applies, bool) and not applies: continue - return pane_type - raise TypeError('%s type could not be rendered.' % type(obj).__name__) + __abstract = True - def __init__(self, object, **params): + def __init__(self, object=None, **params): applies = self.applies(object) - if isinstance(applies, bool) and not applies: + if (isinstance(applies, bool) and not applies) and object is not None : raise ValueError("%s pane does not support objects of type '%s'" % (type(self).__name__, type(object).__name__)) super(PaneBase, self).__init__(object=object, **params) kwargs = {k: v for k, v in params.items() if k in Layoutable.param} self.layout = self.default_layout(self, **kwargs) + self.param.watch(self._update_pane, self._rerender_params) def __repr__(self, depth=0): cls = type(self).__name__ params = param_reprs(self, ['object']) - obj = type(self.object).__name__ + obj = 'Empty' if self.object is None else type(self.object).__name__ template = '{cls}({obj}, {params})' if params else '{cls}({obj})' return template.format(cls=cls, params=', '.join(params), obj=obj) @@ -113,63 +86,109 @@ def __getitem__(self, index): """ return self.layout[index] + #---------------------------------------------------------------- + # Callback API + #---------------------------------------------------------------- + + def _synced_params(self): + ignored_params = ['name', 'default_layout']+self._rerender_params + return [p for p in self.param if p not in ignored_params] + + def _update_object(self, old_model, doc, root, parent, comm): + if self._updates: + self._update(old_model) + else: + new_model = self._get_model(doc, root, parent, comm) + try: + index = parent.children.index(old_model) + except IndexError: + self.warning('%s pane model %s could not be replaced ' + 'with new model %s, ensure that the ' + 'parent is not modified at the same ' + 'time the panel is being updated.' % + (type(self).__name__, old_model, new_model)) + else: + parent.children[index] = new_model + + def _update_pane(self, event): + for ref, (model, parent) in self._models.items(): + viewable, root, doc, comm = state._views[ref] + if comm or state.curdoc: + self._update_object(model, doc, root, parent, comm) + if comm: + push(doc, comm) + else: + cb = partial(self._update_object, model, doc, root, parent, comm) + doc.add_next_tick_callback(cb) + + def _update(self, model): + """ + If _updates=True this method is used to update an existing + Bokeh model instead of replacing the model entirely. The + supplied model should be updated with the current state. + """ + raise NotImplementedError + + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- + def _get_root(self, doc, comm=None): if self._updates: root = self._get_model(doc, comm=comm) else: root = self.layout._get_model(doc, comm=comm) self._preprocess(root) + ref = root.ref['id'] + state._views[ref] = (self, root, doc, comm) return root - def _cleanup(self, root=None, final=False): - super(PaneBase, self)._cleanup(root, final) - if final: - self.object = None + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- - def _update(self, model): + @classmethod + def applies(cls, obj): """ - If _updates=True this method is used to update an existing Bokeh - model instead of replacing the model entirely. The supplied model - should be updated with the current state. + Given the object return a boolean indicating whether the Pane + can render the object. If the priority of the pane is set to + None, this method may also be used to define a priority + depending on the object being rendered. """ - raise NotImplementedError + return None - def _link_object(self, doc, root, parent, comm=None): - """ - Links the object parameter to the rendered Bokeh model, triggering - an update when the object changes. + @classmethod + def get_pane_type(cls, obj): """ - ref = root.ref['id'] - - def update_pane(change): - old_model = self._models[ref] + Returns the applicable Pane type given an object by resolving + the precedence of all types whose applies method declares that + the object is supported. - if self._updates: - # Pane supports model updates - def update_models(): - self._update(old_model) - else: - # Otherwise replace the whole model - new_model = self._get_model(doc, root, parent, comm) - def update_models(): - try: - index = parent.children.index(old_model) - except IndexError: - self.warning('%s pane model %s could not be replaced ' - 'with new model %s, ensure that the ' - 'parent is not modified at the same ' - 'time the panel is being updated.' % - (type(self).__name__, old_model, new_model)) - else: - parent.children[index] = new_model - - if comm: - update_models() - push(doc, comm) - elif state.curdoc: - update_models() - else: - doc.add_next_tick_callback(update_models) + Parameters + ---------- + obj (object): The object type to return a Pane for - if ref not in self._callbacks: - self._callbacks[ref].append(self.param.watch(update_pane, 'object')) + Returns + ------- + The applicable Pane type with the highest precedence. + """ + if isinstance(obj, Viewable): + return type(obj) + descendents = [] + for p in param.concrete_descendents(PaneBase).values(): + priority = p.applies(obj) if p.priority is None else p.priority + if isinstance(priority, bool) and priority: + raise ValueError('If a Pane declares no priority ' + 'the applies method should return a ' + 'priority value specific to the ' + 'object type or False, but the %s pane ' + 'declares no priority.' % p.__name__) + elif priority is None or priority is False: + continue + descendents.append((priority, p)) + pane_types = reversed(sorted(descendents, key=lambda x: x[0])) + for _, pane_type in pane_types: + applies = pane_type.applies(obj) + if isinstance(applies, bool) and not applies: continue + return pane_type + raise TypeError('%s type could not be rendered.' % type(obj).__name__) diff --git a/panel/pane/equation.py b/panel/pane/equation.py index 648137993e..a4a4e18ca0 100644 --- a/panel/pane/equation.py +++ b/panel/pane/equation.py @@ -80,6 +80,8 @@ class LaTeX(PNG): dpi = param.Number(default=72, bounds=(1, 1900), doc=""" Resolution per inch for the rendered equation.""") + _rerender_params = ['object', 'size', 'dpi'] + @classmethod def applies(cls, obj): if is_sympy_expr(obj) or hasattr(obj, '_repr_latex_'): @@ -100,7 +102,7 @@ def _imgshape(self, data): return int(w*72), int(h*72) def _img(self): - obj=self.object # Default: LaTeX string + obj = self.object # Default: LaTeX string if hasattr(obj, '_repr_latex_'): obj = obj._repr_latex_() diff --git a/panel/pane/holoviews.py b/panel/pane/holoviews.py index 573bd9a576..128311d639 100644 --- a/panel/pane/holoviews.py +++ b/panel/pane/holoviews.py @@ -5,7 +5,9 @@ from __future__ import absolute_import, division, unicode_literals import sys + from collections import OrderedDict, defaultdict +from functools import partial import param @@ -14,6 +16,8 @@ from ..viewable import Viewable from ..widgets import Player from .base import PaneBase, Pane +from .plot import Bokeh, Matplotlib +from .plotly import Plotly class HoloViews(PaneBase): @@ -37,27 +41,39 @@ class HoloViews(PaneBase): priority = 0.8 + _rerender_params = ['object', 'widgets', 'backend', 'widget_type'] + + _panes = {'bokeh': Bokeh, 'matplotlib': Matplotlib, 'plotly': Plotly} + def __init__(self, object, **params): super(HoloViews, self).__init__(object, **params) self.widget_box = Column() self._update_widgets() self._plots = {} + self.param.watch(self._update_widgets, self._rerender_params) + + #---------------------------------------------------------------- + # Callback API + #---------------------------------------------------------------- - @param.depends('object', 'widgets', watch=True) - def _update_widgets(self): + def _update_widgets(self, *events): if self.object is None: - widgets, values = [] + widgets, values = [], [] else: widgets, values = self.widgets_from_dimensions(self.object, self.widgets, self.widget_type) self._values = values # Clean up anything models listening to the previous widgets - for _, cbs in self._callbacks.items(): - for cb in list(cbs): - if cb.inst in self.widget_box.objects: - cb.inst.param.unwatch(cb) - cbs.remove(cb) + for cb in list(self._callbacks): + if cb.inst in self.widget_box.objects: + cb.inst.param.unwatch(cb) + self._callbacks.remove(cb) + + # Add new widget callbacks + for widget in widgets: + watcher = widget.param.watch(self._widget_callback, 'value') + self._callbacks.append(watcher) self.widget_box.objects = widgets if widgets and not self.widget_box in self.layout.objects: @@ -65,23 +81,49 @@ def _update_widgets(self): elif not widgets and self.widget_box in self.layout.objects: self.layout.pop(self.widget_box) - @classmethod - def applies(cls, obj): - if 'holoviews' not in sys.modules: - return False - from holoviews.core.dimension import Dimensioned - return isinstance(obj, Dimensioned) + def _update_plot(self, plot, pane, event): + from holoviews.core.util import cross_index + from holoviews.plotting.bokeh.plot import BokehPlot - def _cleanup(self, root=None, final=False): - """ - Traverses HoloViews object to find and clean up any streams - connected to existing plots. - """ - if root is not None: - old_plot = self._plots.pop(root.ref['id'], None) - if old_plot: - old_plot.cleanup() - super(HoloViews, self)._cleanup(root, final) + widgets = self.widget_box.objects + if self.widget_type == 'scrubber': + key = cross_index([v for v in self._values.values()], widgets[0].value) + else: + key = tuple(w.value for w in widgets) + + if isinstance(plot, BokehPlot): + if plot.comm or state.curdoc: + plot.update(key) + if plot.comm: + plot.push() + else: + plot.document.add_next_tick_callback(partial(plot.update, key)) + else: + plot.update(key) + pane.object = plot.state + + def _widget_callback(self, event): + for ref, (plot, pane) in self._plots.items(): + self._update_plot(plot, pane, event) + + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- + + def _get_model(self, doc, root=None, parent=None, comm=None): + if root is None: + return self._get_root(doc, comm) + ref = root.ref['id'] + plot = self._render(doc, comm, root) + child_pane = self._panes.get(self.backend, Pane)(plot.state) + model = child_pane._get_model(doc, root, parent, comm) + if ref in self._plots: + old_plot, old_pane = self._plots[ref] + old_plot.comm = None # Ensure comm does not cleaned up + old_plot.cleanup() + self._plots[ref] = (plot, child_pane) + self._models[ref] = (model, parent) + return model def _render(self, doc, comm, root): from holoviews import Store, renderer @@ -96,72 +138,46 @@ def _render(self, doc, comm, root): kwargs = {'doc': doc, 'root': root} if backend == 'bokeh' else {} if comm: kwargs['comm'] = comm - plot = renderer.get_plot(self.object, **kwargs) - ref = root.ref['id'] - if ref in self._plots: - old_plot = self._plots[ref] - old_plot.comm = None - old_plot.cleanup() - self._plots[root.ref['id']] = plot - return plot - - def _get_model(self, doc, root=None, parent=None, comm=None): - if root is None: - return self._get_root(doc, comm) - ref = root.ref['id'] - plot = self._render(doc, comm, root) - child_pane = Pane(plot.state, _temporary=True) - model = child_pane._get_model(doc, root, parent, comm) - self._models[ref] = model - self._link_object(doc, root, parent, comm) - if self.widget_box.objects: - self._link_widgets(child_pane, root, comm) - return model - - def _link_widgets(self, pane, root, comm): - def update_plot(change): - from holoviews.core.util import cross_index - from holoviews.plotting.bokeh.plot import BokehPlot + return renderer.get_plot(self.object, **kwargs) - widgets = self.widget_box.objects - if self.widget_type == 'scrubber': - key = cross_index([v for v in self._values.values()], widgets[0].value) - else: - key = tuple(w.value for w in widgets) + def _cleanup(self, root): + """ + Traverses HoloViews object to find and clean up any streams + connected to existing plots. + """ + old_plot, old_pane = self._plots.pop(root.ref['id'], None) + if old_plot: + old_plot.cleanup() + if old_pane: + old_pane._cleanup(root) + super(HoloViews, self)._cleanup(root) - plot = self._plots[root.ref['id']] - if isinstance(plot, BokehPlot): - if comm: - plot.update(key) - plot.push() - elif state.curdoc: - plot.update(key) - else: - def update_plot(): - plot.update(key) - plot.document.add_next_tick_callback(update_plot) - else: - plot.update(key) - pane.object = plot.state + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- - ref = root.ref['id'] - for w in self.widget_box.objects: - watcher = w.param.watch(update_plot, 'value') - self._callbacks[ref].append(watcher) + @classmethod + def applies(cls, obj): + if 'holoviews' not in sys.modules: + return False + from holoviews.core.dimension import Dimensioned + return isinstance(obj, Dimensioned) @classmethod def widgets_from_dimensions(cls, object, widget_types={}, widgets_type='individual'): from holoviews.core import Dimension from holoviews.core.util import isnumeric, unicode, datetime_types from holoviews.core.traversal import unique_dimkeys + from holoviews.plotting.util import get_dynamic_mode from ..widgets import Widget, DiscreteSlider, Select, FloatSlider, DatetimeInput + dynamic, bounded = get_dynamic_mode(object) dims, keys = unique_dimkeys(object) if dims == [Dimension('Frame')] and keys == [(0,)]: return [], {} nframes = 1 - values = dict(zip(dims, zip(*keys))) + values = dict() if dynamic else dict(zip(dims, zip(*keys))) dim_values = OrderedDict() widgets = [] for dim in dims: @@ -182,7 +198,7 @@ def widgets_from_dimensions(cls, object, widget_types={}, widgets_type='individu else: raise ValueError('Explicit widget definitions expected ' 'to be a widget instance or type, %s ' - 'dimension widget declared as %s.' % + 'dimension widget declared as %s.' % (dim, widget)) if vals: if all(isnumeric(v) or isinstance(v, datetime_types) for v in vals) and len(vals) > 1: @@ -230,11 +246,11 @@ def generate_panel_bokeh_map(root_model, panel_views): """ map_hve_bk = defaultdict(list) for pane in panel_views: - if root_model.ref['id'] in pane._models: - bk_plots = pane._plots[root_model.ref['id']].traverse(lambda x: x, [is_bokeh_element_plot]) + if root_model.ref['id'] in pane._models: + bk_plots = pane._plots[root_model.ref['id']][0].traverse(lambda x: x, [is_bokeh_element_plot]) for plot in bk_plots: for hv_elem in plot.link_sources: - map_hve_bk[hv_elem].append(plot) + map_hve_bk[hv_elem].append(plot) return map_hve_bk @@ -247,7 +263,7 @@ def find_links(root_view, root_model): return hv_views = root_view.select(HoloViews) - root_plots = [plot for view in hv_views for plot in view._plots.values() + root_plots = [plot for view in hv_views for plot, _ in view._plots.values() if getattr(plot, 'root', None) is root_model] if not root_plots: diff --git a/panel/pane/image.py b/panel/pane/image.py index 0c737835fa..40c525871c 100644 --- a/panel/pane/image.py +++ b/panel/pane/image.py @@ -58,6 +58,8 @@ def _imgshape(self, data): def _get_properties(self): p = super(ImageBase, self)._get_properties() + if self.object is None: + return dict(p, text='') data = self._img() if isinstance(data, str): data = base64.b64decode(data) @@ -144,6 +146,8 @@ def _imgshape(self, data): def _get_properties(self): p = super(ImageBase, self)._get_properties() + if self.object is None: + return dict(p, text='') data = self._img() width, height = self._imgshape(data) if not isinstance(data, bytes): diff --git a/panel/pane/markup.py b/panel/pane/markup.py index 2ed249aa11..0bbb62ae1c 100644 --- a/panel/pane/markup.py +++ b/panel/pane/markup.py @@ -36,15 +36,14 @@ class DivPaneBase(PaneBase): _rename = {'object': 'text'} def _get_properties(self): - return {p : getattr(self,p) for p in list(Layoutable.param) + ['style'] + return {p : getattr(self, p) for p in list(Layoutable.param) + ['style'] if getattr(self, p) is not None} def _get_model(self, doc, root=None, parent=None, comm=None): model = _BkDiv(**self._get_properties()) if root is None: root = model - self._models[root.ref['id']] = model - self._link_object(doc, root, parent, comm) + self._models[root.ref['id']] = (model, parent) return model def _update(self, model): @@ -74,9 +73,9 @@ def applies(cls, obj): def _get_properties(self): properties = super(HTML, self)._get_properties() - text=self.object + text = '' if self.object is None else self.object if hasattr(text, '_repr_html_'): - text=text._repr_html_() + text = text._repr_html_() return dict(properties, text=text) @@ -97,7 +96,11 @@ def applies(cls, obj): def _get_properties(self): properties = super(Str, self)._get_properties() - return dict(properties, text='
'+escape(str(self.object))+'
') + if self.object is None: + text = '' + else: + text = '
'+escape(str(self.object))+'
' + return dict(properties, text=text) class Markdown(DivPaneBase): @@ -123,11 +126,12 @@ def applies(cls, obj): def _get_properties(self): import markdown data = self.object - if not isinstance(data, string_types): + if data is None: + data = '' + elif not isinstance(data, string_types): data = data._repr_markdown_() properties = super(Markdown, self)._get_properties() properties['style'] = properties.get('style', {}) extensions = ['markdown.extensions.extra', 'markdown.extensions.smarty'] - html = markdown.markdown(self.object, extensions=extensions, - output_format='html5') + html = markdown.markdown(data, extensions=extensions, output_format='html5') return dict(properties, text=html) diff --git a/panel/pane/plot.py b/panel/pane/plot.py index e9ab910623..303e7a691e 100644 --- a/panel/pane/plot.py +++ b/panel/pane/plot.py @@ -9,7 +9,7 @@ import param -from bokeh.models import LayoutDOM, CustomJS +from bokeh.models import LayoutDOM, CustomJS, Spacer as BkSpacer from ..util import remove_root from .base import PaneBase @@ -32,7 +32,10 @@ def _get_model(self, doc, root=None, parent=None, comm=None): if root is None: return self._get_root(doc, comm) - model = self.object + if self.object is None: + model = BkSpacer() + else: + model = self.object ref = root.ref['id'] for js in model.select({'type': CustomJS}): js.code = js.code.replace(model.ref['id'], ref) @@ -40,8 +43,7 @@ def _get_model(self, doc, root=None, parent=None, comm=None): if model._document and doc is not model._document: remove_root(model, doc) - self._models[ref] = model - self._link_object(doc, root, parent, comm) + self._models[ref] = (model, parent) return model @@ -57,6 +59,8 @@ class Matplotlib(PNG): dpi = param.Integer(default=144, bounds=(1, None), doc=""" Scales the dpi of the matplotlib figure.""") + _rerender_params = ['object', 'dpi'] + @classmethod def applies(cls, obj): if 'matplotlib' not in sys.modules: @@ -92,6 +96,8 @@ class RGGPlot(PNG): dpi = param.Integer(default=144, bounds=(1, None)) + _rerender_params = ['object', 'dpi', 'width', 'height'] + @classmethod def applies(cls, obj): return type(obj).__name__ == 'GGPlot' and hasattr(obj, 'r_repr') @@ -124,6 +130,8 @@ def applies(cls, obj): def _get_properties(self): p = super(YT, self)._get_properties() + if self.object is None: + return p width = height = 0 if self.width is None or self.height is None: diff --git a/panel/pane/plotly.py b/panel/pane/plotly.py index 3ba19bc2f5..d7955cc7e0 100644 --- a/panel/pane/plotly.py +++ b/panel/pane/plotly.py @@ -4,7 +4,6 @@ """ from __future__ import absolute_import, division, unicode_literals -import param import numpy as np from bokeh.models import ColumnDataSource @@ -22,53 +21,61 @@ class Plotly(PaneBase): the figure on bokeh server and via Comms. """ - plotly_layout = param.Dict() - _updates = True priority = 0.8 - def __init__(self, object, layout=None, **params): - super(Plotly, self).__init__(self._to_figure(object, layout), - plotly_layout=layout, **params) - @classmethod def applies(cls, obj): return ((isinstance(obj, list) and obj and all(cls.applies(o) for o in obj)) or - hasattr(obj, 'to_plotly_json')) + hasattr(obj, 'to_plotly_json') or (isinstance(obj, dict) + and 'data' in obj and 'layout' in obj)) - def _to_figure(self, obj, layout={}): + def _to_figure(self, obj): import plotly.graph_objs as go if isinstance(obj, go.Figure): - fig = obj + return obj + elif isinstance(obj, dict): + data, layout = obj['data'], obj['layout'] + elif isinstance(obj, tuple): + data, layout = obj else: - data = obj if isinstance(obj, list) else [obj] - fig = go.Figure(data=data, layout=layout) - return fig + data, layout = obj, {} + data = data if isinstance(data, list) else [data] + return go.Figure(data=data, layout=layout) - def _get_model(self, doc, root=None, parent=None, comm=None): - """ - Should return the bokeh model to be rendered. - """ - fig = self._to_figure(self.object, self.plotly_layout) - json = fig.to_plotly_json() - traces = json['data'] + def _get_sources(self, json): sources = [] + traces = json['data'] for trace in traces: data = {} for key, value in list(trace.items()): if isinstance(value, np.ndarray): data[key] = trace.pop(key) sources.append(ColumnDataSource(data)) + return sources + + def _get_model(self, doc, root=None, parent=None, comm=None): + """ + Should return the bokeh model to be rendered. + """ + if self.object is None: + json, sources = None, [] + else: + fig = self._to_figure(self.object) + json = fig.to_plotly_json() + sources = self._get_sources(json) model = PlotlyPlot(data=json, data_sources=sources) if root is None: root = model - self._models[root.ref['id']] = model - self._link_object(doc, root, parent, comm) + self._models[root.ref['id']] = (model, parent) return model def _update(self, model): - fig = self._to_figure(self.object, self.plotly_layout) + if self.object is None: + model.data = None + return + fig = self._to_figure(self.object) json = fig.to_plotly_json() traces = json['data'] new_sources = [] diff --git a/panel/pane/vega.py b/panel/pane/vega.py index 199c8b8626..b3fa6d36c7 100644 --- a/panel/pane/vega.py +++ b/panel/pane/vega.py @@ -37,9 +37,6 @@ class Vega(PaneBase): _updates = True - def __init__(self, object, **params): - super(Vega, self).__init__(object, **params) - @classmethod def is_altair(cls, obj): if 'altair' in sys.modules: @@ -81,19 +78,24 @@ def _get_sources(self, json, sources): sources['data'] = ColumnDataSource(data=ds_as_cds(data)) def _get_model(self, doc, root=None, parent=None, comm=None): - json = self._to_json(self.object) - json['data'] = dict(json['data']) sources = {} - self._get_sources(json, sources) + if self.object is None: + json = None + else: + json = self._to_json(self.object) + json['data'] = dict(json['data']) + self._get_sources(json, sources) model = VegaPlot(data=json, data_sources=sources) if root is None: root = model - self._models[root.ref['id']] = model - self._link_object(doc, root, parent, comm) + self._models[root.ref['id']] = (model, parent) return model def _update(self, model): - json = self._to_json(self.object) - self._get_sources(json, model.data_sources) + if self.object is None: + json = None + else: + json = self._to_json(self.object) + self._get_sources(json, model.data_sources) model.data = json diff --git a/panel/param.py b/panel/param.py index 6cb2cd14ec..4b30a87322 100644 --- a/panel/param.py +++ b/panel/param.py @@ -18,6 +18,7 @@ from param.parameterized import classlist +from .io import state from .layout import Row, Panel, Tabs, Column from .links import Link from .pane.base import Pane, PaneBase @@ -28,7 +29,7 @@ from .widgets import ( LiteralInput, Select, Checkbox, FloatSlider, IntSlider, RangeSlider, MultiSelect, StaticText, Button, Toggle, TextInput, DiscreteSlider, - DatetimeInput, DateRangeSlider, ColorPicker) + DatetimeInput, DateRangeSlider, ColorPicker, Widget) def ObjectSelector(pobj): @@ -91,7 +92,7 @@ class Param(PaneBase): usually to update the default Parameter values of the underlying parameterized object.""") - parameters = param.List(default=None, doc=""" + parameters = param.List(default=[], doc=""" If set this serves as a whitelist of parameters to display on the supplied Parameterized object.""") @@ -130,27 +131,19 @@ class Param(PaneBase): param.DateRange: DateRangeSlider } - @classmethod - def applies(cls, obj): - return (is_parameterized(obj) or isinstance(obj, param.parameterized.Parameters)) + _rerender_params = [] - def __init__(self, object, **params): + def __init__(self, object=None, **params): if isinstance(object, param.parameterized.Parameters): object = object.cls if object.self is None else object.self - if 'name' not in params: - params['name'] = object.name - if 'parameters' not in params: + if 'parameters' not in params and object is not None: params['parameters'] = [p for p in object.param if p != 'name'] super(Param, self).__init__(object, **params) self._updating = False - # Construct widgets - self._widgets = self._get_widgets() - widgets = [widget for widgets in self._widgets.values() for widget in widgets] - # Construct Layout kwargs = {p: v for p, v in self.get_param_values() if p in Layoutable.param} - self._widget_box = self.default_layout(*widgets, **kwargs) + self._widget_box = self.default_layout(**kwargs) layout = self.expand_layout if isinstance(layout, Panel): @@ -164,9 +157,10 @@ def __init__(self, object, **params): raise ValueError('expand_layout expected to be a panel.layout.Panel' 'type or instance, found %s type.' % type(layout).__name__) - - if not (self.expand_button == False and not self.expand): - self._link_subobjects() + self.param.watch(self._update_widgets, [ + 'object', 'parameters', 'display_threshold', 'expand_button', + 'expand', 'expand_layout', 'widgets', 'show_labels', 'show_name']) + self._update_widgets() def __repr__(self, depth=0): cls = type(self).__name__ @@ -178,7 +172,7 @@ def __repr__(self, depth=0): if v is self.param[p].default: continue elif v is None: continue elif isinstance(v, string_types) and v == '': continue - elif p == 'object' or (p == 'name' and v.startswith(obj_cls)): continue + elif p == 'object' or (p == 'name' and (v.startswith(obj_cls) or v.startswith(cls))): continue elif p == 'parameters' and v == parameters: continue try: params.append('%s=%s' % (p, abbreviated_repr(v))) @@ -188,6 +182,47 @@ def __repr__(self, depth=0): template = '{cls}({obj}, {params})' if params else '{cls}({obj})' return template.format(cls=cls, params=', '.join(params), obj=obj) + #---------------------------------------------------------------- + # Callback API + #---------------------------------------------------------------- + + def _synced_params(self): + ignored_params = ['name', 'default_layout'] + return [p for p in Layoutable.param if p not in ignored_params] + + def _update_widgets(self, *events): + parameters = [] + for event in sorted(events, key=lambda x: x.name): + if event.name == 'object': + if isinstance(event.new, param.parameterized.Parameters): + self.object = object.cls if object.self is None else object.self + return + if event.new is None: + parameters = None + else: + parameters = [p for p in event.new.param if p != 'name'] + if event.name == 'parameters': + parameters = None if event.new == [] else event.new + + if parameters != [] and parameters != self.parameters: + self.parameters = parameters + return + + for cb in list(self._callbacks): + if cb.inst in self._widget_box.objects: + cb.inst.param.unwatch(cb) + self._callbacks.remove(cb) + + # Construct widgets + if self.object is None: + self._widgets = {} + else: + self._widgets = self._get_widgets() + widgets = [widget for widgets in self._widgets.values() for widget in widgets] + self._widget_box.objects = widgets + if not (self.expand_button == False and not self.expand): + self._link_subobjects() + def _link_subobjects(self): for pname, widgets in self._widgets.items(): if not any(is_parameterized(getattr(w, 'value', None)) or @@ -205,13 +240,12 @@ def toggle_pane(change, parameter=pname): if existing: old_panel = existing[0] if not change.new: - old_panel._cleanup(final=old_panel._temporary) - self._expand_layout.pop(old_panel) + self._expand_layout.remove(old_panel) elif change.new: kwargs = {k: v for k, v in self.get_param_values() if k not in ['name', 'object', 'parameters']} pane = Param(parameterized, name=parameterized.name, - _temporary=True, **kwargs) + **kwargs) if isinstance(self._expand_layout, Tabs): title = self.object.param[pname].label pane = (title, pane) @@ -232,7 +266,7 @@ def update_pane(change, parameter=pname): kwargs = {k: v for k, v in self.get_param_values() if k not in ['name', 'object', 'parameters']} pane = Param(parameterized, name=parameterized.name, - _temporary=True, **kwargs) + **kwargs) layout[layout.objects.index(existing[0])] = pane else: layout.pop(existing[0]) @@ -240,7 +274,7 @@ def update_pane(change, parameter=pname): watchers = [selector.param.watch(update_pane, 'value')] if toggle: watchers.append(toggle.param.watch(toggle_pane, 'value')) - self._callbacks['instance'] += watchers + self._callbacks += watchers if self.expand: if self.expand_button: @@ -248,14 +282,6 @@ def update_pane(change, parameter=pname): else: toggle_pane(namedtuple('Change', 'new')(True)) - def widget_type(cls, pobj): - ptype = type(pobj) - for t in classlist(ptype)[::-1]: - if t in cls._mapping: - if isinstance(cls._mapping[t], types.FunctionType): - return cls._mapping[t](pobj) - return cls._mapping[t] - def widget(self, p_name): """Get widget for param_name""" p_obj = self.object.param[p_name] @@ -266,7 +292,8 @@ def widget(self, p_name): widget_class = self.widgets[p_name] value = getattr(self.object, p_name) - kw = dict(value=value, disabled=p_obj.constant, name=p_obj.label) + label = p_obj.label if self.show_labels else '' + kw = dict(value=value, disabled=p_obj.constant, name=label) if hasattr(p_obj, 'get_range'): options = p_obj.get_range() @@ -284,8 +311,13 @@ def widget(self, p_name): widget_class = LiteralInput kwargs = {k: v for k, v in kw.items() if k in widget_class.param} - widget = widget_class(**kwargs) - watchers = self._callbacks['instance'] + + if isinstance(widget_class, Widget): + widget = widget_class + else: + widget = widget_class(**kwargs) + + watchers = self._callbacks if isinstance(p_obj, param.Action): widget.button_type = 'success' def action(change): @@ -356,9 +388,9 @@ def link(change): else: return [widget] - def _cleanup(self, root=None, final=False): - self.layout._cleanup(root, final) - super(Param, self)._cleanup(root, final) + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- def _get_widgets(self): """Return name,widget boxes for all parameters (i.e., a property sheet)""" @@ -389,14 +421,36 @@ def _get_widgets(self): def _get_root(self, doc, comm=None): root = self.layout._get_root(doc, comm) - self._models[root.ref['id']] = root + ref = root.ref['id'] + self._models[ref] = (root, None) + state._views[ref] = (self, root, doc, comm) return root def _get_model(self, doc, root=None, parent=None, comm=None): model = self.layout._get_model(doc, root, parent, comm) - self._models[root.ref['id']] = model + self._models[root.ref['id']] = (model, parent) return model + def _cleanup(self, root): + self.layout._cleanup(root) + super(Param, self)._cleanup(root) + + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + + @classmethod + def applies(cls, obj): + return (is_parameterized(obj) or isinstance(obj, param.parameterized.Parameters)) + + @classmethod + def widget_type(cls, pobj): + ptype = type(pobj) + for t in classlist(ptype)[::-1]: + if t in cls._mapping: + if isinstance(cls._mapping[t], types.FunctionType): + return cls._mapping[t](pobj) + return cls._mapping[t] class ParamMethod(PaneBase): @@ -413,17 +467,17 @@ def __init__(self, object, **params): self._kwargs = {p: params.pop(p) for p in list(params) if p not in self.param} super(ParamMethod, self).__init__(object, **params) - kwargs = dict(self.get_param_values(), **dict(self._kwargs, _temporary=True)) + kwargs = dict(self.get_param_values(), **self._kwargs) del kwargs['object'] self._pane = Pane(self.object(), **kwargs) self._inner_layout = Row(self._pane, **{k: v for k, v in params.items() if k in Row.param}) + self._link_object_params() - @classmethod - def applies(cls, obj): - return inspect.ismethod(obj) and isinstance(get_method_owner(obj), param.Parameterized) + #---------------------------------------------------------------- + # Callback API + #---------------------------------------------------------------- - def _link_object_params(self, doc, root, parent, comm): - ref = root.ref['id'] + def _link_object_params(self): parameterized = get_method_owner(self.object) params = parameterized.param.params_depended_on(self.object.__name__) deps = params @@ -434,7 +488,7 @@ def update_pane(*events): new_deps = parameterized.param.params_depended_on(self.object.__name__) for p in list(deps): if p in new_deps: continue - watchers = self._callbacks.get(ref, []) + watchers = self._callbacks for w in list(watchers): if (w.inst is p.inst and w.cls is p.cls and p.name in w.parameter_names): @@ -449,7 +503,7 @@ def update_pane(*events): pobj = p.cls if p.inst is None else p.inst ps = [_p.name for _p in params] watcher = pobj.param.watch(update_pane, ps, p.what) - self._callbacks[ref].append(watcher) + self._callbacks.append(watcher) for p in params: deps.append(p) @@ -465,18 +519,13 @@ def update_pane(*events): pvals = dict(self._pane.get_param_values()) new_params = {k: v for k, v in new_object.get_param_values() if k != 'name' and v is not pvals[k]} - try: - self._pane.set_param(**new_params) - except: - raise - finally: - new_object._cleanup(final=new_object._temporary) + self._pane.set_param(**new_params) else: self._pane.object = new_object return # Replace pane entirely - kwargs = dict(self.get_param_values(), _temporary=True, **self._kwargs) + kwargs = dict(self.get_param_values(), **self._kwargs) del kwargs['object'] self._pane = Pane(new_object, **kwargs) self._inner_layout[0] = self._pane @@ -486,23 +535,34 @@ def update_pane(*events): pobj = (p.inst or p.cls) ps = [_p.name for _p in params] watcher = pobj.param.watch(update_pane, ps, p.what) - self._callbacks[ref].append(watcher) + self._callbacks.append(watcher) + + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- def _get_model(self, doc, root=None, parent=None, comm=None): if root is None: return self._get_root(doc, comm) ref = root.ref['id'] - if ref in self._callbacks: + if ref in self._models: self._cleanup(root) model = self._inner_layout._get_model(doc, root, parent, comm) - self._link_object_params(doc, root, parent, comm) - self._models[ref] = model + self._models[ref] = (model, parent) return model - def _cleanup(self, root=None, final=False): - self._inner_layout._cleanup(root, final) - super(ParamMethod, self)._cleanup(root, final) + def _cleanup(self, root=None): + self._inner_layout._cleanup(root) + super(ParamMethod, self)._cleanup(root) + + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + + @classmethod + def applies(cls, obj): + return inspect.ismethod(obj) and isinstance(get_method_owner(obj), param.Parameterized) class JSONInit(param.Parameterized): diff --git a/panel/tests/test_holoviews.py b/panel/tests/test_holoviews.py index 56c1d7840c..9eca3a8c8c 100644 --- a/panel/tests/test_holoviews.py +++ b/panel/tests/test_holoviews.py @@ -6,7 +6,8 @@ import pytest from bokeh.models import (Row as BkRow, Column as BkColumn, GlyphRenderer, - Scatter, Line, GridBox) + Scatter, Line, GridBox, Select as BkSelect, + Slider as BkSlider) from bokeh.plotting import Figure from panel.layout import Column, Row @@ -19,7 +20,6 @@ hv = None hv_available = pytest.mark.skipif(hv is None, reason="requires holoviews") -from .test_layout import get_div from .test_panes import mpl_available @@ -40,22 +40,44 @@ def test_holoviews_pane_mpl_renderer(document, comm): row = pane._get_root(document, comm=comm) assert isinstance(row, BkRow) assert len(row.children) == 1 - assert len(pane._callbacks) == 1 model = row.children[0] - assert pane._models[row.ref['id']] is model - div = get_div(model) - assert 'False' - + widget.value = True assert div.text == '
True
' @@ -158,19 +156,17 @@ def test(a): return a if a else BkDiv(text='Test') interact_pane = interactive(test, a=False) - pane = interact_pane._pane widget = interact_pane._widgets['a'] assert isinstance(widget, widgets.Checkbox) assert widget.value == False - column = interact_pane.layout._get_model(document, comm=comm) + column = interact_pane.layout._get_root(document, comm=comm) assert isinstance(column, BkColumn) - div = get_div(column.children[1].children[0]) + div = column.children[1].children[0] assert div.text == 'Test' - + widget.value = True - assert pane._callbacks == {} - div = get_div(column.children[1].children[0]) + div = column.children[1].children[0] assert div.text == '
True
' def test_interact_replaces_model(document, comm): @@ -183,27 +179,20 @@ def test(a): assert isinstance(widget, widgets.Checkbox) assert widget.value == False - column = interact_pane.layout._get_model(document, comm=comm) + column = interact_pane.layout._get_root(document, comm=comm) assert isinstance(column, BkColumn) div = column.children[1].children[0] assert isinstance(div, BkDiv) assert div.text == 'Test' - assert len(interact_pane._callbacks['instance']) == 1 - assert column.ref['id'] in pane._callbacks - assert pane._models[column.ref['id']] is div - + assert pane._models[column.ref['id']][0] is div + widget.value = True - assert column.ref['id'] not in pane._callbacks - assert pane._callbacks == {} new_pane = interact_pane._pane assert new_pane is not pane new_div = column.children[1].children[0] assert isinstance(new_div, BkDiv) assert new_div.text == '

ABC

' - assert len(interact_pane._callbacks['instance']) == 1 - assert column.ref['id'] in new_pane._callbacks - assert new_pane._models[column.ref['id']] is new_div + assert new_pane._models[column.ref['id']][0] is new_div interact_pane._cleanup(column) assert len(interact_pane._callbacks) == 1 - assert pane._callbacks == {} diff --git a/panel/tests/test_layout.py b/panel/tests/test_layout.py index 622ec1f73d..6ad7a0d1b6 100644 --- a/panel/tests/test_layout.py +++ b/panel/tests/test_layout.py @@ -8,6 +8,7 @@ from panel.pane import Bokeh, Pane from panel.param import Param + @pytest.fixture def tabs(document, comm): """Set up a tabs instance""" @@ -21,15 +22,18 @@ def assert_tab_is_similar(tab1, tab2): assert tab1.name == tab2.name assert tab1.title == tab2.title -def get_div(box): - # Temporary utilities to unpack widget boxes - if isinstance(box, Div): - return box - return box.children[0] +@pytest.mark.parametrize('layout', [Column, Row, Tabs, Spacer]) +def test_layout_model_cache_cleanup(layout, document, comm): + l = layout() + + model = l._get_root(document, comm) -def get_divs(children): - return [get_div(c) for c in children] + assert model.ref['id'] in l._models + assert l._models[model.ref['id']] == (model, None) + + l._cleanup(model) + assert l._models == {} @pytest.mark.parametrize('panel', [Column, Row]) @@ -158,12 +162,10 @@ def test_layout_setitem(panel, document, comm): model = layout._get_root(document, comm=comm) - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is model.children[0] + assert p1._models[model.ref['id']][0] is model.children[0] div3 = Div() layout[0] = div3 assert model.children == [div3, div2] - assert p1._callbacks == {} assert p1._models == {} @@ -188,15 +190,12 @@ def test_layout_setitem_replace_all(panel, document, comm): model = layout._get_root(document, comm=comm) - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is model.children[0] + assert p1._models[model.ref['id']][0] is model.children[0] div3 = Div() div4 = Div() layout[:] = [div3, div4] assert model.children == [div3, div4] - assert p1._callbacks == {} assert p1._models == {} - assert p2._callbacks == {} assert p2._models == {} @@ -222,15 +221,12 @@ def test_layout_setitem_replace_slice(panel, document, comm): model = layout._get_root(document, comm=comm) - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is model.children[0] + assert p1._models[model.ref['id']][0] is model.children[0] div3 = Div() div4 = Div() layout[1:] = [div3, div4] assert model.children == [div1, div3, div4] - assert p2._callbacks == {} assert p2._models == {} - assert p3._callbacks == {} assert p3._models == {} @@ -269,11 +265,9 @@ def test_layout_pop(panel, document, comm): model = layout._get_root(document, comm=comm) - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is model.children[0] + assert p1._models[model.ref['id']][0] is model.children[0] layout.pop(0) assert model.children == [div2] - assert p1._callbacks == {} assert p1._models == {} @@ -286,11 +280,9 @@ def test_layout_remove(panel, document, comm): model = layout._get_root(document, comm=comm) - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is model.children[0] + assert p1._models[model.ref['id']][0] is model.children[0] layout.remove(p1) assert model.children == [div2] - assert p1._callbacks == {} assert p1._models == {} @@ -303,11 +295,9 @@ def test_layout_clear(panel, document, comm): model = layout._get_root(document, comm=comm) - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is model.children[0] + assert p1._models[model.ref['id']][0] is model.children[0] layout.clear() assert model.children == [] - assert p1._callbacks == p2._callbacks == {} assert p1._models == p2._models == {} @@ -324,6 +314,7 @@ def test_tabs_basic_constructor(document, comm): assert 'plain' in tab1.child.text assert 'text' in tab2.child.text + def test_tabs_constructor(document, comm): div1 = Div() div2 = Div() @@ -636,15 +627,13 @@ def test_tabs_setitem(document, comm): model = tabs._get_root(document, comm=comm) tab1, tab2 = model.tabs - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is tab1.child + assert p1._models[model.ref['id']][0] is tab1.child div3 = Div() tabs[0] = ('C', div3) tab1, tab2 = model.tabs assert tab1.child is div3 assert tab1.title == 'C' assert tab2.child is div2 - assert p1._callbacks == {} assert p1._models == {} @@ -667,8 +656,7 @@ def test_tabs_setitem_replace_all(document, comm): model = layout._get_root(document, comm=comm) - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is model.tabs[0].child + assert p1._models[model.ref['id']][0] is model.tabs[0].child div3 = Div() div4 = Div() layout[:] = [('B', div3), ('C', div4)] @@ -677,9 +665,7 @@ def test_tabs_setitem_replace_all(document, comm): assert tab1.title == 'B' assert tab2.child is div4 assert tab2.title == 'C' - assert p1._callbacks == {} assert p1._models == {} - assert p2._callbacks == {} assert p2._models == {} @@ -703,8 +689,7 @@ def test_tabs_setitem_replace_slice(document, comm): model = layout._get_root(document, comm=comm) - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is model.tabs[0].child + assert p1._models[model.ref['id']][0] is model.tabs[0].child div3 = Div() div4 = Div() layout[1:] = [('D', div3), ('E', div4)] @@ -715,9 +700,7 @@ def test_tabs_setitem_replace_slice(document, comm): assert tab2.title == 'D' assert tab3.child is div4 assert tab3.title == 'E' - assert p2._callbacks == {} assert p2._models == {} - assert p3._callbacks == {} assert p3._models == {} @@ -754,13 +737,11 @@ def test_tabs_pop(document, comm): model = tabs._get_root(document, comm=comm) tab1 = model.tabs[0] - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is tab1.child + assert p1._models[model.ref['id']][0] is tab1.child tabs.pop(0) assert len(model.tabs) == 1 tab1 = model.tabs[0] assert tab1.child is div2 - assert p1._callbacks == {} assert p1._models == {} @@ -773,13 +754,11 @@ def test_tabs_remove(document, comm): model = tabs._get_root(document, comm=comm) tab1 = model.tabs[0] - assert model.ref['id'] in p1._callbacks - assert p1._models[model.ref['id']] is tab1.child + assert p1._models[model.ref['id']][0] is tab1.child tabs.remove(p1) assert len(model.tabs) == 1 tab1 = model.tabs[0] assert tab1.child is div2 - assert p1._callbacks == {} assert p1._models == {} @@ -794,7 +773,6 @@ def test_tabs_clear(document, comm): tabs.clear() assert tabs._names == [] assert len(model.tabs) == 0 - assert p1._callbacks == p2._callbacks == {} assert p1._models == p2._models == {} diff --git a/panel/tests/test_layoutable.py b/panel/tests/test_layoutable.py new file mode 100644 index 0000000000..ecfd5eb40a --- /dev/null +++ b/panel/tests/test_layoutable.py @@ -0,0 +1,70 @@ +from __future__ import absolute_import + +import pytest + +from panel.layout import Column, Row, Tabs, Spacer +from panel.pane import (HTML, Str, PNG, JPG, GIF, SVG, Markdown, LaTeX, + Matplotlib, RGGPlot, YT, Plotly, Vega) + +from .test_widgets import all_widgets + + +def check_layoutable_properties(layoutable, model): + layoutable.background = '#ffffff' + assert model.background == '#ffffff' + + layoutable.css_classes = ['custom_class'] + assert model.css_classes == ['custom_class'] + + layoutable.width = 500 + assert model.width == 500 + + layoutable.height = 450 + assert model.height == 450 + + layoutable.min_height = 300 + assert model.min_height == 300 + + layoutable.min_width = 250 + assert model.min_width == 250 + + layoutable.max_height = 600 + assert model.max_height == 600 + + layoutable.max_width = 550 + assert model.max_width == 550 + + layoutable.margin = 10 + assert model.margin == (10, 10, 10, 10) + + layoutable.sizing_mode = 'stretch_width' + assert model.sizing_mode == 'stretch_width' + + layoutable.width_policy = 'max' + assert model.width_policy == 'max' + + layoutable.height_policy = 'min' + assert model.height_policy == 'min' + + +@pytest.mark.parametrize('pane', [Str, Markdown, HTML, PNG, JPG, SVG, + GIF, LaTeX, Matplotlib, RGGPlot, YT, + Plotly, Vega]) +def test_pane_layout_properties(pane, document, comm): + p = pane() + model = p._get_root(document, comm) + check_layoutable_properties(p, model) + + +@pytest.mark.parametrize('widget', all_widgets) +def test_widget_layout_properties(widget, document, comm): + w = widget() + model = w._get_root(document, comm) + check_layoutable_properties(w, model) + + +@pytest.mark.parametrize('layout', [Column, Row, Tabs, Spacer]) +def test_layout_properties(layout, document, comm): + l = layout() + model = l._get_root(document, comm) + check_layoutable_properties(l, model) diff --git a/panel/tests/test_links.py b/panel/tests/test_links.py index f835cd1d0a..a360cf9344 100644 --- a/panel/tests/test_links.py +++ b/panel/tests/test_links.py @@ -28,13 +28,12 @@ def test_pnwidget_hvplot_links(document, comm): assert len(hv_views) == 1 assert len(widg_views) == 1 - slider = widg_views[0]._models[model.ref['id']] - scatter = hv_views[0]._plots[model.ref['id']].handles['glyph'] + slider = widg_views[0]._models[model.ref['id']][0] + scatter = hv_views[0]._plots[model.ref['id']][0].handles['glyph'] link_customjs = slider.js_property_callbacks['change:value'][-1] assert link_customjs.args['source'] is slider assert link_customjs.args['target'] is scatter - code = ("value = source['value'];" "try { property = target.properties['size'];" @@ -58,7 +57,7 @@ def test_bkwidget_hvplot_links(document, comm): assert len(hv_views) == 1 slider = bokeh_widget - scatter = hv_views[0]._plots[model.ref['id']].handles['glyph'] + scatter = hv_views[0]._plots[model.ref['id']][0].handles['glyph'] link_customjs = slider.js_property_callbacks['change:value'][-1] assert link_customjs.args['source'] is slider @@ -113,8 +112,8 @@ def test_link_with_customcode(document, comm): assert len(hv_views) == 1 assert len(widg_views) == 1 - range_slider = widg_views[0]._models[model.ref['id']] - x_range = hv_views[0]._plots[model.ref['id']].handles['x_range'] + range_slider = widg_views[0]._models[model.ref['id']][0] + x_range = hv_views[0]._plots[model.ref['id']][0].handles['x_range'] link_customjs = range_slider.js_property_callbacks['change:value'][-1] assert link_customjs.args['source'] is range_slider diff --git a/panel/tests/test_panes.py b/panel/tests/test_panes.py index 96addfc68f..2820132f35 100644 --- a/panel/tests/test_panes.py +++ b/panel/tests/test_panes.py @@ -33,7 +33,6 @@ markdown_available = pytest.mark.skipif(markdown is None, reason="requires markdown") from .fixtures import mpl_figure -from .test_layout import get_div def test_get_bokeh_pane_type(): @@ -50,21 +49,18 @@ def test_bokeh_pane(document, comm): assert isinstance(row, BkRow) assert len(row.children) == 1 model = row.children[0] - assert row.ref['id'] in pane._callbacks - assert get_div(model) is div - assert pane._models[row.ref['id']] is model + assert model is div + assert pane._models[row.ref['id']][0] is model # Replace Pane.object div2 = Div() pane.object = div2 new_model = row.children[0] - assert get_div(new_model) is div2 - assert row.ref['id'] in pane._callbacks - assert pane._models[row.ref['id']] is new_model + assert new_model is div2 + assert pane._models[row.ref['id']][0] is new_model # Cleanup pane._cleanup(row) - assert pane._callbacks == {} assert pane._models == {} @@ -84,20 +80,17 @@ def test_matplotlib_pane(document, comm): # Create pane model = pane._get_root(document, comm=comm) - assert model.ref['id'] in pane._callbacks assert 'Markdown

" # Replace Pane.object pane.object = "*Markdown*" - assert model.ref['id'] in pane._callbacks - assert pane._models[model.ref['id']] is model + assert pane._models[model.ref['id']][0] is model assert model.text == "

Markdown

" # Cleanup pane._cleanup(model) - assert pane._callbacks == {} assert pane._models == {} @@ -133,19 +123,16 @@ def test_html_pane(document, comm): # Create pane model = pane._get_root(document, comm=comm) - assert model.ref['id'] in pane._callbacks - assert pane._models[model.ref['id']] is model + assert pane._models[model.ref['id']][0] is model assert model.text == "

Test

" # Replace Pane.object pane.object = "

Test

" - assert model.ref['id'] in pane._callbacks - assert pane._models[model.ref['id']] is model + assert pane._models[model.ref['id']][0] is model assert model.text == "

Test

" # Cleanup pane._cleanup(model) - assert pane._callbacks == {} assert pane._models == {} @@ -156,15 +143,14 @@ def test_latex_pane(document, comm): # Create pane model = pane._get_root(document, comm=comm) - assert model.ref['id'] in pane._callbacks - assert pane._models[model.ref['id']] is model + assert pane._models[model.ref['id']][0] is model # Just checks for a PNG, not a specific rendering, to avoid # false alarms when formatting of the PNG changes assert model.text[0:32] == "<h1>Test</h1>" # Replace Pane.object pane.object = "

Test

" - assert model.ref['id'] in pane._callbacks - assert pane._models[model.ref['id']] is model + assert pane._models[model.ref['id']][0] is model assert model.text == "
<h2>Test</h2>
" # Cleanup pane._cleanup(model) - assert pane._callbacks == {} assert pane._models == {} @@ -198,8 +181,7 @@ def test_svg_pane(document, comm): # Create pane model = pane._get_root(document, comm=comm) - assert model.ref['id'] in pane._callbacks - assert pane._models[model.ref['id']] is model + assert pane._models[model.ref['id']][0] is model assert model.text.startswith(' """ pane.object = circle - assert model.ref['id'] in pane._callbacks - assert pane._models[model.ref['id']] is model + assert pane._models[model.ref['id']][0] is model assert model.text.startswith('Test' + + assert isinstance(widget, Slider) + assert widget.start == 0 + assert widget.end == 10 + + +def test_set_parameters(document, comm): + class Test(param.Parameterized): + a = param.Number(bounds=(0, 10)) + b = param.String(default='A') + + pane = Param(Test()) + + model = pane._get_root(document, comm=comm) + + assert len(model.children) == 3 + title, slider, text = model.children + assert isinstance(title, Div) + assert isinstance(slider, Slider) + assert isinstance(text, TextInput) + + pane.parameters = ['b'] + + assert len(model.children) == 2 + title, text = model.children + assert isinstance(title, Div) + assert isinstance(text, TextInput) + + +def test_set_display_threshold(document, comm): + class Test(param.Parameterized): + a = param.Number(bounds=(0, 10), precedence=1) + b = param.String(default='A', precedence=2) + + pane = Param(Test()) + + model = pane._get_root(document, comm=comm) + + assert len(model.children) == 3 + title, slider, text = model.children + assert isinstance(title, Div) + assert isinstance(slider, Slider) + assert isinstance(text, TextInput) + + pane.display_threshold = 1.5 + + assert len(model.children) == 2 + title, text = model.children + assert isinstance(title, Div) + assert isinstance(text, TextInput) + + +def test_set_widgets(document, comm): + class Test(param.Parameterized): + a = param.Number(default=1, bounds=(0, 10), precedence=1) + b = param.String(default='A', precedence=2) + + pane = Param(Test()) + + model = pane._get_root(document, comm=comm) + + assert len(model.children) == 3 + title, slider, text = model.children + assert isinstance(title, Div) + assert isinstance(slider, Slider) + assert isinstance(text, TextInput) + + pane.widgets = {'a': LiteralInput(value=1, type=(float, int))} + + assert len(model.children) == 3 + title, number, text = model.children + assert isinstance(title, Div) + assert isinstance(number, TextInput) + assert isinstance(text, TextInput) + + +def test_set_show_name(document, comm): + class Test(param.Parameterized): + a = param.Number(bounds=(0, 10)) + + pane = Param(Test()) + + model = pane._get_root(document, comm=comm) + + assert len(model.children) == 2 + title, widget = model.children + assert isinstance(title, Div) + assert isinstance(widget, Slider) + + pane.show_name = False + + assert len(model.children) == 1 + assert isinstance(model.children[0], Slider) + + +def test_set_show_labels(document, comm): + class Test(param.Parameterized): + a = param.Number(bounds=(0, 10)) + + pane = Param(Test()) + + model = pane._get_root(document, comm=comm) + + assert len(model.children) == 2 + title, widget = model.children + assert isinstance(title, Div) + assert isinstance(widget, Slider) + assert widget.title == 'A' + + pane.show_labels = False + + assert len(model.children) == 2 + assert isinstance(model.children[1], Slider) + assert model.children[1].title == '' + + def test_expand_param_subobject(document, comm): class Test(param.Parameterized): a = param.Parameter() test = Test(a=Test(name='Nested')) - test_pane = Pane(test, _temporary=True) + test_pane = Pane(test) model = test_pane._get_root(document, comm=comm) toggle = model.children[2] @@ -387,7 +520,6 @@ class Test(param.Parameterized): assert len(model.children) == 4 _, _, _, subpanel = test_pane.layout.objects col = model.children[3] - assert 'instance' in subpanel._callbacks assert isinstance(col, BkColumn) assert isinstance(col, BkColumn) assert len(col.children) == 2 @@ -398,8 +530,6 @@ class Test(param.Parameterized): # Collapse subpanel test_pane._widgets['a'][1].value = False assert len(model.children) == 3 - assert subpanel._callbacks == {} - def test_switch_param_subobject(document, comm): @@ -410,7 +540,7 @@ class Test(param.Parameterized): o2 = Test(name='Subobject 2') Test.param['a'].objects = [o1, o2, 3] test = Test(a=o1, name='Nested') - test_pane = Pane(test, _temporary=True) + test_pane = Pane(test) model = test_pane._get_root(document, comm=comm) toggle = model.children[2] @@ -421,7 +551,6 @@ class Test(param.Parameterized): assert len(model.children) == 4 _, _, _, subpanel = test_pane.layout.objects col = model.children[3] - assert 'instance' in subpanel._callbacks assert isinstance(col, BkColumn) assert len(col.children) == 3 div, select, widget = col.children @@ -429,10 +558,9 @@ class Test(param.Parameterized): assert isinstance(select, Select) # Switch subobject - test_pane._widgets['a'][0].value = o2 + test_pane._widgets['a'][0].value = o2 _, _, _, subpanel = test_pane.layout.objects col = model.children[3] - assert 'instance' in subpanel._callbacks assert isinstance(col, BkColumn) assert len(col.children) == 3 div, select, widget = col.children @@ -442,9 +570,9 @@ class Test(param.Parameterized): # Collapse subpanel test_pane._widgets['a'][1].value = False assert len(model.children) == 3 - assert subpanel._callbacks == {} + assert subpanel._models == {} + - def test_expand_param_subobject_into_row(document, comm): class Test(param.Parameterized): @@ -452,7 +580,7 @@ class Test(param.Parameterized): test = Test(a=Test(name='Nested')) row = Row() - test_pane = Pane(test, expand_layout=row, _temporary=True) + test_pane = Pane(test, expand_layout=row) layout = Row(test_pane, row) model = layout._get_root(document, comm=comm) @@ -464,7 +592,6 @@ class Test(param.Parameterized): assert len(model.children) == 2 subpanel = row.objects[0] row = model.children[1] - assert 'instance' in subpanel._callbacks assert isinstance(row, BkRow) assert len(row.children) == 1 box = row.children[0] @@ -477,15 +604,15 @@ class Test(param.Parameterized): # Collapse subpanel test_pane._widgets['a'][1].value = False assert len(row.children) == 0 - assert subpanel._callbacks == {} + assert subpanel._models == {} + - def test_expand_param_subobject_expand(document, comm): class Test(param.Parameterized): a = param.Parameter() test = Test(a=Test(name='Nested')) - test_pane = Pane(test, _temporary=True, expand=True, expand_button=True) + test_pane = Pane(test, expand=True, expand_button=True) model = test_pane._get_root(document, comm=comm) toggle = model.children[2] @@ -495,7 +622,6 @@ class Test(param.Parameterized): assert len(model.children) == 4 _, _, _, subpanel = test_pane.layout.objects col = model.children[3] - assert 'instance' in subpanel._callbacks assert isinstance(col, BkColumn) assert len(col.children) == 2 div, widget = col.children @@ -505,7 +631,7 @@ class Test(param.Parameterized): # Collapse subpanel test_pane._widgets['a'][1].value = False assert len(model.children) == 3 - assert subpanel._callbacks == {} + assert subpanel._models == {} def test_param_subobject_expand_no_toggle(document, comm): @@ -513,7 +639,7 @@ class Test(param.Parameterized): a = param.Parameter() test = Test(a=Test(name='Nested')) - test_pane = Pane(test, _temporary=True, expand=True, + test_pane = Pane(test, expand=True, expand_button=False) model = test_pane._get_root(document, comm=comm) @@ -523,17 +649,16 @@ class Test(param.Parameterized): # Expand subpane _, _, subpanel = test_pane.layout.objects div, widget = model.children[2].children - assert 'instance' in subpanel._callbacks assert div.text == 'Nested' assert isinstance(widget, BkTextInput) - + def test_expand_param_subobject_tabs(document, comm): class Test(param.Parameterized): abc = param.Parameter() test = Test(abc=Test(name='Nested'), name='A') - test_pane = Pane(test, expand_layout=Tabs, _temporary=True) + test_pane = Pane(test, expand_layout=Tabs) model = test_pane._get_root(document, comm=comm) toggle = model.tabs[0].child.children[1] @@ -545,7 +670,6 @@ class Test(param.Parameterized): _, subpanel = test_pane.layout.objects subtabs = model.tabs[1].child assert model.tabs[1].title == 'Abc' - assert 'instance' in subpanel._callbacks assert isinstance(subtabs, BkTabs) assert len(subtabs.tabs) == 1 assert subtabs.tabs[0].title == 'Nested' @@ -559,7 +683,6 @@ class Test(param.Parameterized): # Collapse subpanel test_pane._widgets['abc'][1].value = False assert len(model.tabs) == 1 - assert subpanel._callbacks == {} class View(param.Parameterized): @@ -601,26 +724,20 @@ def test_param_method_pane(document, comm): assert len(row.children) == 1 inner_row = row.children[0] model = inner_row.children[0] - div = get_div(model) - assert row.ref['id'] in inner_pane._callbacks - assert pane._models[row.ref['id']] is inner_row - assert isinstance(div, Div) - assert div.text == '0' + assert pane._models[row.ref['id']][0] is inner_row + assert isinstance(model, Div) + assert model.text == '0' # Update pane test.a = 5 new_model = inner_row.children[0] - div = get_div(new_model) assert inner_pane is pane._pane - assert div.text == '5' - assert row.ref['id'] in inner_pane._callbacks - assert pane._models[row.ref['id']] is inner_row + assert new_model.text == '5' + assert pane._models[row.ref['id']][0] is inner_row # Cleanup pane pane._cleanup(row) - assert pane._callbacks == {} assert pane._models == {} - assert inner_pane._callbacks == {} assert inner_pane._models == {} @@ -637,34 +754,28 @@ def test_param_method_pane_subobject(document, comm): assert len(row.children) == 1 inner_row = row.children[0] model = inner_row.children[0] - div = get_div(model) + assert isinstance(model, Div) + assert model.text == '42' # Ensure that subobject is being watched - assert row.ref['id'] in pane._callbacks - watchers = pane._callbacks[row.ref['id']] + watchers = pane._callbacks assert any(w.inst is subobject for w in watchers) - assert row.ref['id'] in inner_pane._callbacks - assert pane._models[row.ref['id']] is inner_row - assert isinstance(div, Div) - assert div.text == '42' + assert pane._models[row.ref['id']][0] is inner_row # Ensure that switching the subobject triggers update in watchers new_subobject = View(name='Nested', a=42) test.b = new_subobject - assert row.ref['id'] in pane._callbacks - assert pane._models[row.ref['id']] is inner_row - watchers = pane._callbacks[row.ref['id']] + assert pane._models[row.ref['id']][0] is inner_row + watchers = pane._callbacks assert not any(w.inst is subobject for w in watchers) assert any(w.inst is new_subobject for w in watchers) - + # Cleanup pane pane._cleanup(row) - assert pane._callbacks == {} assert pane._models == {} - assert inner_pane._callbacks == {} assert inner_pane._models == {} - + @mpl_available def test_param_method_pane_mpl(document, comm): test = View() @@ -678,26 +789,20 @@ def test_param_method_pane_mpl(document, comm): assert len(row.children) == 1 inner_row = row.children[0] model = inner_row.children[0] - assert row.ref['id'] in inner_pane._callbacks - assert pane._models[row.ref['id']] is inner_row - div = get_div(model) - text = div.text + assert pane._models[row.ref['id']][0] is inner_row + text = model.text # Update pane test.a = 5 - model = inner_row.children[0] + new_model = inner_row.children[0] assert inner_pane is pane._pane - assert div is get_div(model) - assert div.text != text - assert len(inner_pane._callbacks) == 1 - assert row.ref['id'] in inner_pane._callbacks - assert pane._models[row.ref['id']] is inner_row + assert new_model is model + assert new_model.text != text + assert pane._models[row.ref['id']][0] is inner_row # Cleanup pane pane._cleanup(row) - assert pane._callbacks == {} assert pane._models == {} - assert inner_pane._callbacks == {} assert inner_pane._models == {} @@ -714,26 +819,19 @@ def test_param_method_pane_changing_type(document, comm): assert len(row.children) == 1 inner_row = row.children[0] model = inner_row.children[0] - assert row.ref['id'] in inner_pane._callbacks - - div = get_div(model) - text = div.text + text = model.text + assert text.startswith('DiscreteSlider: 1' widget.value = 2 - discrete_slider._comm_change({'value': 2}) + discrete_slider._slider._comm_change({'value': 2}) assert discrete_slider.value == 10 assert label.text == 'DiscreteSlider: 10' @@ -497,7 +524,6 @@ def test_discrete_slider(document, comm): assert label.text == 'DiscreteSlider: 100' - def test_discrete_date_slider(document, comm): dates = OrderedDict([('2016-01-0%d' % i, datetime(2016, 1, i)) for i in range(1, 4)]) discrete_slider = DiscreteSlider(name='DiscreteSlider', value=dates['2016-01-02'], @@ -518,7 +544,7 @@ def test_discrete_date_slider(document, comm): assert label.text == 'DiscreteSlider: 2016-01-02' widget.value = 2 - discrete_slider._comm_change({'value': 2}) + discrete_slider._slider._comm_change({'value': 2}) assert discrete_slider.value == dates['2016-01-03'] assert label.text == 'DiscreteSlider: 2016-01-03' @@ -527,7 +553,6 @@ def test_discrete_date_slider(document, comm): assert label.text == 'DiscreteSlider: 2016-01-01' - def test_discrete_slider_options_dict(document, comm): options = OrderedDict([('0.1', 0.1), ('1', 1), ('10', 10), ('100', 100)]) discrete_slider = DiscreteSlider(name='DiscreteSlider', value=1, @@ -546,7 +571,7 @@ def test_discrete_slider_options_dict(document, comm): assert label.text == 'DiscreteSlider: 1' widget.value = 2 - discrete_slider._comm_change({'value': 2}) + discrete_slider._slider._comm_change({'value': 2}) assert discrete_slider.value == 10 assert label.text == 'DiscreteSlider: 10' @@ -555,22 +580,26 @@ def test_discrete_slider_options_dict(document, comm): assert label.text == 'DiscreteSlider: 100' - def test_cross_select_constructor(document, comm): - cross_select = CrossSelector(options=['A', 'B', 'C', 1, 2, 3], value=['A', 1]) + cross_select = CrossSelector(options=['A', 'B', 'C', 1, 2, 3], value=['A', 1], size=5) - assert cross_select._lists[True].options == {'A': 'A', '1': '1'} - assert cross_select._lists[False].options == {'B': 'B', 'C': 'C', '2': '2', '3': '3'} + assert cross_select._lists[True].options == ['A', '1'] + assert cross_select._lists[False].options == ['B', 'C', '2', '3'] # Change selection cross_select.value = ['B', 2] - assert cross_select._lists[True].options == {'B': 'B', '2': '2'} - assert cross_select._lists[False].options == {'A': 'A', 'C': 'C', '1': '1', '3': '3'} + assert cross_select._lists[True].options == ['B', '2'] + assert cross_select._lists[False].options == ['A', 'C', '1', '3'] # Change options cross_select.options = {'D': 'D', '4': 4} - assert cross_select._lists[True].options == {} - assert cross_select._lists[False].options == {'D': 'D', '4': '4'} + assert cross_select._lists[True].options == [] + assert cross_select._lists[False].options == ['D', '4'] + + # Change size + cross_select.size = 5 + assert cross_select._lists[True].size == 5 + assert cross_select._lists[False].size == 5 # Query unselected item cross_select._search[False].value = 'D' @@ -578,17 +607,17 @@ def test_cross_select_constructor(document, comm): # Move queried item cross_select._buttons[True].param.trigger('clicks') - assert cross_select._lists[False].options == {'4': '4'} + assert cross_select._lists[False].options == ['4'] assert cross_select._lists[False].value == [] - assert cross_select._lists[True].options == {'D': 'D'} + assert cross_select._lists[True].options == ['D'] assert cross_select._lists[False].value == [] # Query selected item cross_select._search[True].value = 'D' cross_select._buttons[False].param.trigger('clicks') - assert cross_select._lists[False].options == {'D': 'D', '4': '4'} + assert cross_select._lists[False].options == ['D', '4'] assert cross_select._lists[False].value == ['D'] - assert cross_select._lists[True].options == {} + assert cross_select._lists[True].options == [] # Clear query cross_select._search[False].value = '' diff --git a/panel/viewable.py b/panel/viewable.py index 54745493ae..6fae3fc298 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -9,7 +9,6 @@ import signal import uuid from functools import partial -from collections import defaultdict import param @@ -237,9 +236,11 @@ def _get_root(self, doc, comm=None): """ root = self._get_model(doc, comm=comm) self._preprocess(root) + ref = root.ref['id'] + state._views[ref] = (self, root, doc, comm) return root - def _cleanup(self, model=None, final=False): + def _cleanup(self, model): """ Clean up method which is called when a Viewable is destroyed. @@ -247,11 +248,12 @@ def _cleanup(self, model=None, final=False): ---------- model: bokeh.model.Model Bokeh model for the view being cleaned up - final: boolean - Whether the Viewable should be destroyed entirely """ def _preprocess(self, root): + """ + Applies preprocessing hooks to the model. + """ for hook in self._preprocessing_hooks: hook(self, root) @@ -260,7 +262,6 @@ def _repr_mimebundle_(self, include=None, exclude=None): doc = _Document() comm = state._comm_manager.get_server_comm() model = self._get_root(doc, comm) - state._views[model.ref['id']] = (self, model) return render_mimebundle(model, doc, comm) def _server_destroy(self, session_context): @@ -268,7 +269,7 @@ def _server_destroy(self, session_context): Server lifecycle hook triggered when session is destroyed. """ doc = session_context._document - self._cleanup(self._documents[doc], final=self._temporary) + self._cleanup(self._documents[doc]) del self._documents[doc] def _modify_doc(self, server_id, doc): @@ -514,9 +515,10 @@ class Reactive(Viewable): the objects parameters and the underlying bokeh model either via the defined pyviz_comms.Comm type or when using bokeh server. - In order to link parameters with bokeh model instances the - _link_params and _link_props methods may be called in the - _get_model method. Since there may not be a 1-to-1 mapping between + In order to bi-directionally link parameters with bokeh model + instances the _link_params and _link_props methods define + callbacks triggered when either the parameter or bokeh property + values change. Since there may not be a 1-to-1 mapping between parameter and the model property the _process_property_change and _process_param_change may be overridden to apply any necessary transformations. @@ -534,11 +536,193 @@ class Reactive(Viewable): def __init__(self, **params): # temporary flag denotes panes created for temporary, internal # use which should be garbage collected once they have been used - self._temporary = params.pop('_temporary', False) super(Reactive, self).__init__(**params) self._processing = False self._events = {} - self._callbacks = defaultdict(list) + self._callbacks = [] + self._link_params() + + #---------------------------------------------------------------- + # Callback API + #---------------------------------------------------------------- + + def _update_model(self, events, msg, root, model, doc, comm=None): + if comm: + for attr, new in msg.items(): + setattr(model, attr, new) + event = doc._held_events[-1] if doc._held_events else None + if (event and event.model is model and event.attr == attr and + event.new is new): + continue + # If change did not trigger event trigger it manually + old = getattr(model, attr) + serializable_new = model.lookup(attr).serializable_value(model) + event = ModelChangedEvent(doc, model, attr, old, new, serializable_new) + _combine_document_events(event, doc._held_events) + else: + model.update(**msg) + + def _link_params(self): + def param_change(*events): + msgs = [] + for event in events: + msg = self._process_param_change({event.name: event.new}) + if msg: + msgs.append(msg) + + events = {event.name: event for event in events} + msg = {k: v for msg in msgs for k, v in msg.items()} + if not msg: + return + + for ref, (model, parent) in self._models.items(): + if ref not in state._views: + continue + viewable, root, doc, comm = state._views[ref] + if comm or state.curdoc: + self._update_model(events, msg, root, model, doc, comm) + if comm: + push(doc, comm) + else: + cb = partial(self._update_model, events, msg, root, model, doc, comm) + doc.add_next_tick_callback(cb) + + params = self._synced_params() + if params: + watcher = self.param.watch(param_change, params) + self._callbacks.append(watcher) + + def _link_props(self, model, properties, doc, root, comm=None): + if comm is None: + for p in properties: + model.on_change(p, partial(self._server_change, doc)) + else: + client_comm = state._comm_manager.get_client_comm(on_msg=self._comm_change) + for p in properties: + customjs = self._get_customjs(p, client_comm, root.ref['id']) + model.js_on_change(p, customjs) + + def _comm_change(self, msg): + if not msg: + return + self._events.update(msg) + self._change_event() + + def _server_change(self, doc, attr, old, new): + self._events.update({attr: new}) + if not self._processing: + self._processing = True + doc.add_timeout_callback(partial(self._change_event, doc), self._debounce) + + def _change_event(self, doc=None): + try: + state.curdoc = doc + events = self._events + self._events = {} + self.set_param(**self._process_property_change(events)) + except: + raise + else: + if self._events: + if doc: + doc.add_timeout_callback(partial(self._change_event, doc), self._debounce) + else: + self._change_event() + finally: + self._processing = False + state.curdoc = None + + def _get_customjs(self, change, client_comm, plot_id): + """ + Returns a CustomJS callback that can be attached to send the + model state across the notebook comms. + """ + # Abort callback if value matches last received event + abort = """ + const receiver = window.PyViz.receivers['{plot_id}']; + const events = receiver ? receiver._partial.content.events : []; + for (let event of events) {{ + if ((event.kind == 'ModelChanged') && (event.attr == '{change}') && + (cb_obj.id == event.model.id) && + (cb_obj['{change}'] == event.new)) {{ + return; + }} + }} + """.format(plot_id=plot_id, change=change) + data_template = "data = {{{change}: cb_obj['{change}']}};" + fetch_data = data_template.format(change=change) + self_callback = JS_CALLBACK.format(comm_id=client_comm.id, + timeout=self._timeout, + debounce=self._debounce, + plot_id=plot_id) + js_callback = CustomJS(code='\n'.join([abort, + fetch_data, + self_callback])) + return js_callback + + #---------------------------------------------------------------- + # Model API + #---------------------------------------------------------------- + + def _init_properties(self): + return {k: v for k, v in self.param.get_param_values() + if v is not None} + + def _synced_params(self): + return list(self.param) + + def _process_property_change(self, msg): + """ + Transform bokeh model property changes into parameter updates. + Should be overridden to provide appropriate mapping between + parameter value and bokeh model change. By default uses the + _rename class level attribute to map between parameter and + property names. + """ + inverted = {v: k for k, v in self._rename.items()} + return {inverted.get(k, k): v for k, v in msg.items()} + + def _process_param_change(self, msg): + """ + Transform parameter changes into bokeh model property updates. + Should be overridden to provide appropriate mapping between + parameter value and bokeh model change. By default uses the + _rename class level attribute to map between parameter and + property names. + """ + properties = {self._rename.get(k, k): v for k, v in msg.items() + if self._rename.get(k, False) is not None} + if 'width' in properties and self.sizing_mode is None: + properties['min_width'] = properties['width'] + if 'height' in properties and self.sizing_mode is None: + properties['min_height'] = properties['height'] + return properties + + def _cleanup(self, root): + super(Reactive, self)._cleanup(root) + + # Clean up comms + model, _ = self._models.pop(root.ref['id'], (None, None)) + if model is None: + return + + customjs = model.select({'type': CustomJS}) + pattern = "data\['comm_id'\] = \"(.*)\"" + for js in customjs: + comm_ids = list(re.findall(pattern, js.code)) + if not comm_ids: + continue + comm_id = comm_ids[0] + comm = state._comm_manager._comms.pop(comm_id, None) + if comm: + try: + comm.close() + except: + pass + + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- def link(self, target, callbacks=None, **links): """ @@ -583,7 +767,7 @@ def link(*events): _updating.pop(_updating.index(event.name)) params = list(callbacks) if callbacks else list(links) cb = self.param.watch(link, params) - self._callbacks['instance'].append(cb) + self._callbacks.append(cb) return cb def jslink(self, target, code=None, **links): @@ -626,159 +810,3 @@ def jslink(self, target, code=None, **links): for k, v in list(mapping.items()): mapping[k] = target._rename.get(v, v) return GenericLink(self, target, properties=links, code=code) - - def _cleanup(self, root=None, final=False): - super(Reactive, self)._cleanup(root, final) - if final: - watchers = self._callbacks.pop('instance', []) - for watcher in watchers: - obj = watcher.cls if watcher.inst is None else watcher.inst - obj.param.unwatch(watcher) - - if root is None: - return - - callbacks = self._callbacks.pop(root.ref['id'], {}) - for watcher in callbacks: - obj = watcher.cls if watcher.inst is None else watcher.inst - obj.param.unwatch(watcher) - - # Clean up comms - model = self._models.pop(root.ref['id'], None) - if model is None: - return - - customjs = model.select({'type': CustomJS}) - pattern = "data\['comm_id'\] = \"(.*)\"" - for js in customjs: - comm_ids = list(re.findall(pattern, js.code)) - if not comm_ids: - continue - comm_id = comm_ids[0] - comm = state._comm_manager._comms.pop(comm_id, None) - if comm: - try: - comm.close() - except: - pass - - def _init_properties(self): - return {k: v for k, v in self.param.get_param_values() - if v is not None} - - def _process_property_change(self, msg): - """ - Transform bokeh model property changes into parameter updates. - Should be overridden to provide appropriate mapping between - parameter value and bokeh model change. By default uses the - _rename class level attribute to map between parameter and - property names. - """ - inverted = {v: k for k, v in self._rename.items()} - return {inverted.get(k, k): v for k, v in msg.items()} - - def _process_param_change(self, msg): - """ - Transform parameter changes into bokeh model property updates. - Should be overridden to provide appropriate mapping between - parameter value and bokeh model change. By default uses the - _rename class level attribute to map between parameter and - property names. - """ - properties = {self._rename.get(k, k): v for k, v in msg.items() - if self._rename.get(k, False) is not None} - if 'width' in properties and self.sizing_mode is None: - properties['min_width'] = properties['width'] - elif self.min_width is None: - properties['min_width'] = None - if 'height' in properties and self.sizing_mode is None: - properties['min_height'] = properties['height'] - elif self.min_height is None: - properties['min_height'] = None - return properties - - def _link_params(self, model, params, doc, root, comm=None): - def param_change(*events): - msgs = [] - for event in events: - msg = self._process_param_change({event.name: event.new}) - if msg: - msgs.append(msg) - - if not msgs: return - - def update_model(): - update = {k: v for msg in msgs for k, v in msg.items()} - if comm: - for attr, new in update.items(): - setattr(model, attr, new) - event = doc._held_events[-1] if doc._held_events else None - if (event and event.model is model and event.attr == attr and - event.new is new): - continue - # If change did not trigger event trigger it manually - old = getattr(model, attr) - serializable_new = model.lookup(attr).serializable_value(model) - event = ModelChangedEvent(doc, model, attr, old, new, serializable_new) - _combine_document_events(event, doc._held_events) - else: - model.update(**update) - - if comm: - update_model() - push(doc, comm) - elif state.curdoc: - update_model() - else: - doc.add_next_tick_callback(update_model) - - ref = root.ref['id'] - watcher = self.param.watch(param_change, params) - self._callbacks[ref].append(watcher) - - def _link_props(self, model, properties, doc, root, comm=None): - if comm is None: - for p in properties: - model.on_change(p, partial(self._server_change, doc)) - else: - client_comm = state._comm_manager.get_client_comm(on_msg=self._comm_change) - for p in properties: - customjs = self._get_customjs(p, client_comm, root.ref['id']) - model.js_on_change(p, customjs) - - def _comm_change(self, msg): - if not msg: - return - self._events.update(msg) - self._change_event() - - def _server_change(self, doc, attr, old, new): - self._events.update({attr: new}) - if not self._processing: - self._processing = True - doc.add_timeout_callback(partial(self._change_event, doc), self._debounce) - - def _change_event(self, doc=None): - try: - state.curdoc = doc - events = self._events - self._events = {} - self.set_param(**self._process_property_change(events)) - finally: - self._processing = False - state.curdoc = None - - def _get_customjs(self, change, client_comm, plot_id): - """ - Returns a CustomJS callback that can be attached to send the - model state across the notebook comms. - """ - data_template = "data = {{{change}: cb_obj['{change}']}};" - fetch_data = data_template.format(change=change) - self_callback = JS_CALLBACK.format(comm_id=client_comm.id, - timeout=self._timeout, - debounce=self._debounce, - plot_id=plot_id) - js_callback = CustomJS(code='\n'.join([fetch_data, - self_callback])) - return js_callback diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index adea70df06..cfb21fc40e 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -4,7 +4,7 @@ """ from __future__ import absolute_import, division, unicode_literals -from .base import Widget # noqa +from .base import Widget, CompositeWidget # noqa from .button import Button, Toggle # noqa from .input import ( # noqa ColorPicker, Checkbox, DatetimeInput, DatePicker, FileInput, diff --git a/panel/widgets/base.py b/panel/widgets/base.py index 10b9c3a3ee..6be08ea78b 100644 --- a/panel/widgets/base.py +++ b/panel/widgets/base.py @@ -41,10 +41,17 @@ def _get_model(self, doc, root=None, parent=None, comm=None): if root is None: root = model # Link parameters and bokeh model - params = list(self.param) values = dict(self.get_param_values()) properties = list(self._process_param_change(values)) - self._models[root.ref['id']] = model - self._link_params(model, params, doc, root, comm) + self._models[root.ref['id']] = (model, parent) self._link_props(model, properties, doc, root, comm) return model + + +class CompositeWidget(Widget): + """ + A baseclass for widgets which are made up of two or more other + widgets + """ + + __abstract = True diff --git a/panel/widgets/button.py b/panel/widgets/button.py index d22931d500..e12d745d48 100644 --- a/panel/widgets/button.py +++ b/panel/widgets/button.py @@ -18,6 +18,8 @@ class _ButtonBase(Widget): _rename = {'name': 'label'} + __abstract = True + class Button(_ButtonBase): diff --git a/panel/widgets/input.py b/panel/widgets/input.py index 32707daa67..c26707d8da 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -84,6 +84,8 @@ class StaticText(Widget): _format = '{title}: {value}' + _rename = {'name': 'title', 'value': 'text'} + def _process_param_change(self, msg): msg = super(StaticText, self)._process_property_change(msg) msg.pop('title', None) @@ -95,7 +97,6 @@ def _process_param_change(self, msg): return msg - class DatePicker(Widget): value = param.Date(default=None) @@ -125,14 +126,13 @@ class ColorPicker(Widget): _rename = {'value': 'color', 'name': 'title'} - class LiteralInput(Widget): """ LiteralInput allows declaring Python literals using a text input widget. Optionally a type may be declared. """ - type = param.ClassSelector(default=None, class_=type, + type = param.ClassSelector(default=None, class_=(type, tuple), is_instance=True) value = param.Parameter(default=None) @@ -151,9 +151,10 @@ def _validate(self, event): if not isinstance(new, self.type): if event: self.value = event.old + types = repr(self.type) if isinstance(self.type, tuple) else self.type.__name__ raise ValueError('LiteralInput expected %s type but value %s ' 'is of type %s.' % - (self.type.__name__, new, type(new).__name__)) + (types, new, type(new).__name__)) def _process_property_change(self, msg): msg = super(LiteralInput, self)._process_property_change(msg) diff --git a/panel/widgets/player.py b/panel/widgets/player.py index be2cbd86ea..6484bab1d3 100644 --- a/panel/widgets/player.py +++ b/panel/widgets/player.py @@ -6,7 +6,9 @@ import param from ..models.widgets import Player as _BkPlayer +from ..util import hashable from .base import Widget +from .select import SelectBase class PlayerBase(Widget): @@ -20,7 +22,7 @@ class PlayerBase(Widget): step = param.Integer(default=1, doc=""" Number of frames to step forward and back by on each event.""") - height = param.Integer(default=250, readonly=True) + height = param.Integer(default=80) _widget_type = _BkPlayer @@ -54,7 +56,7 @@ def __init__(self, **params): super(Player, self).__init__(**params) -class DiscretePlayer(PlayerBase): +class DiscretePlayer(PlayerBase, SelectBase): """ The DiscretePlayer provides controls to iterate through a list of discrete options. The speed at which the widget plays is defined @@ -63,34 +65,28 @@ class DiscretePlayer(PlayerBase): interval = param.Integer(default=500, doc="Interval between updates") - options = param.ClassSelector(default=[], class_=(dict, list)) - value = param.Parameter() _rename = {'name': None, 'options': None} def _process_param_change(self, msg): - options = msg.get('options', self.options) - if isinstance(options, list): - values = options - else: - values = list(options.values()) + values = [hashable(v) for v in self.values] if 'options' in msg: msg['start'] = 0 - msg['end'] = len(options) - 1 - if self.value not in values: + msg['end'] = len(values) - 1 + if values and self.value not in values: self.value = values[0] if 'value' in msg: - value = msg['value'] - msg['value'] = values.index(value) + value = hashable(msg['value']) + if hashable(value) in values: + msg['value'] = values.index(value) + elif values: + self.value = values[0] return super(DiscretePlayer, self)._process_param_change(msg) def _process_property_change(self, msg): - options = self.options - if isinstance(options, list): - values = options - else: - values = list(options.values()) if 'value' in msg: - msg['value'] = values[msg['value']] + value = msg.pop('value') + if value < len(self.options): + msg['value'] = self.values[value] return msg diff --git a/panel/widgets/select.py b/panel/widgets/select.py index 5e63c9cb19..8a71d68841 100644 --- a/panel/widgets/select.py +++ b/panel/widgets/select.py @@ -18,49 +18,73 @@ from ..layout import Column, Row, VSpacer from ..util import as_unicode, hashable -from .base import Widget +from ..viewable import Layoutable +from .base import Widget, CompositeWidget from .button import _ButtonBase, Button from .input import TextInput +class SelectBase(Widget): -class Select(Widget): + options = param.ClassSelector(default=[], class_=(dict, list)) - options = param.Dict(default={}) + __abstract = True + + @property + def labels(self): + return [as_unicode(o) for o in self.options] + + @property + def values(self): + if isinstance(self.options, dict): + return list(self.options.values()) + else: + return self.options + + @property + def _items(self): + return dict(zip(self.labels, self.values)) + + +class Select(SelectBase): value = param.Parameter(default=None) _widget_type = _BkSelect def __init__(self, **params): - options = params.get('options', None) - if isinstance(options, list): - params['options'] = OrderedDict([(as_unicode(o), o) for o in options]) super(Select, self).__init__(**params) - options = list(self.options.values()) - if self.value is None and None not in options and options: - self.value = options[0] + values = self.values + if self.value is None and None not in values and values: + self.value = values[0] def _process_param_change(self, msg): msg = super(Select, self)._process_param_change(msg) - mapping = {hashable(v): k for k, v in self.options.items()} - if msg.get('value') is not None: + mapping = {hashable(v): k for k, v in self._items.items()} + if 'value' in msg: hash_val = hashable(msg['value']) if hash_val in mapping: msg['value'] = mapping[hash_val] - else: - msg['value'] = list(self.options)[0] + elif mapping: + self.value = self.values[0] + if 'options' in msg: - msg['options'] = list(msg['options']) + msg['options'] = self.labels + hash_val = hashable(self.value) + if mapping and hash_val not in mapping: + self.value = self.values[0] + return msg def _process_property_change(self, msg): msg = super(Select, self)._process_property_change(msg) if 'value' in msg: - if msg['value'] is None: - msg['value'] = None + if not self.values: + pass + elif msg['value'] is None: + msg['value'] = self.values[0] else: - msg['value'] = self.options[msg['value']] + msg['value'] = self._items[msg['value']] msg.pop('options', None) return msg @@ -77,18 +101,22 @@ class MultiSelect(Select): def _process_param_change(self, msg): msg = super(Select, self)._process_param_change(msg) - mapping = {hashable(v): k for k, v in self.options.items()} + mapping = {hashable(v): k for k, v in self._items.items()} if 'value' in msg: - msg['value'] = [hashable(mapping[v]) for v in msg['value'] - if v in mapping] + msg['value'] = [mapping[hashable(v)] for v in msg['value'] + if hashable(v) in mapping] + if 'options' in msg: - msg['options'] = list(msg['options']) + msg['options'] = self.labels + if any(hashable(v) not in mapping for v in self.value): + self.value = [v for v in self.value if hashable(v) in mapping] return msg def _process_property_change(self, msg): msg = super(Select, self)._process_property_change(msg) if 'value' in msg: - msg['value'] = [self.options[v] for v in msg['value']] + msg['value'] = [self._items[v] for v in msg['value'] + if v in self.labels] msg.pop('options', None) return msg @@ -108,20 +136,34 @@ class AutocompleteInput(Widget): class _RadioGroupBase(Select): + __abstract = True + def _process_param_change(self, msg): msg = super(Select, self)._process_param_change(msg) - mapping = OrderedDict([(hashable(v), k) for k, v in self.options.items()]) - if msg.get('value') is not None: - msg['active'] = list(mapping).index(msg.pop('value')) + values = [hashable(v) for v in self._items.values()] + if 'value' in msg: + value = hashable(msg.pop('value')) + if value in values: + msg['active'] = values.index(value) + else: + msg['active'] = None + if 'options' in msg: msg['labels'] = list(msg.pop('options')) + value = hashable(self.value) + if value not in values: + self.value = None msg.pop('title', None) return msg def _process_property_change(self, msg): msg = super(Select, self)._process_property_change(msg) if 'active' in msg: - msg['value'] = list(self.options.values())[msg.pop('active')] + index = msg.pop('active') + if index is None: + msg['value'] = None + else: + msg['value'] = list(self.values)[index] return msg @@ -145,20 +187,26 @@ class _CheckGroupBase(Select): value = param.List(default=[]) + __abstract = True + def _process_param_change(self, msg): msg = super(Select, self)._process_param_change(msg) - mapping = OrderedDict([(hashable(v), k) for k, v in self.options.items()]) - if msg.get('value') is not None: - msg['active'] = [list(mapping).index(v) for v in msg.pop('value')] + values = [hashable(v) for v in self._items.values()] + if 'value' in msg: + msg['active'] = [values.index(hashable(v)) for v in msg.pop('value') + if hashable(v) in values] if 'options' in msg: msg['labels'] = list(msg.pop('options')) + if any(hashable(v) not in values for v in self.value): + self.value = [v for v in self.value if hashable(v) in values] msg.pop('title', None) return msg def _process_property_change(self, msg): msg = super(Select, self)._process_property_change(msg) if 'active' in msg: - msg['value'] = [list(self.options.values())[a] for a in msg.pop('active')] + values = self.values + msg['value'] = [values[a] for a in msg.pop('active')] return msg @@ -220,7 +268,7 @@ def __new__(cls, widget_type='button', behavior='check', **params): return RadioBoxGroup(**params) -class CrossSelector(MultiSelect): +class CrossSelector(CompositeWidget, MultiSelect): """ A composite widget which allows selecting from a list of items by moving them between two lists. Supports filtering values by @@ -243,17 +291,19 @@ def __init__(self, *args, **kwargs): super(CrossSelector, self).__init__(**kwargs) # Compute selected and unselected values - mapping = {hashable(v): k for k, v in self.options.items()} + mapping = {hashable(v): k for k, v in self._items.items()} selected = [mapping[hashable(v)] for v in kwargs.get('value', [])] - unselected = [k for k in self.options if k not in selected] + unselected = [k for k in self.labels if k not in selected] # Define whitelist and blacklist + layout = dict(sizing_mode=self.sizing_mode, width_policy=self.width_policy, + height_policy=self.height_policy, background=self.background) width = int((self.width-50)/2) self._lists = { False: MultiSelect(options=unselected, size=self.size, - height=self.height-50, width=width), + height=self.height-50, width=width, **layout), True: MultiSelect(options=selected, size=self.size, - height=self.height-50, width=width) + height=self.height-50, width=width, **layout) } self._lists[False].param.watch(self._update_selection, 'value') self._lists[True].param.watch(self._update_selection, 'value') @@ -278,40 +328,63 @@ def __init__(self, *args, **kwargs): whitelist = Column(self._search[True], self._lists[True]) buttons = Column(self._buttons[True], self._buttons[False], width=50) - self._layout = Row(blacklist, Column(VSpacer(), buttons, VSpacer()), whitelist) - - self.param.watch(self._update_options, 'options') - self.param.watch(self._update_value, 'value') - self.link(self._lists[False], size='size') - self.link(self._lists[True], size='size') + self._composite = Row(blacklist, Column(VSpacer(), buttons, VSpacer()), whitelist, + css_classes=self.css_classes, margin=self.margin, **layout) self._selected = {False: [], True: []} self._query = {False: '', True: ''} + self.param.watch(self._update_layout_params, list(Layoutable.param)) + + + def _update_layout_params(self, *events): + for event in events: + if event.name in ['css_classes']: + setattr(self._composite, event.name, event.new) + elif event.name in ['sizing_mode', 'width_policy', 'height_policy', + 'background', 'margin']: + setattr(self._composite, event.name, event.new) + setattr(self._lists[True], event.name, event.new) + setattr(self._lists[False], event.name, event.new) + elif event.name == 'height': + setattr(self._lists[True], event.name, event.new-50) + setattr(self._lists[False], event.name, event.new-50) + elif event.name == 'width': + width = int((event.new-50)/2) + setattr(self._lists[True], event.name, width) + setattr(self._lists[False], event.name, width) + + + @param.depends('size', watch=True) + def _update_size(self): + self._lists[False].size = self.size + self._lists[True].size = self.size @param.depends('disabled', watch=True) def _update_disabled(self): self._buttons[False].disabled = self.disabled self._buttons[True].disabled = self.disabled - def _update_value(self, event): - mapping = {hashable(v): k for k, v in self.options.items()} - selected = OrderedDict([(mapping[k], mapping[k]) for k in event.new]) - unselected = OrderedDict([(k, k) for k in self.options if k not in selected]) + @param.depends('value', watch=True) + def _update_value(self): + mapping = {hashable(v): k for k, v in self._items.items()} + selected = [mapping[hashable(v)] for v in self.value] + unselected = [k for k in self.labels if k not in selected] self._lists[True].options = selected self._lists[True].value = [] self._lists[False].options = unselected self._lists[False].value = [] - def _update_options(self, event): + @param.depends('options', watch=True) + def _update_options(self): """ Updates the options of each of the sublists after the options for the whole widget are updated. """ self._selected[False] = [] self._selected[True] = [] - self._lists[True].options = {} + self._lists[True].options = [] self._lists[True].value = [] - self._lists[False].options = OrderedDict([(k, k) for k in event.new]) + self._lists[False].options = self.labels self._lists[False].value = [] def _apply_filters(self): @@ -328,8 +401,8 @@ def _filter_options(self, event): def _apply_query(self, selected): query = self._query[selected] - other = self._lists[not selected].options - options = OrderedDict([(k, k) for k in self.options if k not in other]) + other = self._lists[not selected].labels + options = [k for k in self.labels if k not in other] if not query: self._lists[selected].options = options self._lists[selected].value = [] @@ -339,7 +412,7 @@ def _apply_query(self, selected): matches = list(filter(match.search, options)) except: matches = list(options) - self._lists[selected].options = options if options else {} + self._lists[selected].options = options if options else [] self._lists[selected].value = [m for m in matches] def _update_selection(self, event): @@ -368,4 +441,4 @@ def _apply_selection(self, event): self._apply_filters() def _get_model(self, doc, root=None, parent=None, comm=None): - return self._layout._get_model(doc, root, parent, comm) + return self._composite._get_model(doc, root, parent, comm) diff --git a/panel/widgets/slider.py b/panel/widgets/slider.py index 207667402f..795c236b09 100644 --- a/panel/widgets/slider.py +++ b/panel/widgets/slider.py @@ -10,14 +10,14 @@ import param import numpy as np -from bokeh.models import Column as _BkColumn, Div as _BkDiv from bokeh.models.widgets import ( DateSlider as _BkDateSlider, DateRangeSlider as _BkDateRangeSlider, RangeSlider as _BkRangeSlider, Slider as _BkSlider) -from ..util import push, value_as_datetime -from .base import Widget - +from ..util import value_as_datetime +from .base import Widget, CompositeWidget +from ..layout import Column +from .input import StaticText class _SliderBase(Widget): @@ -50,6 +50,8 @@ class _SliderBase(Widget): _widget_type = _BkSlider + __abstract = True + class FloatSlider(_SliderBase): @@ -84,7 +86,7 @@ class DateSlider(_SliderBase): _widget_type = _BkDateSlider -class DiscreteSlider(_SliderBase): +class DiscreteSlider(CompositeWidget, _SliderBase): options = param.ClassSelector(default=[], class_=(dict, list)) @@ -92,11 +94,16 @@ class DiscreteSlider(_SliderBase): formatter = param.String(default='%.3g') + _rename = {'formatter': None} + def __init__(self, **params): + self._processing = False + self._text = StaticText() + self._slider = IntSlider() super(DiscreteSlider, self).__init__(**params) if 'formatter' not in params and all(isinstance(v, (int, np.int_)) for v in self.values): self.formatter = '%d' - if self.value is None and None not in self.values: + if self.value is None and None not in self.values and self.options: self.value = self.values[0] elif self.value not in self.values: raise ValueError('Value %s not a valid option, ' @@ -104,6 +111,47 @@ def __init__(self, **params): 'is one of the declared options.' % self.value) + self._composite = Column(self._text, self._slider) + self._update_value() + self._update_options() + self._slider.param.watch(self._sync_value, 'value') + + @param.depends('value', watch=True) + def _update_value(self): + labels, values = self.labels, self.values + if self.value not in values: + self.value = values[0] + return + index = self.values.index(self.value) + self._text.value = labels[index] + if self._processing: + return + try: + self._processing = True + self._slider.value = index + finally: + self._processing = False + + @param.depends('options', watch=True) + def _update_options(self): + slider_msg = {'start': 0, 'end': len(self.options) - 1} + self._slider.set_param(**slider_msg) + values = self.values + if self.value not in values: + self.value = values[0] + + def _sync_value(self, event): + if self._processing: + return + try: + self._processing = True + self.value = self.values[event.new] + finally: + self._processing = False + + def _get_model(self, doc, root=None, parent=None, comm=None): + return self._composite._get_model(doc, root, parent, comm) + @property def labels(self): title = ('%s: ' % self.name if self.name else '') @@ -116,80 +164,6 @@ def labels(self): def values(self): return list(self.options.values()) if isinstance(self.options, dict) else self.options - def _get_model(self, doc, root=None, parent=None, comm=None): - model = _BkColumn() - if root is None: - root = model - msg = self._process_param_change(self._init_properties()) - div = _BkDiv(text=msg['text']) - slider = _BkSlider(start=msg['start'], end=msg['end'], value=msg['value'], - title=None, step=1, show_value=False, tooltips=None) - - # Link parameters and bokeh model - self._link_params(model, slider, div, ['value', 'options'], doc, root, comm) - self._link_props(slider, ['value'], doc, root, comm) - - model.children = [div, slider] - self._models[root.ref['id']] = model - - return model - - def _link_params(self, model, slider, div, params, doc, root, comm=None): - from .. import state - - def param_change(*events): - combined_msg = {} - for event in events: - msg = self._process_param_change({event.name: event.new}) - if msg: - combined_msg.update(msg) - - if not combined_msg: - return - - def update_model(): - slider.update(**{k: v for k, v in combined_msg.items() - if k in slider.properties()}) - div.update(**{k: v for k, v in combined_msg.items() - if k in div.properties()}) - - if comm: - update_model() - push(doc, comm) - elif state.curdoc: - update_model() - else: - doc.add_next_tick_callback(update_model) - - ref = root.ref['id'] - for p in params: - self._callbacks[ref].append(self.param.watch(param_change, p)) - - def _process_param_change(self, msg): - labels, values = self.labels, self.values - if 'name' in msg: - msg['text'] = labels[values.index(self.value)] - if 'options' in msg: - msg['start'] = 0 - msg['end'] = len(msg['options']) - 1 - msg['labels'] = labels - if self.value not in values: - self.value = values[0] - if 'value' in msg: - value = msg['value'] - if value not in values: - self.value = values[0] - msg.pop('value') - return msg - label = labels[values.index(value)] - msg['value'] = values.index(value) - msg['text'] = label - return msg - - def _process_property_change(self, msg): - if 'value' in msg: - msg['value'] = self.values[msg['value']] - return msg class RangeSlider(_SliderBase):