diff --git a/examples/tutorial/04_Make_an_App.ipynb b/examples/tutorial/04_Make_an_App.ipynb index bd16c96e..0a9461a3 100644 --- a/examples/tutorial/04_Make_an_App.ipynb +++ b/examples/tutorial/04_Make_an_App.ipynb @@ -77,6 +77,50 @@ "tools = PanelWidgets(annotator, field_values=fields_values)\n", "pn.Row(tools, annotator_element).servable()" ] + }, + { + "cell_type": "markdown", + "id": "b393d681", + "metadata": {}, + "source": [ + "## Make it pop!\n", + "\n", + "Rather than laying out the widgets on the side, the widgets can also be shown as a popup.\n", + "\n", + "Now, when an annotation is created, the widgets will popup next to the annotation, and closed when `x` or `✔️` is clicked.\n", + "\n", + "The widgets can also be displayed when double clicking anywhere on the plot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "323fd44c", + "metadata": {}, + "outputs": [], + "source": [ + "PanelWidgets(annotator, field_values=fields_values, as_popup=True)\n", + "annotator_element" + ] + }, + { + "cell_type": "markdown", + "id": "4515e3b1", + "metadata": {}, + "source": [ + "It's also possible to use as a popup and display the widgets on the side." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "07a1853c", + "metadata": {}, + "outputs": [], + "source": [ + "tools = PanelWidgets(annotator, field_values=fields_values, as_popup=True)\n", + "pn.Row(tools, annotator_element).servable()" + ] } ], "metadata": { diff --git a/holonote/annotate/display.py b/holonote/annotate/display.py index 82ece5bd..63267065 100644 --- a/holonote/annotate/display.py +++ b/holonote/annotate/display.py @@ -431,16 +431,16 @@ def tap_selector(x, y) -> None: # Tap tool must be enabled on the element else: self.annotator.select_by_index() - tap_stream = hv.streams.Tap(source=element, transient=True) - tap_stream.add_subscriber(tap_selector) + self._tap_stream = hv.streams.Tap(source=element, transient=True) + self._tap_stream.add_subscriber(tap_selector) return element def register_double_tap_clear(self, element: hv.Element) -> hv.Element: def double_tap_clear(x, y): self.clear_indicated_region() - double_tap_stream = hv.streams.DoubleTap(source=element, transient=True) - double_tap_stream.add_subscriber(double_tap_clear) + self._double_tap_stream = hv.streams.DoubleTap(source=element, transient=True) + self._double_tap_stream.add_subscriber(double_tap_clear) return element def indicators(self) -> hv.DynamicMap: diff --git a/holonote/app/panel.py b/holonote/app/panel.py index 7f37fe05..518cedee 100644 --- a/holonote/app/panel.py +++ b/holonote/app/panel.py @@ -18,6 +18,8 @@ class PanelWidgets(Viewer): + reset_on_apply = param.Boolean(default=True, doc="Reset fields widgets on apply") + mapping = { str: pn.widgets.TextInput, bool: pn.widgets.Checkbox, @@ -27,7 +29,15 @@ class PanelWidgets(Viewer): float: pn.widgets.FloatSlider, } - def __init__(self, annotator: Annotator, field_values: dict[str, Any] | None = None): + def __init__( + self, + annotator: Annotator, + field_values: dict[str, Any] | None = None, + as_popup: bool = False, + **params, + ): + super().__init__(**params) + self._layouts = {} self.annotator = annotator self.annotator.snapshot() self._widget_mode_group = pn.widgets.RadioButtonGroup( @@ -48,6 +58,27 @@ def __init__(self, annotator: Annotator, field_values: dict[str, Any] | None = N self._set_standard_callbacks() + self._layout = pn.Column(self.fields_widgets, self.tool_widgets) + if self.visible_widget is not None: + self._layout.insert(0, self.visible_widget) + + self._as_popup = as_popup + if self._as_popup: + self._layout.visible = False + displays = self.annotator._displays + if not displays: + kdims = list(self.annotator.spec.keys()) + display = self.annotator.get_display(*kdims) + display.indicators() + for display in displays.values(): + if display.region_format in ("range", "range-range"): + stream = display._edit_streams[0] + elif display.region_format in ("point", "point-point"): + stream = display._edit_streams[1] + self._register_stream_popup(stream) + self._register_tap_popup(display) + self._register_double_tap_clear(display) + def _create_visible_widget(self): if self.annotator.groupby is None: self.visible_widget = None @@ -167,6 +198,8 @@ def _reset_fields_widgets(self): for widget_name, default in self._fields_values.items(): if isinstance(default, param.Parameter): default = default.default + if isinstance(default, list): + default = default[0] with contextlib.suppress(Exception): # TODO: Fix when lists (for categories, not the same as the default!) self._fields_widgets[widget_name].value = default @@ -183,7 +216,8 @@ def _callback_apply(self, event): fields_values = {k: v.value for k, v in self._fields_widgets.items()} if self._widget_mode_group.value == "+": self.annotator.add_annotation(**fields_values) - self._reset_fields_widgets() + if self.reset_on_apply: + self._reset_fields_widgets() elif (self._widget_mode_group.value == "✏") and (selected_ind is not None): self.annotator.update_annotation_fields( selected_ind, **fields_values @@ -191,6 +225,59 @@ def _callback_apply(self, event): elif self._widget_mode_group.value == "-" and selected_ind is not None: self.annotator.delete_annotation(selected_ind) + def _get_layout(self, name): + def close_layout(event): + layout.visible = False + + layout = self._layouts.get(name) + if not layout: + layout = self._layout.clone(visible=False) + self._widget_apply_button.on_click(close_layout) + self._layouts[name] = layout + return layout + + def _hide_layouts(self): + for layout in self._layouts.values(): + layout.visible = False + + def _register_stream_popup(self, stream): + def _popup(*args, **kwargs): + layout = self._get_layout(stream.name) + with param.parameterized.batch_call_watchers(self): + self._hide_layouts() + self._widget_mode_group.value = "+" + layout.visible = True + return layout + + stream.popup = _popup + + def _register_tap_popup(self, display): + def tap_popup(x, y) -> None: # Tap tool must be enabled on the element + layout = self._get_layout("tap") + if self.annotator.selection_enabled: + with param.parameterized.batch_call_watchers(self): + self._hide_layouts() + layout.visible = True + return layout + + display._tap_stream.popup = tap_popup + + def _register_double_tap_clear(self, display): + def double_tap_toggle(x, y): + layout = self._get_layout("doubletap") + if layout.visible: + with param.parameterized.batch_call_watchers(self): + self._hide_layouts() + layout.visible = True + return layout + + try: + tools = display._element.opts["tools"] + except KeyError: + tools = [] + display._element.opts(tools=[*tools, "doubletap"]) + display._double_tap_stream.popup = double_tap_toggle + def _callback_commit(self, event): self.annotator.commit() @@ -204,17 +291,15 @@ def _watcher_selected_indices(self, event): widget.value = value def _watcher_mode_group(self, event): - if event.new in ["-", "✏"]: - self.annotator.selection_enabled = True - self.annotator.select_by_index() - self.annotator.editable_enabled = False - elif event.new == "+": - self.annotator.editable_enabled = True - self.annotator.select_by_index() - self.annotator.selection_enabled = False - - for widget in self._fields_widgets.values(): - widget.disabled = event.new == "-" + with param.parameterized.batch_call_watchers(self): + if event.new in ("-", "✏"): + self.annotator.selection_enabled = True + elif event.new == "+": + self.annotator.editable_enabled = True + self.annotator.selection_enabled = False + + for widget in self._fields_widgets.values(): + widget.disabled = event.new == "-" def _set_standard_callbacks(self): self._widget_apply_button.on_click(self._callback_apply) @@ -224,6 +309,4 @@ def _set_standard_callbacks(self): self._widget_mode_group.param.watch(self._watcher_mode_group, "value") def __panel__(self): - if self.visible_widget is None: - return pn.Column(self.fields_widgets, self.tool_widgets) - return pn.Column(self.visible_widget, self.fields_widgets, self.tool_widgets) + return self._layout.clone(visible=True) diff --git a/holonote/tests/test_app.py b/holonote/tests/test_app.py index 7e968aee..7e93d12d 100644 --- a/holonote/tests/test_app.py +++ b/holonote/tests/test_app.py @@ -9,3 +9,12 @@ def test_panel_app(annotator_range1d): w = PanelWidgets(annotator_range1d) assert isinstance(w.fields_widgets, pn.Column) assert isinstance(w.tool_widgets, pn.Row) + + +def test_as_popup(annotator_range1d): + w = PanelWidgets(annotator_range1d, as_popup=True) + assert not w._layout.visible + for display in w.annotator._displays.values(): + assert display._edit_streams[0].popup + assert display._tap_stream.popup + assert w.__panel__().visible