From a36fac3165732a3b3c61274fb1a0401e98fa8498 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 7 Sep 2023 13:39:24 -0400 Subject: [PATCH 1/5] auto connect a ipywidget slider to a linear selector, not yet tested --- fastplotlib/graphics/selectors/_linear.py | 91 +++++++++++++++++------ 1 file changed, 69 insertions(+), 22 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 39710305d..a56eade1a 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -27,7 +27,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 +56,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 @@ -85,6 +81,9 @@ def __init__( raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)") limits = tuple(map(round, limits)) + + self.limits = limits + selection = round(selection) if axis == "x": @@ -144,11 +143,6 @@ def __init__( self, axis=axis, value=selection, limits=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 +150,8 @@ def __init__( self._block_ipywidget_call = False + self._handled_widgets = list() + # init base selector BaseSelector.__init__( self, @@ -166,16 +162,36 @@ 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(self.selection()) + + 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): @@ -188,7 +204,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 +214,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,8 +224,6 @@ 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( @@ -224,11 +239,43 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): step=1, **kwargs, ) - self.ipywidget_slider = slider - self._setup_ipywidget_slider(slider) + self.add_ipywidget_handler(slider) return slider + def add_ipywidget_handler( + self, + widget: Union[ipywidgets.IntSlider, ipywidgets.FloatSlider, ipywidgets.FloatLogSlider], + 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( + "`widget` must be one of: ipywidgets.IntSlider, ipywidgets.FloatSlider, or ipywidgets.FloatLogSlider" + ) + + 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 From 10f9176959b4f2793f25a7f163177d988ae1cb61 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 7 Sep 2023 14:07:53 -0400 Subject: [PATCH 2/5] add _moving attr, smooth bidirectional link between linear selector and ipywidget sliders --- fastplotlib/graphics/selectors/_base_selector.py | 5 +++++ fastplotlib/graphics/selectors/_linear.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) 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 a56eade1a..ff8ec97fa 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -196,7 +196,7 @@ def _update_ipywidgets(self, ev): 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"] From 925c096f59395714fdbba558452c510cef5b30b5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 7 Sep 2023 14:10:25 -0400 Subject: [PATCH 3/5] update linear selector example nb --- examples/notebooks/linear_selector.ipynb | 25 ++++++++++-------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/examples/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb index a4d6b97ea..9356404e8 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", @@ -50,21 +50,16 @@ "# fastplotlib LineSelector can make an ipywidget slider and return it :D \n", "ipywidget_slider = selector.make_ipywidget_slider()\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 +130,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.3" + "version": "3.11.4" } }, "nbformat": 4, From 0fb645b827375145b2f1e1f30c80ddcfaded2bac Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 9 Sep 2023 20:50:46 -0400 Subject: [PATCH 4/5] ipywidget connector for LinearRegionSelector, limits is a settable property for linear and linearregion --- .../notebooks/linear_region_selector.ipynb | 26 ++- examples/notebooks/linear_selector.ipynb | 1 + .../graphics/_features/_selection_features.py | 12 +- fastplotlib/graphics/selectors/_linear.py | 44 +++-- .../graphics/selectors/_linear_region.py | 156 +++++++++++++++++- 5 files changed, 216 insertions(+), 23 deletions(-) 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 9356404e8..a67a30e98 100644 --- a/examples/notebooks/linear_selector.ipynb +++ b/examples/notebooks/linear_selector.ipynb @@ -49,6 +49,7 @@ "\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", 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/_linear.py b/fastplotlib/graphics/selectors/_linear.py index ff8ec97fa..7def8418e 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,19 @@ class LinearSelector(Graphic, BaseSelector): + @property + def limits(self) -> Tuple[float, float]: + return self._limits + + @limits.setter + def limits(self, values: Tuple[float, float]): + 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, @@ -80,9 +94,7 @@ 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 = limits + self._limits = tuple(map(round, limits)) selection = round(selection) @@ -140,7 +152,7 @@ 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._move_info: dict = None @@ -166,7 +178,7 @@ def _setup_ipywidget_slider(self, widget): value = self.selection() if isinstance(widget, ipywidgets.IntSlider): - value = int(self.selection()) + value = int(value) widget.value = value @@ -230,13 +242,22 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): "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.add_ipywidget_handler(slider) @@ -245,7 +266,7 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): def add_ipywidget_handler( self, - widget: Union[ipywidgets.IntSlider, ipywidgets.FloatSlider, ipywidgets.FloatLogSlider], + widget, step: Union[int, float] = None ): """ @@ -263,7 +284,8 @@ def add_ipywidget_handler( if not isinstance(widget, (ipywidgets.IntSlider, ipywidgets.FloatSlider, ipywidgets.FloatLogSlider)): raise TypeError( - "`widget` must be one of: ipywidgets.IntSlider, ipywidgets.FloatSlider, or ipywidgets.FloatLogSlider" + 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: diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 0759cd4fc..7c27f9b4a 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,19 @@ class LinearRegionSelector(Graphic, BaseSelector): + @property + def limits(self) -> Tuple[float, float]: + return self._limits + + @limits.setter + def limits(self, values: Tuple[float, float]): + 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 +103,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 +225,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 +367,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": From 39e45b399db905e9f432f93cbdbc9752dcb6d438 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 11 Sep 2023 16:59:42 -0400 Subject: [PATCH 5/5] comments --- fastplotlib/graphics/selectors/_linear.py | 2 ++ fastplotlib/graphics/selectors/_linear_region.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 7def8418e..951e353d3 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -25,6 +25,8 @@ def limits(self) -> Tuple[float, float]: @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" diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 7c27f9b4a..8579ad6d0 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -24,6 +24,8 @@ def limits(self) -> Tuple[float, float]: @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"