diff --git a/panel/io.py b/panel/io.py index 5da9c2ec9b3..5f0cab907de 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 b9419a8b083..10b48c771cf 100644 --- a/panel/layout.py +++ b/panel/layout.py @@ -36,34 +36,15 @@ 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 _update_model(self, events, msg, root, model, doc, comm): + 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 def _cleanup(self, root=None, final=False): super(Panel, self)._cleanup(root, final) @@ -71,26 +52,6 @@ def _cleanup(self, root=None, final=False): 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 _get_objects(self, model, old_objects, doc, root, comm=None): """ Returns new child models for the layout while reusing unchanged @@ -102,7 +63,7 @@ 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) @@ -115,9 +76,7 @@ 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 @@ -190,6 +149,30 @@ def __repr__(self, depth=0, max_depth=10): objs=('%s' % spacer).join(objs), spacer=spacer ) + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + + 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 new_objects = list(self) @@ -318,7 +301,7 @@ 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) @@ -361,6 +344,10 @@ def __setitem__(self, index, panes): new_objects[i], self._names[i] = self._to_object_and_name(pane) self.objects = new_objects + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + def append(self, pane): new_object, new_name = self._to_object_and_name(pane) new_objects = list(self) @@ -415,11 +402,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/pane/base.py b/panel/pane/base.py index b7b87513180..21c1507f539 100644 --- a/panel/pane/base.py +++ b/panel/pane/base.py @@ -57,39 +57,6 @@ class PaneBase(Reactive): __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 - - @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__) - def __init__(self, object, **params): applies = self.applies(object) if isinstance(applies, bool) and not applies: @@ -99,6 +66,7 @@ def __init__(self, object, **params): 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, 'object') def __repr__(self, depth=0): cls = type(self).__name__ @@ -119,57 +87,94 @@ def _get_root(self, doc, comm=None): 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 - 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. + 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 - def _link_object(self, doc, root, parent, comm=None): + def _synced_params(self): + return [p for p in self.param if p not in ['object', 'name']] + + 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_model, model, doc, root, parent, comm) + doc.add_next_tick_callback(cb) + + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + + @classmethod + def applies(cls, obj): """ - Links the object parameter to the rendered Bokeh model, triggering - an update when the object changes. + 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. """ - ref = root.ref['id'] + return None - def update_pane(change): - old_model = self._models[ref] + @classmethod + def get_pane_type(cls, obj): + """ + 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/holoviews.py b/panel/pane/holoviews.py index 573bd9a5766..950d4e6a3dd 100644 --- a/panel/pane/holoviews.py +++ b/panel/pane/holoviews.py @@ -42,22 +42,27 @@ def __init__(self, object, **params): self.widget_box = Column() self._update_widgets() self._plots = {} + self._panes = {} @param.depends('object', 'widgets', watch=True) def _update_widgets(self): 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 self._callbacks['instance']: + if cb.inst in self.widget_box.objects: + cb.inst.param.unwatch(cb) + self._callbacks['instance'].remove(cb) + + # Add new widget callbacks + for widget in widgets: + watcher = widget.param.watch(self._widget_callback, 'value') + self._callbacks['instance'].append(watcher) self.widget_box.objects = widgets if widgets and not self.widget_box in self.layout.objects: @@ -65,12 +70,9 @@ 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 _widget_callback(self): + for ref, (plot, pane) in self._plots.items(): + self._update_plot(plot, pane) def _cleanup(self, root=None, final=False): """ @@ -78,9 +80,11 @@ def _cleanup(self, root=None, final=False): connected to existing plots. """ if root is not None: - old_plot = self._plots.pop(root.ref['id'], None) + 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, final) def _render(self, doc, comm, root): @@ -96,14 +100,28 @@ 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 + return renderer.get_plot(self.object, **kwargs) + + def _update_plot(self, plot, pane): + from holoviews.core.util import cross_index + from holoviews.plotting.bokeh.plot import BokehPlot + + 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 comm or state.curdoc: + plot.update(key) + if comm: + plot.push() + else: + plot.document.add_next_tick_callback(partial(plot.update, key)) + else: + plot.update(key) + pane.object = plot.state def _get_model(self, doc, root=None, parent=None, comm=None): if root is None: @@ -112,42 +130,23 @@ def _get_model(self, doc, root=None, parent=None, comm=None): 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) + if ref in self._plots: + old_plot, old_pane = self._plots[ref] + old_plot.cleanup() + self._plots[ref] = (plot, child_pane) + self._models[ref] = (model, parent) 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 - - 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) - - 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'): @@ -224,14 +223,14 @@ def is_bokeh_element_plot(plot): and not isinstance(plot, GenericOverlayPlot)) -def generate_panel_bokeh_map(root_model, panel_views): +def generate_panel_bokeh_map(root_model, panel_views): # ALERT REVIEW THIS BEFORE MERGE """ mapping panel elements to its bokeh models """ 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]) + 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) diff --git a/panel/pane/markup.py b/panel/pane/markup.py index 2ed249aa114..944b6bf363f 100644 --- a/panel/pane/markup.py +++ b/panel/pane/markup.py @@ -43,8 +43,7 @@ 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): diff --git a/panel/pane/plot.py b/panel/pane/plot.py index e9ab9106234..d40032e4bce 100644 --- a/panel/pane/plot.py +++ b/panel/pane/plot.py @@ -40,8 +40,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 diff --git a/panel/pane/plotly.py b/panel/pane/plotly.py index 3ba19bc2f5f..151a19b3a79 100644 --- a/panel/pane/plotly.py +++ b/panel/pane/plotly.py @@ -63,8 +63,7 @@ def _get_model(self, doc, root=None, parent=None, comm=None): 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): diff --git a/panel/pane/vega.py b/panel/pane/vega.py index 199c8b86267..fcb49e611fc 100644 --- a/panel/pane/vega.py +++ b/panel/pane/vega.py @@ -88,8 +88,7 @@ def _get_model(self, doc, root=None, parent=None, comm=None): 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): diff --git a/panel/param.py b/panel/param.py index 6cb2cd14ec6..2fa5adbd821 100644 --- a/panel/param.py +++ b/panel/param.py @@ -389,7 +389,9 @@ 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 + state._views[ref] = (self, model, doc, comm) return root def _get_model(self, doc, root=None, parent=None, comm=None): diff --git a/panel/tests/test_holoviews.py b/panel/tests/test_holoviews.py index 56c1d7840c3..654088e8749 100644 --- a/panel/tests/test_holoviews.py +++ b/panel/tests/test_holoviews.py @@ -42,7 +42,7 @@ def test_holoviews_pane_mpl_renderer(document, comm): assert len(row.children) == 1 assert len(pane._callbacks) == 1 model = row.children[0] - assert pane._models[row.ref['id']] is model + assert pane._models[row.ref['id']][0] is model div = get_div(model) assert '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 diff --git a/panel/tests/test_layout.py b/panel/tests/test_layout.py index 622ec1f73df..f9061a61d71 100644 --- a/panel/tests/test_layout.py +++ b/panel/tests/test_layout.py @@ -158,12 +158,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 +186,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 +217,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 +261,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 +276,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 +291,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 +310,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,7 +623,6 @@ 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 div3 = Div() tabs[0] = ('C', div3) @@ -644,7 +630,6 @@ def test_tabs_setitem(document, comm): assert tab1.child is div3 assert tab1.title == 'C' assert tab2.child is div2 - assert p1._callbacks == {} assert p1._models == {} @@ -667,8 +652,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 +661,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 +685,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 +696,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 +733,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 +750,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 +769,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_panes.py b/panel/tests/test_panes.py index 96addfc68fe..2820132f35c 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('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 +497,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 +517,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 +526,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 +544,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,12 +553,11 @@ 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]) - 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] diff --git a/panel/viewable.py b/panel/viewable.py index 54745493aee..8ccfadef3cf 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -237,6 +237,8 @@ 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): @@ -252,6 +254,9 @@ def _cleanup(self, model=None, final=False): """ def _preprocess(self, root): + """ + Applies preprocessing hooks to the model. + """ for hook in self._preprocessing_hooks: hook(self, root) @@ -260,7 +265,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): @@ -514,9 +518,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. @@ -539,93 +544,7 @@ def __init__(self, **params): self._processing = False self._events = {} self._callbacks = defaultdict(list) - - def link(self, target, callbacks=None, **links): - """ - Links the parameters on this object to attributes on another - object in Python. Supports two modes, either specify a mapping - between the source and target object parameters as keywords or - provide a dictionary of callbacks which maps from the source - parameter to a callback which is triggered when the parameter - changes. - - Parameters - ---------- - target: object - The target object of the link. - callbacks: dict - Maps from a parameter in the source object to a callback. - **links: dict - Maps between parameters on this object to the parameters - on the supplied object. - """ - if links and callbacks: - raise ValueError('Either supply a set of parameters to ' - 'link as keywords or a set of callbacks, ' - 'not both.') - elif not links and not callbacks: - raise ValueError('Declare parameters to link or a set of ' - 'callbacks, neither was defined.') - - _updating = [] - def link(*events): - for event in events: - if event.name in _updating: continue - _updating.append(event.name) - try: - if callbacks: - callbacks[event.name](target, event) - else: - setattr(target, links[event.name], event.new) - except: - raise - finally: - _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) - return cb - - def jslink(self, target, code=None, **links): - """ - Links properties on the source object to those on the target - object in JS code. Supports two modes, either specify a - mapping between the source and target model properties as - keywords or provide a dictionary of JS code snippets which - maps from the source parameter to a JS code snippet which is - executed when the property changes. - - Parameters - ---------- - target: HoloViews object or bokeh Model or panel Viewable - The target to link the value to. - code: dict - Custom code which will be executed when the widget value - changes. - **links: dict - A mapping between properties on the source model and the - target model property to link it to. - - Returns - ------- - link: GenericLink - The GenericLink which can be used unlink the widget and - the target model. - """ - if links and code: - raise ValueError('Either supply a set of properties to ' - 'link as keywords or a set of JS code ' - 'callbacks, not both.') - elif not links and not code: - raise ValueError('Declare parameters to link or a set of ' - 'callbacks, neither was defined.') - - from .links import GenericLink - if isinstance(target, Reactive): - mapping = code or links - for k, v in list(mapping.items()): - mapping[k] = target._rename.get(v, v) - return GenericLink(self, target, properties=links, code=code) + self._link_params() def _cleanup(self, root=None, final=False): super(Reactive, self)._cleanup(root, final) @@ -644,7 +563,7 @@ def _cleanup(self, root=None, final=False): obj.param.unwatch(watcher) # Clean up comms - model = self._models.pop(root.ref['id'], None) + model, _ = self._models.pop(root.ref['id'], (None, None)) if model is None: return @@ -697,7 +616,27 @@ def _process_param_change(self, msg): properties['min_height'] = None return properties - def _link_params(self, model, params, doc, root, comm=None): + def _update_model(self, events, msg, root, model, doc, comm): + 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(**update) + + def _synced_params(self): + return list(self.param) + + def _link_params(self): + def param_change(*events): msgs = [] for event in events: @@ -705,36 +644,25 @@ def param_change(*events): 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) + 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: - model.update(**update) - - if comm: - update_model() - push(doc, comm) - elif state.curdoc: - update_model() - else: - doc.add_next_tick_callback(update_model) + cb = partial(self._update_model, events, msg, root, model, doc, comm) + doc.add_next_tick_callback(cb) - ref = root.ref['id'] - watcher = self.param.watch(param_change, params) - self._callbacks[ref].append(watcher) + watcher = self.param.watch(param_change, self._synced_params()) + self._callbacks['instance'].append(watcher) def _link_props(self, model, properties, doc, root, comm=None): if comm is None: @@ -764,6 +692,14 @@ def _change_event(self, doc=None): 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 @@ -782,3 +718,94 @@ def _get_customjs(self, change, client_comm, plot_id): js_callback = CustomJS(code='\n'.join([fetch_data, self_callback])) return js_callback + + #---------------------------------------------------------------- + # Public API + #---------------------------------------------------------------- + + def link(self, target, callbacks=None, **links): + """ + Links the parameters on this object to attributes on another + object in Python. Supports two modes, either specify a mapping + between the source and target object parameters as keywords or + provide a dictionary of callbacks which maps from the source + parameter to a callback which is triggered when the parameter + changes. + + Parameters + ---------- + target: object + The target object of the link. + callbacks: dict + Maps from a parameter in the source object to a callback. + **links: dict + Maps between parameters on this object to the parameters + on the supplied object. + """ + if links and callbacks: + raise ValueError('Either supply a set of parameters to ' + 'link as keywords or a set of callbacks, ' + 'not both.') + elif not links and not callbacks: + raise ValueError('Declare parameters to link or a set of ' + 'callbacks, neither was defined.') + + _updating = [] + def link(*events): + for event in events: + if event.name in _updating: continue + _updating.append(event.name) + try: + if callbacks: + callbacks[event.name](target, event) + else: + setattr(target, links[event.name], event.new) + except: + raise + finally: + _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) + return cb + + def jslink(self, target, code=None, **links): + """ + Links properties on the source object to those on the target + object in JS code. Supports two modes, either specify a + mapping between the source and target model properties as + keywords or provide a dictionary of JS code snippets which + maps from the source parameter to a JS code snippet which is + executed when the property changes. + + Parameters + ---------- + target: HoloViews object or bokeh Model or panel Viewable + The target to link the value to. + code: dict + Custom code which will be executed when the widget value + changes. + **links: dict + A mapping between properties on the source model and the + target model property to link it to. + + Returns + ------- + link: GenericLink + The GenericLink which can be used unlink the widget and + the target model. + """ + if links and code: + raise ValueError('Either supply a set of properties to ' + 'link as keywords or a set of JS code ' + 'callbacks, not both.') + elif not links and not code: + raise ValueError('Declare parameters to link or a set of ' + 'callbacks, neither was defined.') + + from .links import GenericLink + if isinstance(target, Reactive): + mapping = code or links + for k, v in list(mapping.items()): + mapping[k] = target._rename.get(v, v) + return GenericLink(self, target, properties=links, code=code) diff --git a/panel/widgets/base.py b/panel/widgets/base.py index 10b9c3a3eee..1773636082a 100644 --- a/panel/widgets/base.py +++ b/panel/widgets/base.py @@ -41,10 +41,8 @@ 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 diff --git a/panel/widgets/select.py b/panel/widgets/select.py index 5e63c9cb196..af031f4117e 100644 --- a/panel/widgets/select.py +++ b/panel/widgets/select.py @@ -23,44 +23,65 @@ 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] + self.value = self.values[0] + if 'options' in msg: - msg['options'] = list(msg['options']) + msg['options'] = self.labels + hash_val = hashable(self.value) + if 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 + msg['value'] = self.values[0] else: - msg['value'] = self.options[msg['value']] + msg['value'] = self.items[msg['value']] msg.pop('options', None) return msg @@ -81,14 +102,15 @@ def _process_param_change(self, msg): if 'value' in msg: msg['value'] = [hashable(mapping[v]) for v in msg['value'] if v in mapping] + if 'options' in msg: - msg['options'] = list(msg['options']) + msg['options'] = self.labels 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']] msg.pop('options', None) return msg @@ -243,7 +265,7 @@ 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] @@ -278,7 +300,7 @@ 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._composite = Row(blacklist, Column(VSpacer(), buttons, VSpacer()), whitelist) self.param.watch(self._update_options, 'options') self.param.watch(self._update_value, 'value') @@ -368,4 +390,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 207667402fe..93f17015289 100644 --- a/panel/widgets/slider.py +++ b/panel/widgets/slider.py @@ -17,7 +17,8 @@ from ..util import push, value_as_datetime from .base import Widget - +from ..layout import Column +from .input import StaticText class _SliderBase(Widget): @@ -104,6 +105,48 @@ def __init__(self, **params): 'is one of the declared options.' % self.value) + self._processing = False + self._text = StaticText() + self._slider = IntSlider() + 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 = self.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): + print(event) + if self._processing: + return + try: + self._processing = True + self.value = self.values[event.new] + finally: + self._processing = False + @property def labels(self): title = ('%s: ' % self.name if self.name else '') @@ -117,79 +160,7 @@ 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 + return self._composite._get_model(doc, root, parent, comm) class RangeSlider(_SliderBase):