diff --git a/examples/notebooks/linear_region_selector.ipynb b/examples/notebooks/linear_region_selector.ipynb index 11cd3a490..f252e6f6f 100644 --- a/examples/notebooks/linear_region_selector.ipynb +++ b/examples/notebooks/linear_region_selector.ipynb @@ -19,7 +19,7 @@ "source": [ "import fastplotlib as fpl\n", "import numpy as np\n", - "\n", + "from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n", "\n", "gp = fpl.GridPlot((2, 2))\n", "\n", @@ -83,7 +83,27 @@ "ls_x.selection.add_event_handler(set_zoom_x)\n", "ls_y.selection.add_event_handler(set_zoom_y)\n", "\n", - "gp.show()" + "# make some ipywidget sliders too\n", + "# these are not necessary, it's just to show how they can be connected\n", + "x_range_slider = IntRangeSlider(\n", + " value=ls_x.selection(),\n", + " min=ls_x.limits[0],\n", + " max=ls_x.limits[1],\n", + " description=\"x\"\n", + ")\n", + "\n", + "y_range_slider = FloatRangeSlider(\n", + " value=ls_y.selection(),\n", + " min=ls_y.limits[0],\n", + " max=ls_y.limits[1],\n", + " description=\"x\"\n", + ")\n", + "\n", + "# connect the region selector to the ipywidget slider\n", + "ls_x.add_ipywidget_handler(x_range_slider, step=5)\n", + "ls_y.add_ipywidget_handler(y_range_slider, step=0.1)\n", + "\n", + "VBox([gp.show(), x_range_slider, y_range_slider])" ] }, { @@ -281,7 +301,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.3" + "version": "3.11.4" } }, "nbformat": 4, diff --git a/examples/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb index a4d6b97ea..a67a30e98 100644 --- a/examples/notebooks/linear_selector.ipynb +++ b/examples/notebooks/linear_selector.ipynb @@ -21,7 +21,7 @@ "from fastplotlib.graphics.selectors import Synchronizer\n", "\n", "import numpy as np\n", - "from ipywidgets import VBox\n", + "from ipywidgets import VBox, IntSlider, FloatSlider\n", "\n", "plot = fpl.Plot()\n", "\n", @@ -49,22 +49,18 @@ "\n", "# fastplotlib LineSelector can make an ipywidget slider and return it :D \n", "ipywidget_slider = selector.make_ipywidget_slider()\n", + "ipywidget_slider.description = \"slider1\"\n", + "\n", + "# or you can make your own ipywidget sliders and connect them to the linear selector\n", + "ipywidget_slider2 = IntSlider(min=0, max=100, description=\"slider2\")\n", + "ipywidget_slider3 = FloatSlider(min=0, max=100, description=\"slider3\")\n", + "\n", + "selector2.add_ipywidget_handler(ipywidget_slider2, step=5)\n", + "selector3.add_ipywidget_handler(ipywidget_slider3, step=0.1)\n", "\n", "plot.auto_scale()\n", "plot.show()\n", - "VBox([plot.show(), ipywidget_slider])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a632c8ee-2d4c-44fc-9391-7b2880223fdb", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "selector.step = 0.1" + "VBox([plot.show(), ipywidget_slider, ipywidget_slider2, ipywidget_slider3])" ] }, { @@ -135,7 +131,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.3" + "version": "3.11.4" } }, "nbformat": 4, diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index ae486026e..5f161562f 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -150,14 +150,14 @@ class LinearSelectionFeature(GraphicFeature): def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]): super(LinearSelectionFeature, self).__init__(parent, data=value) - self.axis = axis - self.limits = limits + self._axis = axis + self._limits = limits def _set(self, value: float): - if not (self.limits[0] <= value <= self.limits[1]): + if not (self._limits[0] <= value <= self._limits[1]): return - if self.axis == "x": + if self._axis == "x": self._parent.position_x = value else: self._parent.position_y = value @@ -219,7 +219,7 @@ def __init__( super(LinearRegionSelectionFeature, self).__init__(parent, data=selection) self._axis = axis - self.limits = limits + self._limits = limits self._set(selection) @@ -238,7 +238,7 @@ def _set(self, value: Tuple[float, float]): # make sure bounds not exceeded for v in value: - if not (self.limits[0] <= v <= self.limits[1]): + if not (self._limits[0] <= v <= self._limits[1]): return # make sure `selector width >= 2`, left edge must not move past right edge! diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index a4159c194..da7ba36ec 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -80,6 +80,9 @@ def __init__( self._move_info: MoveInfo = None + # sets to `True` on "pointer_down", sets to `False` on "pointer_up" + self._moving = False #: indicates if the selector is currently being moved + # used to disable fill area events if the edge is being actively hovered # otherwise annoying and requires too much accuracy to move just an edge self._edge_hovered: bool = False @@ -189,6 +192,7 @@ def _move_start(self, event_source: WorldObject, ev): last_position = self._plot_area.map_screen_to_world(ev) self._move_info = MoveInfo(last_position=last_position, source=event_source) + self._moving = True def _move(self, ev): """ @@ -231,6 +235,7 @@ def _move_graphic(self, delta: np.ndarray): def _move_end(self, ev): self._move_info = None + self._moving = False self._plot_area.controller.enabled = True def _move_to_pointer(self, ev): diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 39710305d..951e353d3 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -1,5 +1,6 @@ from typing import * import math +from numbers import Real import numpy as np @@ -18,6 +19,21 @@ class LinearSelector(Graphic, BaseSelector): + @property + def limits(self) -> Tuple[float, float]: + return self._limits + + @limits.setter + def limits(self, values: Tuple[float, float]): + # check that `values` is an iterable of two real numbers + # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types + if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)): + raise TypeError( + "limits must be an iterable of two numeric values" + ) + self._limits = tuple(map(round, values)) # if values are close to zero things get weird so round them + self.selection._limits = self._limits + # TODO: make `selection` arg in graphics data space not world space def __init__( self, @@ -27,7 +43,6 @@ def __init__( parent: Graphic = None, end_points: Tuple[int, int] = None, arrow_keys_modifier: str = "Shift", - ipywidget_slider=None, thickness: float = 2.5, color: Any = "w", name: str = None, @@ -57,9 +72,6 @@ def __init__( "Control", "Shift", "Alt" or ``None``. Double click on the selector first to enable the arrow key movements, or set the attribute ``arrow_key_events_enabled = True`` - ipywidget_slider: IntSlider, optional - ipywidget slider to associate with this graphic - thickness: float, default 2.5 thickness of the slider @@ -84,7 +96,8 @@ def __init__( if len(limits) != 2: raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)") - limits = tuple(map(round, limits)) + self._limits = tuple(map(round, limits)) + selection = round(selection) if axis == "x": @@ -141,14 +154,9 @@ def __init__( self.position_y = selection self.selection = LinearSelectionFeature( - self, axis=axis, value=selection, limits=limits + self, axis=axis, value=selection, limits=self._limits ) - self.ipywidget_slider = ipywidget_slider - - if self.ipywidget_slider is not None: - self._setup_ipywidget_slider(ipywidget_slider) - self._move_info: dict = None self._pygfx_event = None @@ -156,6 +164,8 @@ def __init__( self._block_ipywidget_call = False + self._handled_widgets = list() + # init base selector BaseSelector.__init__( self, @@ -166,21 +176,41 @@ def __init__( ) def _setup_ipywidget_slider(self, widget): - # setup ipywidget slider with callbacks to this LinearSelector - widget.value = int(self.selection()) + # setup an ipywidget slider with bidirectional callbacks to this LinearSelector + value = self.selection() + + if isinstance(widget, ipywidgets.IntSlider): + value = int(value) + + widget.value = value + + # user changes widget -> linear selection changes widget.observe(self._ipywidget_callback, "value") - self.selection.add_event_handler(self._update_ipywidget) + + # user changes linear selection -> widget changes + self.selection.add_event_handler(self._update_ipywidgets) + self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") - def _update_ipywidget(self, ev): - # update the ipywidget slider value when LinearSelector value changes - self._block_ipywidget_call = True - self.ipywidget_slider.value = int(ev.pick_info["new_data"]) + self._handled_widgets.append(widget) + + def _update_ipywidgets(self, ev): + # update the ipywidget sliders when LinearSelector value changes + self._block_ipywidget_call = True # prevent infinite recursion + + value = ev.pick_info["new_data"] + # update all the handled slider widgets + for widget in self._handled_widgets: + if isinstance(widget, ipywidgets.IntSlider): + widget.value = int(value) + else: + widget.value = value + self._block_ipywidget_call = False def _ipywidget_callback(self, change): # update the LinearSelector if the ipywidget value changes - if self._block_ipywidget_call: + if self._block_ipywidget_call or self._moving: return self.selection = change["new"] @@ -188,7 +218,8 @@ def _ipywidget_callback(self, change): def _set_slider_layout(self, *args): w, h = self._plot_area.renderer.logical_size - self.ipywidget_slider.layout = ipywidgets.Layout(width=f"{w}px") + for widget in self._handled_widgets: + widget.layout = ipywidgets.Layout(width=f"{w}px") def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): """ @@ -197,7 +228,7 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): Parameters ---------- kind: str - "IntSlider" or "FloatSlider" + "IntSlider", "FloatSlider" or "FloatLogSlider" kwargs passed to the ipywidget slider constructor @@ -207,28 +238,68 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): ipywidgets.Intslider or ipywidgets.FloatSlider """ - if self.ipywidget_slider is not None: - raise AttributeError("Already has ipywidget slider") if not HAS_IPYWIDGETS: raise ImportError( "Must installed `ipywidgets` to use `make_ipywidget_slider()`" ) + if kind not in ["IntSlider", "FloatSlider", "FloatLogSlider"]: + raise TypeError( + f"`kind` must be one of: 'IntSlider', 'FloatSlider' or 'FloatLogSlider'\n" + f"You have passed: '{kind}'" + ) + cls = getattr(ipywidgets, kind) + value = self.selection() + if "Int" in kind: + value = int(self.selection()) + slider = cls( - min=self.selection.limits[0], - max=self.selection.limits[1], - value=int(self.selection()), - step=1, + min=self.limits[0], + max=self.limits[1], + value=value, **kwargs, ) - self.ipywidget_slider = slider - self._setup_ipywidget_slider(slider) + self.add_ipywidget_handler(slider) return slider + def add_ipywidget_handler( + self, + widget, + step: Union[int, float] = None + ): + """ + Bidirectionally connect events with a ipywidget slider + + Parameters + ---------- + widget: ipywidgets.IntSlider, ipywidgets.FloatSlider, or ipywidgets.FloatLogSlider + ipywidget slider to connect to + + step: int or float, default ``None`` + step size, if ``None`` 100 steps are created + + """ + + if not isinstance(widget, (ipywidgets.IntSlider, ipywidgets.FloatSlider, ipywidgets.FloatLogSlider)): + raise TypeError( + f"`widget` must be one of: ipywidgets.IntSlider, ipywidgets.FloatSlider, or ipywidgets.FloatLogSlider\n" + f"You have passed a: <{type(widget)}" + ) + + if step is None: + step = (self.limits[1] - self.limits[0]) / 100 + + if isinstance(widget, ipywidgets.IntSlider): + step = int(step) + + widget.step = step + + self._setup_ipywidget_slider(widget) + def get_selected_index(self, graphic: Graphic = None) -> Union[int, List[int]]: """ Data index the slider is currently at w.r.t. the Graphic data. With LineGraphic data, the geometry x or y diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 0759cd4fc..8579ad6d0 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -1,4 +1,13 @@ from typing import * +from numbers import Real + +try: + import ipywidgets + + HAS_IPYWIDGETS = True +except (ImportError, ModuleNotFoundError): + HAS_IPYWIDGETS = False + import numpy as np import pygfx @@ -9,6 +18,21 @@ class LinearRegionSelector(Graphic, BaseSelector): + @property + def limits(self) -> Tuple[float, float]: + return self._limits + + @limits.setter + def limits(self, values: Tuple[float, float]): + # check that `values` is an iterable of two real numbers + # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types + if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)): + raise TypeError( + "limits must be an iterable of two numeric values" + ) + self._limits = tuple(map(round, values)) # if values are close to zero things get weird so round them + self.selection._limits = self._limits + def __init__( self, bounds: Tuple[int, int], @@ -81,9 +105,9 @@ def __init__( """ - # lots of very close to zero values etc. so round them + # lots of very close to zero values etc. so round them, otherwise things get weird bounds = tuple(map(round, bounds)) - limits = tuple(map(round, limits)) + self._limits = tuple(map(round, limits)) origin = tuple(map(round, origin)) # TODO: sanity checks, we recommend users to add LinearSelection using the add_linear_selector() methods @@ -203,9 +227,13 @@ def __init__( # set the initial bounds of the selector self.selection = LinearRegionSelectionFeature( - self, bounds, axis=axis, limits=limits + self, bounds, axis=axis, limits=self._limits ) + self._handled_widgets = list() + self._block_ipywidget_call = False + self._pygfx_event = None + BaseSelector.__init__( self, edges=self.edges, @@ -341,6 +369,130 @@ def get_selected_indices( ixs = np.arange(*self.selection(), dtype=int) return ixs + def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): + """ + Makes and returns an ipywidget slider that is associated to this LinearSelector + + Parameters + ---------- + kind: str + "IntRangeSlider" or "FloatRangeSlider" + + kwargs + passed to the ipywidget slider constructor + + Returns + ------- + ipywidgets.Intslider or ipywidgets.FloatSlider + + """ + + if not HAS_IPYWIDGETS: + raise ImportError( + "Must installed `ipywidgets` to use `make_ipywidget_slider()`" + ) + + if kind not in ["IntRangeSlider", "FloatRangeSlider"]: + raise TypeError( + f"`kind` must be one of: 'IntRangeSlider', or 'FloatRangeSlider'\n" + f"You have passed: '{kind}'" + ) + + cls = getattr(ipywidgets, kind) + + value = self.selection() + if "Int" in kind: + value = tuple(map(int, self.selection())) + + slider = cls( + min=self.limits[0], + max=self.limits[1], + value=value, + **kwargs, + ) + self.add_ipywidget_handler(slider) + + return slider + + def add_ipywidget_handler( + self, + widget, + step: Union[int, float] = None + ): + """ + Bidirectionally connect events with a ipywidget slider + + Parameters + ---------- + widget: ipywidgets.IntRangeSlider or ipywidgets.FloatRangeSlider + ipywidget slider to connect to + + step: int or float, default ``None`` + step size, if ``None`` 100 steps are created + + """ + if not isinstance(widget, (ipywidgets.IntRangeSlider, ipywidgets.FloatRangeSlider)): + raise TypeError( + f"`widget` must be one of: ipywidgets.IntRangeSlider or ipywidgets.FloatRangeSlider\n" + f"You have passed a: <{type(widget)}" + ) + + if step is None: + step = (self.limits[1] - self.limits[0]) / 100 + + if isinstance(widget, ipywidgets.IntSlider): + step = int(step) + + widget.step = step + + self._setup_ipywidget_slider(widget) + + def _setup_ipywidget_slider(self, widget): + # setup an ipywidget slider with bidirectional callbacks to this LinearSelector + value = self.selection() + + if isinstance(widget, ipywidgets.IntSlider): + value = tuple(map(int, value)) + + widget.value = value + + # user changes widget -> linear selection changes + widget.observe(self._ipywidget_callback, "value") + + # user changes linear selection -> widget changes + self.selection.add_event_handler(self._update_ipywidgets) + + self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") + + self._handled_widgets.append(widget) + + def _update_ipywidgets(self, ev): + # update the ipywidget sliders when LinearSelector value changes + self._block_ipywidget_call = True # prevent infinite recursion + + value = ev.pick_info["new_data"] + # update all the handled slider widgets + for widget in self._handled_widgets: + if isinstance(widget, ipywidgets.IntSlider): + widget.value = tuple(map(int, value)) + else: + widget.value = value + + self._block_ipywidget_call = False + + def _ipywidget_callback(self, change): + # update the LinearSelector if the ipywidget value changes + if self._block_ipywidget_call or self._moving: + return + + self.selection = change["new"] + + def _set_slider_layout(self, *args): + w, h = self._plot_area.renderer.logical_size + + for widget in self._handled_widgets: + widget.layout = ipywidgets.Layout(width=f"{w}px") + def _move_graphic(self, delta: np.ndarray): # add delta to current bounds to get new positions if self.selection.axis == "x":