From 55185cc3c1ffb4a0ca469326d0f31bc7b4c85a0c Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 21 Oct 2026 23:04:09 -0400 Subject: [PATCH 1/8] Fix a few redraw callback issues * `DragRect` never actually stored a user specified callback & was always defaulting to `None` * `FlexibleRect` never called `redraw_callback` when respanning --- matplotlib_window/base.py | 5 ++++- pyproject.toml | 1 + tests/test_base_objs.py | 31 +++++++++++++++++++++++++++++++ tox.ini | 1 + uv.lock | 14 ++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/matplotlib_window/base.py b/matplotlib_window/base.py index 36f2ed9..31f5e16 100644 --- a/matplotlib_window/base.py +++ b/matplotlib_window/base.py @@ -408,7 +408,7 @@ def __init__( if width <= 0: raise ValueError(f"Width value must be greater than 0. Received: {width}") - self.redraw_callback = None + self.redraw_callback = redraw_callback # Rectangle patches are located from their bottom left corner; because we want to span the # full y range, we need to translate the y position to the bottom of the axes @@ -599,6 +599,9 @@ def _respan_face(self) -> None: self.face.myobj.set_xy(rect_params.xy) self.face.myobj.set_width(width) + if self.redraw_callback is not None: + self.redraw_callback() + self.parent_canvas.draw() # Call directly to avoid infinitely spamming the callback @property diff --git a/pyproject.toml b/pyproject.toml index c7c8472..b971925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dev = [ "pre-commit~=4.0", "pytest~=9.0", "pytest-cov~=7.0", + "pytest-mock~=3.17", "pytest-randomly~=5.0", "ruff~=0.7", "sco1-bumper~=2.0", diff --git a/tests/test_base_objs.py b/tests/test_base_objs.py index 22a71db..102dc9b 100644 --- a/tests/test_base_objs.py +++ b/tests/test_base_objs.py @@ -1,4 +1,5 @@ import pytest +from pytest_mock import MockerFixture from matplotlib_window.base import DragLine, DragRect, FlexibleRect, Orientation from tests.conftest import PLOTOBJ_T @@ -68,3 +69,33 @@ def test_flexrect_disables_click(plotobj: PLOTOBJ_T) -> None: parent_canvas = dr.face.parent_canvas assert not has_callback_to(parent_canvas, "DragRect") + + +def test_dragline_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + dl = DragLine(ax=ax, position=0, redraw_callback=callback) + + ax.set_ylim(0, 10) # Limit change triggers a redraw + callback.assert_called() + assert dl.redraw_callback is callback + + +def test_dragrect_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + dr = DragRect(ax=ax, position=0, width=1, redraw_callback=callback) + + ax.set_ylim(0, 10) # Limit change triggers a redraw + callback.assert_called() + assert dr.redraw_callback is callback + + +def test_flexrect_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + fr = FlexibleRect(ax=ax, position=0, width=1, redraw_callback=callback) + + ax.set_ylim(0, 10) # Limit change triggers a redraw + callback.assert_called() + assert fr.redraw_callback is callback diff --git a/tox.ini b/tox.ini index 57d5d0c..3f98b99 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,7 @@ commands = python -m pytest deps = pytest pytest-cov + pytest-mock pytest-randomly [testenv:clean] diff --git a/uv.lock b/uv.lock index 475b02a..a880afa 100644 --- a/uv.lock +++ b/uv.lock @@ -982,6 +982,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-mock" }, { name = "pytest-randomly" }, { name = "ruff" }, { name = "sco1-bumper" }, @@ -1003,6 +1004,7 @@ dev = [ { name = "pre-commit", specifier = "~=4.0" }, { name = "pytest", specifier = "~=9.0" }, { name = "pytest-cov", specifier = "~=7.0" }, + { name = "pytest-mock", specifier = "~=3.17" }, { name = "pytest-randomly", specifier = "~=5.0" }, { name = "ruff", specifier = "~=0.7" }, { name = "sco1-bumper", specifier = "~=2.0" }, @@ -1485,6 +1487,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz", hash = "sha256:e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4", size = 43064, upload-time = "2026-10-17T16:21:42.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl", hash = "sha256:1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b", size = 11248, upload-time = "2026-10-17T16:21:41.265Z" }, +] + [[package]] name = "pytest-randomly" version = "5.0.0" From 4e7824e14f204581af3f0b262d39340cfda5f5d2 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 22 Oct 2026 16:44:55 -0400 Subject: [PATCH 2/8] Fix incorrect resizing of `DragRect` on axis limit change Height was being adjusted correctly but was never shifted to the new lower y bound so it never spanned the whole plot --- matplotlib_window/base.py | 3 ++- tests/test_base_objs.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/matplotlib_window/base.py b/matplotlib_window/base.py index 31f5e16..89532b4 100644 --- a/matplotlib_window/base.py +++ b/matplotlib_window/base.py @@ -493,7 +493,8 @@ def limit_change(self, ax: Axes) -> None: Resize the rectangle to span the entirety of the y-axis if the axis limit is changed. """ - rect_params = transform_rect_params(ax, 0) # Doesn't matter what the x is, only need height + rect_params = transform_rect_params(ax, self.myobj.get_x()) + self.myobj.set_xy(rect_params.xy) self.myobj.set_height(rect_params.height) self._redraw() diff --git a/tests/test_base_objs.py b/tests/test_base_objs.py index 102dc9b..88e4699 100644 --- a/tests/test_base_objs.py +++ b/tests/test_base_objs.py @@ -39,6 +39,16 @@ def test_dragrect_get_bounds(plotobj: PLOTOBJ_T) -> None: assert dr.bounds == (0, 1) +def test_dragrect_limit_change_respans(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0.2, width=0.3) + + ax.set_ylim(5, 15) + + assert dr.myobj.get_xy() == pytest.approx((0.2, 5)) + assert dr.myobj.get_height() == pytest.approx(10) + + def test_dragrect_click_disable(plotobj: PLOTOBJ_T) -> None: _, ax = plotobj dr = DragRect(ax=ax, position=0, width=1) @@ -63,6 +73,16 @@ def test_flexrect_get_bounds(plotobj: PLOTOBJ_T) -> None: assert dr.bounds == (0, 1) +def test_flexrect_limit_change_respans(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3) + + ax.set_ylim(5, 15) + + assert fr.face.myobj.get_xy() == pytest.approx((0.2, 5)) + assert fr.face.myobj.get_height() == pytest.approx(10) + + def test_flexrect_disables_click(plotobj: PLOTOBJ_T) -> None: _, ax = plotobj dr = FlexibleRect(ax=ax, position=0, width=1, allow_face_drag=False) From 4ae2161320b31740c880a7bcc2aceee7d4584598 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 22 Oct 2026 20:05:09 -0400 Subject: [PATCH 3/8] Fix horizontal `DragLine` validating `snap_to` against incorrect data Horizontal lines had been accidentally validating against the snapto's xdata. This was masked in the tests by the dummy line having the same x & y data. --- matplotlib_window/base.py | 19 ++++++++++++------- tests/test_base_helpers.py | 2 +- tests/test_snapto.py | 21 +++++++++++++++++++-- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/matplotlib_window/base.py b/matplotlib_window/base.py index 89532b4..3fa80d2 100644 --- a/matplotlib_window/base.py +++ b/matplotlib_window/base.py @@ -170,9 +170,9 @@ def disconnect(self) -> None: def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: """ - Validate that the `snap_to` object, if provided, actually contains x data. + Validate that the `snap_to` object, if provided, actually contains lineseries xy data. - If `snap_to` is `None`, or is a plot object that contains x data, it is returned unchanged. + If `snap_to` is `None`, or is a plot object that contains xy data, it is returned unchanged. Otherwise an exception is raised. """ if snap_to is None: @@ -320,10 +320,10 @@ def limit_change(self, ax: Axes) -> None: def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: """ - Validate that the `snap_to` object, if provided, actually contains x data. + Validate that the `snap_to` object, if provided, bounds the line being created. - If `snap_to` is `None`, or is a plot object that contains x data, it is returned unchanged. - Otherwise an exception is raised. + If `snap_to` is `None`, or is a plot object whose data along the line's axis of motion + contains the line's location, it is returned unchanged. Otherwise an exception is raised. NOTE: This should be called after the draggable object is registered so the object is instantiated & references are set. @@ -334,8 +334,13 @@ def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: # Superclass implementation handles checking that the lineseries contains data super().validate_snap_to(snap_to) - # Check that the draggable line is within the bounds of the snap_to lineseries - plotted_data = snap_to.get_xdata() + # Check that the draggable line is within the bounds of the snap_to lineseries along its + # axis of motion + if self.orientation == Orientation.HORIZONTAL: + plotted_data = snap_to.get_ydata() + else: + plotted_data = snap_to.get_xdata() + min_val, max_val = plotted_data.min(), plotted_data.max() # type: ignore[union-attr] if not (min_val <= self.location <= max_val): raise ValueError("DragLine must be within the bounds of the provided snapto line") diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index 8dcc960..b4170f0 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -24,7 +24,7 @@ def test_limit_drag(query: NUMERIC_T, truth_out: NUMERIC_T) -> None: assert limit_drag(plotted_data=PLOTTED_DATA, query=query) == pytest.approx(truth_out) -PLOTTED_TIMEDELTA = np.array([np.timedelta64(i) for i in range(1, 6)]) +PLOTTED_TIMEDELTA = np.array([np.timedelta64(i, "D") for i in range(1, 6)]) NP_TIMEDELTA_LIMIT_DRAG_TEST_CASES = ( (-1, 1), (-1.0, 1), diff --git a/tests/test_snapto.py b/tests/test_snapto.py index c45f9b4..25f4c1a 100644 --- a/tests/test_snapto.py +++ b/tests/test_snapto.py @@ -46,7 +46,8 @@ def test_dragrect_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: dr.validate_snap_to(EMPTY_LINE) -DUMMY_LINE = Line2D(xdata=np.array([0, 1, 2]), ydata=np.array([0, 1, 2])) +# Make sure x & y data are different so we're not masking issues +DUMMY_LINE = Line2D(xdata=np.array([0, 1, 2]), ydata=np.array([10, 11, 12])) def test_vertical_dragline_snapto_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: @@ -72,7 +73,23 @@ def test_vertical_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: def test_horizontal_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: _, ax = plotobj - _ = DragLine(ax, position=1, orientation=Orientation.HORIZONTAL, snap_to=DUMMY_LINE) + _ = DragLine(ax, position=11, orientation=Orientation.HORIZONTAL, snap_to=DUMMY_LINE) + + +def test_vertical_dragline_snapto_oob_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=101, orientation=Orientation.VERTICAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) + + +def test_horizontal_dragline_snapto_oob_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=1, orientation=Orientation.HORIZONTAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) RECT_BOUNDS_TEST_CASES = ( From d879a5178fce65032c896964d55d8c7c6b74ac75 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 22 Oct 2026 20:24:50 -0400 Subject: [PATCH 4/8] Merge user's window helper plot/axes kwargs with the defaults --- README.md | 8 ++++---- matplotlib_window/window.py | 26 ++++++++++++++++++-------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 541d84a..7b9b7f1 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ Plot the provided data & build a draggable fixed-width window to select bounds o | `position` | x-coordinate of the left edge of the window | `int\|float` | Required | | `window_width` | Width, along the x-axis, of the draggable window | `int\|float` | Required | | `snap_to_data` | Prevent dragging of the window beyond beyond the bounds of the plotted data | `bool` | `True` | -| `axes_kwargs` | Optional kwargs to pass to the `Axes` constructor1 | `dict[str, Any]` | `{"title": ...}` | +| `axes_kwargs` | Optional kwargs to pass to the `Axes` setter1 | `dict[str, Any]` | `{"title": ...}` | | `plot_kwargs` | Optional kwargs to pass to the plotting call2 | `dict[str, Any]` | `{}` | -1. kwargs are passed directly to the `Axes` constructor, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. +1. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. 2. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. ### `flexible_window` @@ -40,9 +40,9 @@ Plot the provided data & build a flexible-width window to select bounds of inter | `window_width` | Starting width, along the x-axis, of the flexible window | `int\|float` | Required | | `snap_to_data` | Prevent dragging of the window beyond beyond the bounds of the plotted data | `bool` | `True` | | `allow_face_drag` | Allow dragging of the window using its face1 | `bool` | `False` | -| `axes_kwargs` | Optional kwargs to pass to the `Axes` constructor2 | `dict[str, Any]` | `{"title": ...}` | +| `axes_kwargs` | Optional kwargs to pass to the `Axes` setter2 | `dict[str, Any]` | `{"title": ...}` | | `plot_kwargs` | Optional kwargs to pass to the plotting call3 | `dict[str, Any]` | `{}` | 1. Currently not implemented -2. kwargs are passed directly to the `Axes` constructor, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. +2. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. 3. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. diff --git a/matplotlib_window/window.py b/matplotlib_window/window.py index a302051..a20f81d 100644 --- a/matplotlib_window/window.py +++ b/matplotlib_window/window.py @@ -18,8 +18,8 @@ def fixed_window( position: NUMERIC_T, window_width: NUMERIC_T, snap_to_data: bool = True, - axes_kwargs: dict[str, t.Any] = DEFAULT_AXES_KWARGS, - plot_kwargs: dict[str, t.Any] = DEFAULT_PLOT_KWARGS, + axes_kwargs: dict[str, t.Any] | None = None, + plot_kwargs: dict[str, t.Any] | None = None, ) -> tuple[NUMERIC_T, NUMERIC_T]: """ Plot the provided data & build a draggable fixed-width window to select bounds of interest. @@ -34,10 +34,15 @@ def fixed_window( `axes_kwargs` and `plot_kwargs` may be optionally specified to control the appearance of the resulting `Axes` and `Line2D` objects, respectively, and are passed straight through to their respective objects. Consult their respective documentation for available parameters. + + NOTE: User specified axes/plot kwargs are merged with the module level defaults, where the user + input wins on conflict. """ _, ax = plt.subplots() - ax.set(**axes_kwargs) - ls = ax.plot(x_data, y_data, **plot_kwargs) + + # Merge any user defined axes/plot kwargs with default; user wins vs. default + ax.set(**(DEFAULT_AXES_KWARGS | (axes_kwargs or {}))) + ls = ax.plot(x_data, y_data, **(DEFAULT_PLOT_KWARGS | (plot_kwargs or {}))) if snap_to_data: snap_to = ls[0] @@ -57,8 +62,8 @@ def flexible_window( window_width: NUMERIC_T, snap_to_data: bool = True, allow_face_drag: bool = False, - axes_kwargs: dict[str, t.Any] = DEFAULT_AXES_KWARGS, - plot_kwargs: dict[str, t.Any] = DEFAULT_PLOT_KWARGS, + axes_kwargs: dict[str, t.Any] | None = None, + plot_kwargs: dict[str, t.Any] | None = None, ) -> tuple[NUMERIC_T, NUMERIC_T]: """ Plot the provided data & build a flexible-width window to select bounds of interest. @@ -76,10 +81,15 @@ def flexible_window( `axes_kwargs` and `plot_kwargs` may be optionally specified to control the appearance of the resulting `Axes` and `Line2D` objects, respectively, and are passed straight through to their respective objects. Consult their respective documentation for available parameters. + + NOTE: User specified axes/plot kwargs are merged with the module level defaults, where the user + input wins on conflict. """ _, ax = plt.subplots() - ax.set(**axes_kwargs) - ls = ax.plot(x_data, y_data, **plot_kwargs) + + # Merge any user defined axes/plot kwargs with default; user wins vs. default + ax.set(**(DEFAULT_AXES_KWARGS | (axes_kwargs or {}))) + ls = ax.plot(x_data, y_data, **(DEFAULT_PLOT_KWARGS | (plot_kwargs or {}))) if snap_to_data: snap_to = ls[0] From e9d06ed0501529f1d43c941fd51d41a9edb30de3 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 22 Oct 2026 21:19:02 -0400 Subject: [PATCH 5/8] Utilize native axis data for snap checks More generic implementation of the timedelta-specific fix for #8 --- CHANGELOG.md | 11 ++++ matplotlib_window/base.py | 62 +++++++++++---------- tests/helpers.py | 25 ++++++++- tests/test_base_helpers.py | 48 +++++++++------- tests/test_snapto.py | 111 ++++++++++++++++++++++++++++++++++++- 5 files changed, 206 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b18061..b30154e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (``.``.``) +## [v1.2.0] +### Changed +* Windowing helper utilities now merge user-specified axes/plot kwargs with defaults, preferring user inputs +* (Internal) Snap checking now utilizes axes native data rather than having to special case multiple numpy datatypes to prevent errors + +### Fixed +* Fix `DragRect` completely ignoring user-specified redraw callbacks +* Fix `FlexibleRect` not calling the redraw callback when respanning +* Fix incorrect resizing of `DragRect` on axis limit change +* Fix `DragLine` accidentally validating `snap_to` against xdata for vertical lines + ## [v1.1.0] ### Changed * #9 Dragging is now inhibited while another widget has the canvas locked (e.g. zooming/panning) diff --git a/matplotlib_window/base.py b/matplotlib_window/base.py index 3fa80d2..ed07354 100644 --- a/matplotlib_window/base.py +++ b/matplotlib_window/base.py @@ -200,29 +200,31 @@ def _redraw(self) -> None: self.parent_canvas.draw() +def get_native_data(line: Line2D, axis: t.Literal["x", "y"]) -> npt.NDArray[np.float64]: + """ + Return the line's data along the specified axis, converted to the axes' native float units. + + Plotted data may be of any type supported by matplotlib's unit conversion (e.g. datetimes or + timedeltas), but mouse event coordinates & axes limits are always provided in native units. + """ + if axis == "x": + data = line.get_xdata(orig=False) + else: + data = line.get_ydata(orig=False) + + return np.asarray(data, dtype=float) + + def limit_drag(plotted_data: npt.ArrayLike, query: float) -> float: - """Clamp the query value within the bounds of the provided dataset.""" + """ + Clamp the query value within the bounds of the provided dataset. + + NOTE: `plotted_data` is assumed to be numeric & of the same units as `query`; see + `get_native_data` for converting plotted data to match event coordinates. + """ # Data series may not be sorted, so use min/max - # I'm not sure how to properly type annotate this right now - min_val, max_val = plotted_data.min(), plotted_data.max() # type: ignore[union-attr] - - # Per #8, numpy's timedeltas don't support gt/lt so we need to extract a different value for our - # comparison if we're using them - # If the reach ends up being wider then it might be better to perform a more generic check but - # for now the explicit conversion should cover the currently encountered use cases - if isinstance(min_val, np.timedelta64): # min_val and max_val should be the same type - # I couldn't figure out how to access the timedelta unit that numpy holds internally, but - # casting to float seems to work well enough to keep it in the same dimension being used for - # the plot - min_val = min_val.astype(float) - max_val = max_val.astype(float) - - if query > max_val: - return max_val # type: ignore[no-any-return] - elif query < min_val: - return min_val # type: ignore[no-any-return] - else: - return query + data = np.asarray(plotted_data, dtype=float) + return float(np.clip(query, data.min(), data.max())) class DragLine(_DraggableObject): @@ -290,14 +292,14 @@ def on_motion(self, event: Event) -> t.Any: if self.orientation == Orientation.HORIZONTAL: if self.snap_to: - new_pos = limit_drag(self.snap_to.get_ydata(), event.ydata) + new_pos = limit_drag(get_native_data(self.snap_to, "y"), event.ydata) else: new_pos = event.ydata self.myobj.set_ydata((new_pos, new_pos)) elif self.orientation == Orientation.VERTICAL: if self.snap_to: - new_pos = limit_drag(self.snap_to.get_xdata(), event.xdata) + new_pos = limit_drag(get_native_data(self.snap_to, "x"), event.xdata) else: new_pos = event.xdata @@ -337,11 +339,11 @@ def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: # Check that the draggable line is within the bounds of the snap_to lineseries along its # axis of motion if self.orientation == Orientation.HORIZONTAL: - plotted_data = snap_to.get_ydata() + plotted_data = get_native_data(snap_to, "y") else: - plotted_data = snap_to.get_xdata() + plotted_data = get_native_data(snap_to, "x") - min_val, max_val = plotted_data.min(), plotted_data.max() # type: ignore[union-attr] + min_val, max_val = plotted_data.min(), plotted_data.max() if not (min_val <= self.location <= max_val): raise ValueError("DragLine must be within the bounds of the provided snapto line") @@ -464,12 +466,12 @@ def on_motion(self, event: Event) -> t.Any: if dx < 0: # Moving left, check left edge query = old_x + dx - new_x = limit_drag(self.snap_to.get_xdata(), query) + new_x = limit_drag(get_native_data(self.snap_to, "x"), query) else: # Moving right, check right edge width = self.myobj.get_width() query = old_x + width + dx - new_x = limit_drag(self.snap_to.get_xdata(), query) - width + new_x = limit_drag(get_native_data(self.snap_to, "x"), query) - width else: new_x = old_x + dx @@ -521,8 +523,8 @@ def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: # Check that the draggable rectangle is within the bounds of the snap_to lineseries l_pos, r_pos = self.bounds - plotted_data = snap_to.get_xdata() - min_val, max_val = plotted_data.min(), plotted_data.max() # type: ignore[union-attr] + plotted_data = get_native_data(snap_to, "x") + min_val, max_val = plotted_data.min(), plotted_data.max() if not (min_val <= l_pos <= max_val) or not (min_val <= r_pos <= max_val): raise ValueError("DragRect must be within the bounds of the provided snapto line") diff --git a/tests/helpers.py b/tests/helpers.py index 6e9de55..bc399df 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,4 +1,5 @@ -from matplotlib.backend_bases import FigureCanvasBase +from matplotlib.axes import Axes +from matplotlib.backend_bases import FigureCanvasBase, MouseButton, MouseEvent def has_callback_to( @@ -16,3 +17,25 @@ def has_callback_to( return True return False + + +def fire_event(ax: Axes, name: str, xdata: float, ydata: float) -> None: + """ + Dispatch a `MouseEvent` of the provided `name` to the axes' canvas. + + NOTE: `xdata` and `ydata` are expected in the axes' native float units. + """ + if ax.figure is None: + raise ValueError("Axes must belong to a figure.") + + canvas = ax.figure.canvas + x, y = ax.transData.transform((xdata, ydata)) + event = MouseEvent(name, canvas, x, y, button=MouseButton.LEFT) + canvas.callbacks.process(name, event) + + +def drag(ax: Axes, start: tuple[float, float], end: tuple[float, float]) -> None: + """Simulate a click & drag from `start` to `end`, provided in the axes' native float units.""" + fire_event(ax, "button_press_event", *start) + fire_event(ax, "motion_notify_event", *end) + fire_event(ax, "button_release_event", *end) diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index b4170f0..1244431 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -1,8 +1,16 @@ +import matplotlib.dates as mdates import matplotlib.pyplot as plt import numpy as np import pytest -from matplotlib_window.base import NUMERIC_T, RectParams, limit_drag, transform_rect_params +from matplotlib_window.base import ( + NUMERIC_T, + RectParams, + get_native_data, + limit_drag, + transform_rect_params, +) +from tests.conftest import PLOTOBJ_T PLOTTED_DATA = np.array([1, 2, 3, 4, 5]) LIMIT_DRAG_TEST_CASES = ( @@ -24,28 +32,30 @@ def test_limit_drag(query: NUMERIC_T, truth_out: NUMERIC_T) -> None: assert limit_drag(plotted_data=PLOTTED_DATA, query=query) == pytest.approx(truth_out) -PLOTTED_TIMEDELTA = np.array([np.timedelta64(i, "D") for i in range(1, 6)]) -NP_TIMEDELTA_LIMIT_DRAG_TEST_CASES = ( - (-1, 1), - (-1.0, 1), - (1, 1), - (1.0, 1.0), - (3, 3), - (3.0, 3), - (5, 5), - (5.0, 5), - (7, 5), - (7.0, 5), -) +def test_limit_drag_unsorted_data() -> None: + assert limit_drag(plotted_data=np.array([3, 5, 1, 4, 2]), query=7) == pytest.approx(5) + + +def test_get_native_data_numeric(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot([1, 2, 3], [4, 5, 6])[0] + + np.testing.assert_allclose(get_native_data(ls, "x"), [1, 2, 3]) + np.testing.assert_allclose(get_native_data(ls, "y"), [4, 5, 6]) + +def test_get_native_data_datetime(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dates = np.arange("2026-01-01", "2026-01-04", dtype="datetime64[D]") + ls = ax.plot(dates, dates)[0] -@pytest.mark.parametrize(("query", "truth_out"), NP_TIMEDELTA_LIMIT_DRAG_TEST_CASES) -def test_limit_np_timedelta_drag(query: NUMERIC_T, truth_out: NUMERIC_T) -> None: - assert limit_drag(plotted_data=PLOTTED_TIMEDELTA, query=query) == pytest.approx(truth_out) + truth_data = mdates.date2num(dates) # type: ignore[no-untyped-call] + np.testing.assert_allclose(get_native_data(ls, "x"), truth_data) + np.testing.assert_allclose(get_native_data(ls, "y"), truth_data) -def test_transform_rect() -> None: - _, ax = plt.subplots() +def test_transform_rect(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj ax.set(xlim=(-10, 10), ylim=(-10, 10)) truth_params = RectParams(xy=(0, -10), height=20) diff --git a/tests/test_snapto.py b/tests/test_snapto.py index 25f4c1a..e46aec1 100644 --- a/tests/test_snapto.py +++ b/tests/test_snapto.py @@ -1,9 +1,19 @@ +import datetime as dt +import typing as t + import numpy as np import pytest from matplotlib.lines import Line2D -from matplotlib_window.base import DragLine, DragRect, Orientation, _DraggableObject +from matplotlib_window.base import ( + DragLine, + DragRect, + Orientation, + _DraggableObject, + get_native_data, +) from tests.conftest import PLOTOBJ_T +from tests.helpers import drag def test_dragobj_snapto_none_passthrough() -> None: @@ -113,3 +123,102 @@ def test_dragrect_snapto_out_of_bounds_raises( def test_dragrect_valid_snapto(plotobj: PLOTOBJ_T) -> None: _, ax = plotobj _ = DragRect(ax=ax, position=0, width=1, snap_to=DUMMY_LINE) + + +# snap_to data should work for any type supported by matplotlib's unit conversion, so the tests +# below are run against a range of data types rather than special-casing each one +SNAP_TO_DATA: dict[str, t.Any] = { + "numeric": np.arange(5), + "datetime64[D]": np.arange("2026-01-01", "2026-01-06", dtype="datetime64[D]"), + "datetime64[h]": np.arange("2026-01-01T00", "2026-01-01T05", dtype="datetime64[h]"), + "datetime64[ns]": np.arange(5).astype("timedelta64[s]") + np.datetime64("2026-01-01", "ns"), + "timedelta64[D]": np.arange(1, 6).astype("timedelta64[D]"), # See: #8 + "timedelta64[s]": np.arange(1, 6).astype("timedelta64[s]"), + "datetime": [dt.datetime(2026, 1, day) for day in range(1, 6)], + "date": [dt.date(2026, 1, day) for day in range(1, 6)], +} +SNAP_TO_DATA_PARAMS = pytest.mark.parametrize( + "data", SNAP_TO_DATA.values(), ids=SNAP_TO_DATA.keys() +) + + +def _native_extents(line: Line2D, axis: t.Literal["x", "y"]) -> tuple[float, float, float]: + """Return the min, max, & span of the line's data along `axis`, in native axes units.""" + native = get_native_data(line, axis) + return native.min(), native.max(), (native.max() - native.min()) + + +@SNAP_TO_DATA_PARAMS +def test_vertical_dragline_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + lo, hi, span = _native_extents(ls, "x") + ax.set_xlim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes + + start = get_native_data(ls, "x")[1] + dl = DragLine(ax, position=start, orientation=Orientation.VERTICAL, snap_to=ls) + + drag(ax, (start, 2), (hi + span / 2, 2)) + assert dl.location == pytest.approx(hi, abs=span * 1e-6) + + drag(ax, (hi, 2), (lo - span / 2, 2)) + assert dl.location == pytest.approx(lo, abs=span * 1e-6) + + +@SNAP_TO_DATA_PARAMS +def test_horizontal_dragline_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(range(5), data)[0] + lo, hi, span = _native_extents(ls, "y") + ax.set_ylim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes + + start = get_native_data(ls, "y")[1] + dl = DragLine(ax, position=start, orientation=Orientation.HORIZONTAL, snap_to=ls) + + drag(ax, (2, start), (2, hi + span / 2)) + assert dl.location == pytest.approx(hi, abs=span * 1e-6) + + drag(ax, (2, hi), (2, lo - span / 2)) + assert dl.location == pytest.approx(lo, abs=span * 1e-6) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + lo, hi, span = _native_extents(ls, "x") + ax.set_xlim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes + + native = get_native_data(ls, "x") + width = native[2] - native[1] + dr = DragRect(ax, position=native[1], width=width, snap_to=ls) + + # Drag from the middle of the rectangle so the click lands on the patch; dragging by the full + # data span pushes the leading edge beyond the data while keeping the cursor inside the axes + mid = native[1] + width / 2 + drag(ax, (mid, 2), (mid + span, 2)) + assert dr.bounds == pytest.approx((hi - width, hi), abs=span * 1e-6) + + mid = hi - width / 2 + drag(ax, (mid, 2), (mid - span, 2)) + assert dr.bounds == pytest.approx((lo, lo + width), abs=span * 1e-6) + + +@SNAP_TO_DATA_PARAMS +def test_dragline_snapto_data_types_out_of_bounds_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + _, hi, span = _native_extents(ls, "x") + + with pytest.raises(ValueError, match="bounds"): + _ = DragLine(ax, position=(hi + span), snap_to=ls) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_snapto_data_types_out_of_bounds_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + _, hi, span = _native_extents(ls, "x") + + with pytest.raises(ValueError, match="bounds"): + _ = DragRect(ax, position=hi, width=span, snap_to=ls) From b656a73fe45c1c495c4517843c9af5b42f9bf67b Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 22 Oct 2026 21:43:43 -0400 Subject: [PATCH 6/8] Add NaN awareness --- CHANGELOG.md | 1 + matplotlib_window/base.py | 20 +++++---- tests/test_base_helpers.py | 9 ++++- tests/test_snapto.py | 83 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b30154e..0b6c963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (` Line2D | None: """ Validate that the `snap_to` object, if provided, actually contains lineseries xy data. - If `snap_to` is `None`, or is a plot object that contains xy data, it is returned unchanged. - Otherwise an exception is raised. + If `snap_to` is `None`, or is a plot object that contains at least one finite xy data point, + it is returned unchanged. Otherwise an exception is raised. """ if snap_to is None: return None try: - xydata = snap_to.get_xydata() + xydata = np.asarray(snap_to.get_xydata()) except AttributeError as e: raise ValueError("Cannot provide an empty lineseries to snapto") from e - if len(xydata) == 0: # type: ignore[arg-type] + if len(xydata) == 0: raise ValueError("Cannot provide an empty lineseries to snapto") + # NaN gaps are fine, but there needs to be at least one plotted point to snap to + if not np.isfinite(xydata).all(axis=1).any(): + raise ValueError("Cannot provide a lineseries without any finite data to snapto") + return snap_to def _disable_click(self) -> None: @@ -222,9 +226,9 @@ def limit_drag(plotted_data: npt.ArrayLike, query: float) -> float: NOTE: `plotted_data` is assumed to be numeric & of the same units as `query`; see `get_native_data` for converting plotted data to match event coordinates. """ - # Data series may not be sorted, so use min/max + # Data series may not be sorted, so use min/max; NaN-aware since plotted data may contain gaps data = np.asarray(plotted_data, dtype=float) - return float(np.clip(query, data.min(), data.max())) + return float(np.clip(query, np.nanmin(data), np.nanmax(data))) class DragLine(_DraggableObject): @@ -343,7 +347,7 @@ def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: else: plotted_data = get_native_data(snap_to, "x") - min_val, max_val = plotted_data.min(), plotted_data.max() + min_val, max_val = np.nanmin(plotted_data), np.nanmax(plotted_data) if not (min_val <= self.location <= max_val): raise ValueError("DragLine must be within the bounds of the provided snapto line") @@ -524,7 +528,7 @@ def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: # Check that the draggable rectangle is within the bounds of the snap_to lineseries l_pos, r_pos = self.bounds plotted_data = get_native_data(snap_to, "x") - min_val, max_val = plotted_data.min(), plotted_data.max() + min_val, max_val = np.nanmin(plotted_data), np.nanmax(plotted_data) if not (min_val <= l_pos <= max_val) or not (min_val <= r_pos <= max_val): raise ValueError("DragRect must be within the bounds of the provided snapto line") diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index 1244431..5d76776 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -1,5 +1,4 @@ import matplotlib.dates as mdates -import matplotlib.pyplot as plt import numpy as np import pytest @@ -36,6 +35,14 @@ def test_limit_drag_unsorted_data() -> None: assert limit_drag(plotted_data=np.array([3, 5, 1, 4, 2]), query=7) == pytest.approx(5) +GAPPED_DATA = np.array([np.nan, 1, 2, np.nan, 4, 5, np.nan]) + + +@pytest.mark.parametrize(("query", "truth_out"), LIMIT_DRAG_TEST_CASES) +def test_limit_drag_nan_gaps(query: NUMERIC_T, truth_out: NUMERIC_T) -> None: + assert limit_drag(plotted_data=GAPPED_DATA, query=query) == pytest.approx(truth_out) + + def test_get_native_data_numeric(plotobj: PLOTOBJ_T) -> None: _, ax = plotobj ls = ax.plot([1, 2, 3], [4, 5, 6])[0] diff --git a/tests/test_snapto.py b/tests/test_snapto.py index e46aec1..37a9c15 100644 --- a/tests/test_snapto.py +++ b/tests/test_snapto.py @@ -56,6 +56,89 @@ def test_dragrect_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: dr.validate_snap_to(EMPTY_LINE) +ALL_NAN_LINE = Line2D(xdata=np.full(3, np.nan), ydata=np.full(3, np.nan)) + + +def test_dragobj_snapto_all_nan_data_raises() -> None: + do = _DraggableObject() + with pytest.raises(ValueError, match="finite"): + do.validate_snap_to(ALL_NAN_LINE) + + +def test_dragline_snapto_all_nan_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0) + with pytest.raises(ValueError, match="finite"): + dl.validate_snap_to(ALL_NAN_LINE) + + +def test_dragrect_snapto_all_nan_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + with pytest.raises(ValueError, match="finite"): + dr.validate_snap_to(ALL_NAN_LINE) + + +# NaN gaps, including at the ends, are a common way to represent missing data +GAPPED_X = np.array([np.nan, 0, 1, np.nan, 3, 4, np.nan]) +GAPPED_Y = np.array([np.nan, 10, 11, np.nan, 13, 14, np.nan]) + + +def test_vertical_dragline_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + ax.set_xlim(-4, 8) # Leave room to drag beyond the data inside the axes + dl = DragLine(ax, position=2, orientation=Orientation.VERTICAL, snap_to=ls) + + drag(ax, (2, 12), (6, 12)) + assert dl.location == pytest.approx(4) + + drag(ax, (4, 12), (-2, 12)) + assert dl.location == pytest.approx(0) + + +def test_horizontal_dragline_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + ax.set_ylim(6, 18) # Leave room to drag beyond the data inside the axes + dl = DragLine(ax, position=12, orientation=Orientation.HORIZONTAL, snap_to=ls) + + drag(ax, (2, 12), (2, 16)) + assert dl.location == pytest.approx(14) + + drag(ax, (2, 14), (2, 8)) + assert dl.location == pytest.approx(10) + + +def test_dragrect_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + ax.set_xlim(-4, 8) # Leave room to drag beyond the data inside the axes + dr = DragRect(ax, position=1, width=1, snap_to=ls) + + drag(ax, (1.5, 12), (5.5, 12)) + assert dr.bounds == pytest.approx((3, 4)) + + drag(ax, (3.5, 12), (-0.5, 12)) + assert dr.bounds == pytest.approx((0, 1)) + + +def test_dragline_snapto_nan_gaps_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + + with pytest.raises(ValueError, match="bounds"): + _ = DragLine(ax, position=5, snap_to=ls) + + +def test_dragrect_snapto_nan_gaps_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + + with pytest.raises(ValueError, match="bounds"): + _ = DragRect(ax, position=3, width=2, snap_to=ls) + + # Make sure x & y data are different so we're not masking issues DUMMY_LINE = Line2D(xdata=np.array([0, 1, 2]), ydata=np.array([10, 11, 12])) From 7c3f9b406057b9e06fc4c3233aa3bb076f9d0339 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 22 Oct 2026 22:07:47 -0400 Subject: [PATCH 7/8] Broaden input types supported by plot objects & associated helpers When plotting data, matplotlib performs an implicit float conversion to move the xy data into native axis data. Since we already have to track this change to support snap checking, we might as well loosen the input constraints across the board --- CHANGELOG.md | 1 + README.md | 59 +++++++++++++++++-------------- matplotlib_window/base.py | 69 +++++++++++++++++++++++++++++++++---- matplotlib_window/window.py | 28 ++++++++++----- tests/test_base_helpers.py | 21 +++++++++++ tests/test_snapto.py | 66 +++++++++++++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6c963..63e1908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (`1 | `dict[str, Any]` | `{"title": ...}` | -| `plot_kwargs` | Optional kwargs to pass to the plotting call2 | `dict[str, Any]` | `{}` | - -1. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. -2. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. +| Parameter | Description | Type | Default | +|----------------|----------------------------------------------------------------------|------------------|------------------| +| `x_data` | x data values to plot | See: Note 1 | Required | +| `y_data` | y data values to plot | See: Note 1 | Required | +| `position` | x-coordinate of the left edge of the window | See: Note 1 | Required | +| `window_width` | Width, along the x-axis, of the draggable window | See: Note 1 | Required | +| `snap_to_data` | Prevent dragging of the window beyond the bounds of the plotted data | `bool` | `True` | +| `axes_kwargs` | Optional kwargs to pass to the `Axes` setter2 | `dict[str, Any]` | `{"title": ...}` | +| `plot_kwargs` | Optional kwargs to pass to the plotting call3 | `dict[str, Any]` | `{}` | + +1. `int|float`, or any type supported by matplotlib's unit conversion, see: [Units](#units) +2. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. +3. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. ### `flexible_window` Plot the provided data & build a flexible-width window to select bounds of interest; the x-locations of the edges of the window are returned once the figure window is closed. #### Parameters -| Parameter | Description | Type | Default | -|-------------------|-----------------------------------------------------------------------------|------------------------|------------------| -| `x_data` | x data values to plot | `Sequence[int\|float]` | Required | -| `y_data` | y data values to plot | `Sequence[int\|float]` | Required | -| `position` | x-coordinate of the left edge of the window | `int\|float` | Required | -| `window_width` | Starting width, along the x-axis, of the flexible window | `int\|float` | Required | -| `snap_to_data` | Prevent dragging of the window beyond beyond the bounds of the plotted data | `bool` | `True` | -| `allow_face_drag` | Allow dragging of the window using its face1 | `bool` | `False` | -| `axes_kwargs` | Optional kwargs to pass to the `Axes` setter2 | `dict[str, Any]` | `{"title": ...}` | -| `plot_kwargs` | Optional kwargs to pass to the plotting call3 | `dict[str, Any]` | `{}` | - -1. Currently not implemented -2. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. -3. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. +| Parameter | Description | Type | Default | +|-------------------|----------------------------------------------------------------------|----------------- |------------------| +| `x_data` | x data values to plot | See: Note 1 | Required | +| `y_data` | y data values to plot | See: Note 1 | Required | +| `position` | x-coordinate of the left edge of the window | See: Note 1 | Required | +| `window_width` | Starting width, along the x-axis, of the flexible window | See: Note 1 | Required | +| `snap_to_data` | Prevent dragging of the window beyond the bounds of the plotted data | `bool` | `True` | +| `allow_face_drag` | Allow dragging of the window using its face2 | `bool` | `False` | +| `axes_kwargs` | Optional kwargs to pass to the `Axes` setter3 | `dict[str, Any]` | `{"title": ...}` | +| `plot_kwargs` | Optional kwargs to pass to the plotting call4 | `dict[str, Any]` | `{}` | + +1. `int|float`, or any type supported by matplotlib's unit conversion, see: [Units](#units) +2. Currently not implemented +3. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. +4. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. diff --git a/matplotlib_window/base.py b/matplotlib_window/base.py index fa07cf0..ca77a3c 100644 --- a/matplotlib_window/base.py +++ b/matplotlib_window/base.py @@ -14,6 +14,8 @@ CALLBACK_T: t.TypeAlias = abc.Callable[[Event], t.Any] PLOT_OBJ_T: t.TypeAlias = Line2D | Rectangle NUMERIC_T: t.TypeAlias = float | int +# Any value supported by matplotlib's unit conversion (e.g. numeric, datetime, timedelta) +UNIT_T: t.TypeAlias = t.Any COMMON_OBJ_ID = "dragobj" # Label created object(s) URL for downstream event filtering @@ -219,6 +221,31 @@ def get_native_data(line: Line2D, axis: t.Literal["x", "y"]) -> npt.NDArray[np.f return np.asarray(data, dtype=float) +def to_native(ax: Axes, value: UNIT_T, axis: t.Literal["x", "y"]) -> float: + """ + Convert the provided value to the axes' native float units along the specified axis. + + This mirrors the conversion performed by matplotlib when plotting data, so `value` may be of any + type supported by matplotlib's unit conversion (e.g. datetimes or timedeltas). Numeric values + are returned as floats. + """ + ax_obj = ax.xaxis if axis == "x" else ax.yaxis + converted = ax_obj.convert_units(value) # type: ignore[no-untyped-call] + + # Types without a registered converter (e.g. timedelta64) are cast to float by matplotlib + return float(np.asarray(converted, dtype=np.float64)) + + +def to_native_span(ax: Axes, start: UNIT_T, span: UNIT_T, axis: t.Literal["x", "y"]) -> float: + """ + Convert the provided span to the axes' native float units along the specified axis. + + Spans may not be independently convertible (e.g. a timedelta on a datetime axis), so the span is + converted using its endpoints relative to `start`. + """ + return to_native(ax, (start + span), axis) - to_native(ax, start, axis) + + def limit_drag(plotted_data: npt.ArrayLike, query: float) -> float: """ Clamp the query value within the bounds of the provided dataset. @@ -244,12 +271,16 @@ class DragLine(_DraggableObject): All kwargs not explicitly named by `__init__` are passed through to the `Line2D` constructor, allowing the user to specify custom line formatting in a form expected by `Line2D`. + + `position` may be of any type supported by matplotlib's unit conversion (e.g. datetimes), but is + converted to the axes' native float units on instantiation; `location` is always provided in + native units. """ def __init__( self, ax: Axes, - position: NUMERIC_T, + position: UNIT_T, orientation: Orientation = Orientation.VERTICAL, snap_to: Line2D | None = None, redraw_callback: abc.Callable[[], None] | None = None, @@ -259,6 +290,12 @@ def __init__( self.orientation = orientation self.redraw_callback = redraw_callback + # Work in native units internally so positions are directly comparable to event coordinates + if orientation == Orientation.HORIZONTAL: + position = to_native(ax, position, "y") + else: + position = to_native(ax, position, "x") + line_pos = (position, position) # matplotlib expectes a coordinate pair if orientation == Orientation.HORIZONTAL: obj = Line2D(xdata=ax.get_xlim(), ydata=line_pos, color=color, **kwargs) @@ -401,14 +438,18 @@ class DragRect(_DraggableObject): All kwargs not explicitly named by `__init__` are passed through to the `Rectangle` constructor, allowing the user to specify custom line formatting in a form expected by `Rectangle`. + `position` and `width` may be of any type supported by matplotlib's unit conversion (e.g. a + datetime & timedelta), but are converted to the axes' native float units on instantiation; + `bounds` is always provided in native units. + NOTE: Motion is constrained to the x-axis only. """ def __init__( self, ax: Axes, - position: NUMERIC_T, - width: NUMERIC_T, + position: UNIT_T, + width: UNIT_T, snap_to: Line2D | None = None, redraw_callback: abc.Callable[[], None] | None = None, edgecolor: str | None = "limegreen", @@ -416,9 +457,14 @@ def __init__( alpha: NUMERIC_T = 0.4, **kwargs: t.Any, ) -> None: - if width <= 0: + # Work in native units internally so positions are directly comparable to event coordinates + native_width = to_native_span(ax, position, width, "x") + if native_width <= 0: raise ValueError(f"Width value must be greater than 0. Received: {width}") + position = to_native(ax, position, "x") + width = native_width + self.redraw_callback = redraw_callback # Rectangle patches are located from their bottom left corner; because we want to span the @@ -554,14 +600,18 @@ class FlexibleRect: location of the line has been changed. This callable is expected to take no arguments and has no return. + `position` and `width` may be of any type supported by matplotlib's unit conversion (e.g. a + datetime & timedelta), but are converted to the axes' native float units on instantiation; + `bounds` is always provided in native units. + NOTE: Motion is constrained to the x-axis only. """ def __init__( self, ax: Axes, - position: NUMERIC_T, - width: NUMERIC_T, + position: UNIT_T, + width: UNIT_T, snap_to: Line2D | None = None, redraw_callback: abc.Callable[[], None] | None = None, allow_face_drag: bool = False, @@ -569,9 +619,14 @@ def __init__( facecolor: str = "limegreen", alpha: NUMERIC_T = 0.4, ) -> None: - if width <= 0: + # Work in native units internally so the edge positions can be calculated directly + native_width = to_native_span(ax, position, width, "x") + if native_width <= 0: raise ValueError(f"Width value must be greater than 0. Received: {width}") + position = to_native(ax, position, "x") + width = native_width + self.parent_axes = ax if ax.figure is not None: self.parent_canvas = ax.figure.canvas diff --git a/matplotlib_window/window.py b/matplotlib_window/window.py index a20f81d..adae15c 100644 --- a/matplotlib_window/window.py +++ b/matplotlib_window/window.py @@ -3,7 +3,7 @@ import matplotlib.pyplot as plt -from matplotlib_window.base import DragRect, FlexibleRect, NUMERIC_T +from matplotlib_window.base import DragRect, FlexibleRect, NUMERIC_T, UNIT_T DEFAULT_AXES_KWARGS: dict[str, t.Any] = { "title": "Close window to return selected window bounds", @@ -13,10 +13,10 @@ def fixed_window( - x_data: abc.Sequence[NUMERIC_T], - y_data: abc.Sequence[NUMERIC_T], - position: NUMERIC_T, - window_width: NUMERIC_T, + x_data: abc.Sequence[UNIT_T], + y_data: abc.Sequence[UNIT_T], + position: UNIT_T, + window_width: UNIT_T, snap_to_data: bool = True, axes_kwargs: dict[str, t.Any] | None = None, plot_kwargs: dict[str, t.Any] | None = None, @@ -28,6 +28,11 @@ def fixed_window( `position` specifies the x-coordinate of the left edge of the window. + NOTE: xy data, `position`, and `window_width` may be of any type supported by matplotlib's unit + conversion (e.g. datetimes & timedeltas), but the returned edge locations are always in the + axes' native float units; for datetime data these may be converted back using + `matplotlib.dates.num2date`. + If `snap_to_data` is `True`, the window is prevented from being dragged beyond the bounds of the plotted data. @@ -56,10 +61,10 @@ def fixed_window( def flexible_window( - x_data: abc.Sequence[NUMERIC_T], - y_data: abc.Sequence[NUMERIC_T], - position: NUMERIC_T, - window_width: NUMERIC_T, + x_data: abc.Sequence[UNIT_T], + y_data: abc.Sequence[UNIT_T], + position: UNIT_T, + window_width: UNIT_T, snap_to_data: bool = True, allow_face_drag: bool = False, axes_kwargs: dict[str, t.Any] | None = None, @@ -72,6 +77,11 @@ def flexible_window( `position` specifies the x-coordinate of the left edge of the window. + NOTE: xy data, `position`, and `window_width` may be of any type supported by matplotlib's unit + conversion (e.g. datetimes & timedeltas), but the returned edge locations are always in the + axes' native float units; for datetime data these may be converted back using + `matplotlib.dates.num2date`. + If `snap_to_data` is `True`, the window is prevented from being dragged beyond the bounds of the plotted data. diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index 5d76776..6071e06 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -7,6 +7,8 @@ RectParams, get_native_data, limit_drag, + to_native, + to_native_span, transform_rect_params, ) from tests.conftest import PLOTOBJ_T @@ -61,6 +63,25 @@ def test_get_native_data_datetime(plotobj: PLOTOBJ_T) -> None: np.testing.assert_allclose(get_native_data(ls, "y"), truth_data) +def test_to_native_numeric(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + native = to_native(ax, 2, "x") + + assert isinstance(native, float) + assert native == pytest.approx(2) + assert to_native_span(ax, 2, 3, "x") == pytest.approx(3) + + +def test_to_native_datetime(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + start = np.datetime64("2026-01-02T00", "h") + span = np.timedelta64(6, "h") + + truth_start = mdates.date2num(start) # type: ignore[no-untyped-call] + assert to_native(ax, start, "y") == pytest.approx(truth_start) + assert to_native_span(ax, start, span, "y") == pytest.approx(0.25) # Fraction of a day + + def test_transform_rect(plotobj: PLOTOBJ_T) -> None: _, ax = plotobj ax.set(xlim=(-10, 10), ylim=(-10, 10)) diff --git a/tests/test_snapto.py b/tests/test_snapto.py index 37a9c15..58f0925 100644 --- a/tests/test_snapto.py +++ b/tests/test_snapto.py @@ -8,6 +8,7 @@ from matplotlib_window.base import ( DragLine, DragRect, + FlexibleRect, Orientation, _DraggableObject, get_native_data, @@ -305,3 +306,68 @@ def test_dragrect_snapto_data_types_out_of_bounds_raises(data: t.Any, plotobj: P with pytest.raises(ValueError, match="bounds"): _ = DragRect(ax, position=hi, width=span, snap_to=ls) + + +# Positions & widths may also be provided in the plotted data's own units (e.g. a datetime & +# timedelta), but are always reported back in the axes' native float units +@SNAP_TO_DATA_PARAMS +def test_vertical_dragline_data_type_position(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + native = get_native_data(ls, "x") + + dl = DragLine(ax, position=data[1], orientation=Orientation.VERTICAL, snap_to=ls) + assert isinstance(dl.location, float) + assert dl.location == pytest.approx(native[1]) + + +@SNAP_TO_DATA_PARAMS +def test_horizontal_dragline_data_type_position(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(range(5), data)[0] + native = get_native_data(ls, "y") + + dl = DragLine(ax, position=data[1], orientation=Orientation.HORIZONTAL, snap_to=ls) + assert isinstance(dl.location, float) + assert dl.location == pytest.approx(native[1]) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_data_type_position_width(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + native = get_native_data(ls, "x") + + dr = DragRect(ax, position=data[1], width=(data[2] - data[1]), snap_to=ls) + assert all(isinstance(edge, float) for edge in dr.bounds) + assert dr.bounds == pytest.approx((native[1], native[2])) + + +@SNAP_TO_DATA_PARAMS +def test_flexrect_data_type_position_width(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + native = get_native_data(ls, "x") + + fr = FlexibleRect(ax, position=data[1], width=(data[2] - data[1]), snap_to=ls) + assert all(isinstance(edge, float) for edge in fr.bounds) + assert fr.bounds == pytest.approx((native[1], native[2])) + assert fr.face.bounds == pytest.approx((native[1], native[2])) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_data_type_negative_width_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.plot(data, range(5)) + + with pytest.raises(ValueError, match="greater than"): + _ = DragRect(ax, position=data[2], width=(data[1] - data[2])) + + +@SNAP_TO_DATA_PARAMS +def test_flexrect_data_type_negative_width_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.plot(data, range(5)) + + with pytest.raises(ValueError, match="greater than"): + _ = FlexibleRect(ax, position=data[2], width=(data[1] - data[2])) From aae2977b4061410fb7ff82ac90de9eaf39616fb7 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 22 Oct 2026 22:39:42 -0400 Subject: [PATCH 8/8] Implement face drag for `FlexibleRect` Rearrange test suite --- README.md | 7 +- matplotlib_window/base.py | 73 +++++-- matplotlib_window/window.py | 3 +- tests/helpers.py | 46 +++++ tests/test_base_objs.py | 121 ------------ tests/test_dragline.py | 230 ++++++++++++++++++++++ tests/test_dragobj.py | 21 ++ tests/test_dragrect.py | 189 ++++++++++++++++++ tests/test_flexrect.py | 152 +++++++++++++++ tests/test_snapto.py | 373 ------------------------------------ 10 files changed, 696 insertions(+), 519 deletions(-) delete mode 100644 tests/test_base_objs.py create mode 100644 tests/test_dragline.py create mode 100644 tests/test_dragobj.py create mode 100644 tests/test_dragrect.py create mode 100644 tests/test_flexrect.py delete mode 100644 tests/test_snapto.py diff --git a/README.md b/README.md index 0d381c7..83071ef 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,10 @@ Plot the provided data & build a flexible-width window to select bounds of inter | `position` | x-coordinate of the left edge of the window | See: Note 1 | Required | | `window_width` | Starting width, along the x-axis, of the flexible window | See: Note 1 | Required | | `snap_to_data` | Prevent dragging of the window beyond the bounds of the plotted data | `bool` | `True` | -| `allow_face_drag` | Allow dragging of the window using its face2 | `bool` | `False` | +| `allow_face_drag` | Allow dragging of the window using its face | `bool` | `False` | | `axes_kwargs` | Optional kwargs to pass to the `Axes` setter3 | `dict[str, Any]` | `{"title": ...}` | | `plot_kwargs` | Optional kwargs to pass to the plotting call4 | `dict[str, Any]` | `{}` | 1. `int|float`, or any type supported by matplotlib's unit conversion, see: [Units](#units) -2. Currently not implemented -3. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. -4. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. +2. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. +3. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. diff --git a/matplotlib_window/base.py b/matplotlib_window/base.py index ca77a3c..630a875 100644 --- a/matplotlib_window/base.py +++ b/matplotlib_window/base.py @@ -401,6 +401,18 @@ def location(self) -> NUMERIC_T: return pos[0] # Should be a (location, location) tuple + def set_location(self, position: NUMERIC_T) -> None: + """ + Set the location of the `DragLine` along its relevant axis, in native axes units. + + NOTE: The canvas is not redrawn & `redraw_callback` is not called; the caller is responsible + for redrawing as needed. + """ + if self.orientation == Orientation.VERTICAL: + self.myobj.set_xdata((position, position)) + else: + self.myobj.set_ydata((position, position)) + class RectParams(t.NamedTuple): # noqa: D101 xy: COORD_T @@ -445,6 +457,10 @@ class DragRect(_DraggableObject): NOTE: Motion is constrained to the x-axis only. """ + # Left edge location when the current drag started, used for drag deltas so the object doesn't + # jump to the cursor + drag_start_x: float + def __init__( self, ax: Axes, @@ -481,12 +497,25 @@ def __init__( **kwargs, ) - self.oldxy = rect_params.xy # Used for drag deltas so the object doesn't jump to cursor self.register_plot_object(obj, ax) self.axes_limit_change = ax.callbacks.connect("ylim_changed", self.limit_change) self.snap_to = self.validate_snap_to(snap_to) + def on_click(self, event: Event) -> t.Any: + """ + Mouse click callback. + + In addition to the common click handling, cache the location of the left edge if a drag is + started. This is cached at the start of every drag, rather than at the end of the previous + one, so it remains correct if the rectangle is moved by other means between drags (e.g. by + a `FlexibleRect`'s edges). + """ + super().on_click(event) + + if self.clicked: + self.drag_start_x = self.myobj.get_x() + def on_motion(self, event: Event) -> t.Any: """ On motion callback. @@ -510,7 +539,7 @@ def on_motion(self, event: Event) -> t.Any: # of the bottom left corner when the object was clicked on. Because we can click anywhere on # the patch to begin motion, the patch will jump to the mouse if just using the location of # the MouseEvent. - old_x, _ = self.oldxy + old_x = self.drag_start_x dx = event.xdata - self.click_x if self.snap_to: if dx < 0: @@ -530,20 +559,6 @@ def on_motion(self, event: Event) -> t.Any: self._redraw() - def on_release(self, event: Event) -> t.Any: - """ - Mouse button release callback. - - When the mouse button is released, cache the new corner location & disconnect the callbacks - connected by `self.on_click`. - """ - if not isinstance(event, MouseEvent): - # Type narrowing, matplotlib dispatches a MouseEvent here so shouldn't ever trip this - return - - self.oldxy = self.myobj.get_xy() - self.disconnect() - def limit_change(self, ax: Axes) -> None: """ Axes limit change callback. @@ -604,6 +619,8 @@ class FlexibleRect: datetime & timedelta), but are converted to the axes' native float units on instantiation; `bounds` is always provided in native units. + If `allow_face_drag` is `True`, the entire rectangle may also be dragged using its face. + NOTE: Motion is constrained to the x-axis only. """ @@ -637,8 +654,16 @@ def __init__( # snap_to validation handled by DragRect & DragLine # Create edges after face so they're topmost & take click priority + # If the face is draggable, it snaps as a whole & drags the edges along with it self.face = DragRect( - ax=ax, position=position, width=width, facecolor=facecolor, edgecolor=None, alpha=alpha + ax=ax, + position=position, + width=width, + snap_to=(snap_to if allow_face_drag else None), + redraw_callback=(self._move_edges if allow_face_drag else None), + facecolor=facecolor, + edgecolor=None, + alpha=alpha, ) line_p = partial( @@ -652,8 +677,18 @@ def __init__( if not allow_face_drag: self.face._disable_click() - else: - raise NotImplementedError + + def _move_edges(self) -> None: + """Move the edges to the bounds of the face, called when the face has been dragged.""" + left, right = self.face.bounds + + # Edges may have been dragged past each other, so match them to the face by location + left_edge, right_edge = sorted(self.edges, key=lambda edge: edge.location) + left_edge.set_location(left) + right_edge.set_location(right) + + if self.redraw_callback is not None: + self.redraw_callback() def _respan_face(self) -> None: """Update face dimensions to span the entirety of the y-axes between the two edges.""" diff --git a/matplotlib_window/window.py b/matplotlib_window/window.py index adae15c..51541ea 100644 --- a/matplotlib_window/window.py +++ b/matplotlib_window/window.py @@ -85,8 +85,7 @@ def flexible_window( If `snap_to_data` is `True`, the window is prevented from being dragged beyond the bounds of the plotted data. - If `allow_face_drag` is `True`, the entire window may be dragged using its face. NOTE: This is - currently not implemented. + If `allow_face_drag` is `True`, the entire window may also be dragged using its face. `axes_kwargs` and `plot_kwargs` may be optionally specified to control the appearance of the resulting `Axes` and `Line2D` objects, respectively, and are passed straight through to their diff --git a/tests/helpers.py b/tests/helpers.py index bc399df..81126a8 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,5 +1,13 @@ +import datetime as dt +import typing as t + +import numpy as np +import pytest from matplotlib.axes import Axes from matplotlib.backend_bases import FigureCanvasBase, MouseButton, MouseEvent +from matplotlib.lines import Line2D + +from matplotlib_window.base import get_native_data def has_callback_to( @@ -39,3 +47,41 @@ def drag(ax: Axes, start: tuple[float, float], end: tuple[float, float]) -> None fire_event(ax, "button_press_event", *start) fire_event(ax, "motion_notify_event", *end) fire_event(ax, "button_release_event", *end) + + +EMPTY_LINE = Line2D(xdata=np.array([]), ydata=np.array([])) + + +ALL_NAN_LINE = Line2D(xdata=np.full(3, np.nan), ydata=np.full(3, np.nan)) + + +# NaN gaps, including at the ends, are a common way to represent missing data +GAPPED_X = np.array([np.nan, 0, 1, np.nan, 3, 4, np.nan]) +GAPPED_Y = np.array([np.nan, 10, 11, np.nan, 13, 14, np.nan]) + + +# Make sure x & y data are different so we're not masking issues +DUMMY_LINE = Line2D(xdata=np.array([0, 1, 2]), ydata=np.array([10, 11, 12])) + + +# snap_to data should work for any type supported by matplotlib's unit conversion, so the tests +# below are run against a range of data types rather than special-casing each one +SNAP_TO_DATA: dict[str, t.Any] = { + "numeric": np.arange(5), + "datetime64[D]": np.arange("2026-01-01", "2026-01-06", dtype="datetime64[D]"), + "datetime64[h]": np.arange("2026-01-01T00", "2026-01-01T05", dtype="datetime64[h]"), + "datetime64[ns]": np.arange(5).astype("timedelta64[s]") + np.datetime64("2026-01-01", "ns"), + "timedelta64[D]": np.arange(1, 6).astype("timedelta64[D]"), # See: #8 + "timedelta64[s]": np.arange(1, 6).astype("timedelta64[s]"), + "datetime": [dt.datetime(2026, 1, day) for day in range(1, 6)], + "date": [dt.date(2026, 1, day) for day in range(1, 6)], +} +SNAP_TO_DATA_PARAMS = pytest.mark.parametrize( + "data", SNAP_TO_DATA.values(), ids=SNAP_TO_DATA.keys() +) + + +def native_extents(line: Line2D, axis: t.Literal["x", "y"]) -> tuple[float, float, float]: + """Return the min, max, & span of the line's data along `axis`, in native axes units.""" + native = get_native_data(line, axis) + return native.min(), native.max(), (native.max() - native.min()) diff --git a/tests/test_base_objs.py b/tests/test_base_objs.py deleted file mode 100644 index 88e4699..0000000 --- a/tests/test_base_objs.py +++ /dev/null @@ -1,121 +0,0 @@ -import pytest -from pytest_mock import MockerFixture - -from matplotlib_window.base import DragLine, DragRect, FlexibleRect, Orientation -from tests.conftest import PLOTOBJ_T -from tests.helpers import has_callback_to - - -def test_dragline_invalid_orientation_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - with pytest.raises(ValueError, match="orientation"): - _ = DragLine(ax=ax, position=0, orientation="beans") # type: ignore[arg-type] - - -DRAGLINE_LOCATION_TEST_CASES = ( - (Orientation.VERTICAL, 1), - (Orientation.VERTICAL, 1), -) - - -@pytest.mark.parametrize(("orientation", "truth_location"), DRAGLINE_LOCATION_TEST_CASES) -def test_dragline_get_location( - orientation: Orientation, truth_location: int, plotobj: PLOTOBJ_T -) -> None: - _, ax = plotobj - dl = DragLine(ax=ax, position=1, orientation=orientation) - assert dl.location == truth_location - - -def test_dragrect_invalid_width_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - with pytest.raises(ValueError, match="greater than"): - _ = DragRect(ax=ax, position=0, width=0) - - -def test_dragrect_get_bounds(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0, width=1) - assert dr.bounds == (0, 1) - - -def test_dragrect_limit_change_respans(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0.2, width=0.3) - - ax.set_ylim(5, 15) - - assert dr.myobj.get_xy() == pytest.approx((0.2, 5)) - assert dr.myobj.get_height() == pytest.approx(10) - - -def test_dragrect_click_disable(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0, width=1) - - parent_canvas = dr.parent_canvas - assert has_callback_to(parent_canvas, "DragRect") - - dr._disable_click() - assert dr.click_press == -1 - assert not has_callback_to(parent_canvas, "DragRect") - - -def test_flexrect_invalid_width_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - with pytest.raises(ValueError, match="greater than"): - _ = FlexibleRect(ax=ax, position=0, width=0) - - -def test_flexrect_get_bounds(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = FlexibleRect(ax=ax, position=0, width=1) - assert dr.bounds == (0, 1) - - -def test_flexrect_limit_change_respans(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - fr = FlexibleRect(ax=ax, position=0.2, width=0.3) - - ax.set_ylim(5, 15) - - assert fr.face.myobj.get_xy() == pytest.approx((0.2, 5)) - assert fr.face.myobj.get_height() == pytest.approx(10) - - -def test_flexrect_disables_click(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = FlexibleRect(ax=ax, position=0, width=1, allow_face_drag=False) - - parent_canvas = dr.face.parent_canvas - assert not has_callback_to(parent_canvas, "DragRect") - - -def test_dragline_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: - _, ax = plotobj - callback = mocker.Mock() - dl = DragLine(ax=ax, position=0, redraw_callback=callback) - - ax.set_ylim(0, 10) # Limit change triggers a redraw - callback.assert_called() - assert dl.redraw_callback is callback - - -def test_dragrect_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: - _, ax = plotobj - callback = mocker.Mock() - dr = DragRect(ax=ax, position=0, width=1, redraw_callback=callback) - - ax.set_ylim(0, 10) # Limit change triggers a redraw - callback.assert_called() - assert dr.redraw_callback is callback - - -def test_flexrect_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: - _, ax = plotobj - callback = mocker.Mock() - fr = FlexibleRect(ax=ax, position=0, width=1, redraw_callback=callback) - - ax.set_ylim(0, 10) # Limit change triggers a redraw - callback.assert_called() - assert fr.redraw_callback is callback diff --git a/tests/test_dragline.py b/tests/test_dragline.py new file mode 100644 index 0000000..58abca2 --- /dev/null +++ b/tests/test_dragline.py @@ -0,0 +1,230 @@ +import typing as t + +import pytest +from pytest_mock import MockerFixture + +from matplotlib_window.base import DragLine, Orientation, get_native_data +from tests.conftest import PLOTOBJ_T +from tests.helpers import ( + ALL_NAN_LINE, + DUMMY_LINE, + EMPTY_LINE, + GAPPED_X, + GAPPED_Y, + SNAP_TO_DATA_PARAMS, + drag, + native_extents, +) + + +def test_dragline_invalid_orientation_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + with pytest.raises(ValueError, match="orientation"): + _ = DragLine(ax=ax, position=0, orientation="beans") # type: ignore[arg-type] + + +DRAGLINE_LOCATION_TEST_CASES = ( + (Orientation.VERTICAL, 1), + (Orientation.VERTICAL, 1), +) + + +@pytest.mark.parametrize(("orientation", "truth_location"), DRAGLINE_LOCATION_TEST_CASES) +def test_dragline_get_location( + orientation: Orientation, truth_location: int, plotobj: PLOTOBJ_T +) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=1, orientation=orientation) + assert dl.location == truth_location + + +def test_dragline_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + dl = DragLine(ax=ax, position=0, redraw_callback=callback) + + ax.set_ylim(0, 10) # Limit change triggers a redraw + callback.assert_called() + assert dl.redraw_callback is callback + + +def test_dragline_set_location_vertical(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0.2, orientation=Orientation.VERTICAL) + + dl.set_location(0.7) + assert dl.location == pytest.approx(0.7) + assert get_native_data(dl.myobj, "x") == pytest.approx([0.7, 0.7]) + + +def test_dragline_set_location_horizontal(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0.2, orientation=Orientation.HORIZONTAL) + + dl.set_location(0.7) + assert dl.location == pytest.approx(0.7) + assert get_native_data(dl.myobj, "y") == pytest.approx([0.7, 0.7]) + + +def test_dragline_snapto_none_passthrough(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0) + assert dl.validate_snap_to(None) is None + + +def test_dragline_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0) + with pytest.raises(ValueError, match="empty"): + dl.validate_snap_to(EMPTY_LINE) + + +def test_dragline_snapto_all_nan_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0) + with pytest.raises(ValueError, match="finite"): + dl.validate_snap_to(ALL_NAN_LINE) + + +def test_vertical_dragline_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + ax.set_xlim(-4, 8) # Leave room to drag beyond the data inside the axes + dl = DragLine(ax, position=2, orientation=Orientation.VERTICAL, snap_to=ls) + + drag(ax, (2, 12), (6, 12)) + assert dl.location == pytest.approx(4) + + drag(ax, (4, 12), (-2, 12)) + assert dl.location == pytest.approx(0) + + +def test_horizontal_dragline_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + ax.set_ylim(6, 18) # Leave room to drag beyond the data inside the axes + dl = DragLine(ax, position=12, orientation=Orientation.HORIZONTAL, snap_to=ls) + + drag(ax, (2, 12), (2, 16)) + assert dl.location == pytest.approx(14) + + drag(ax, (2, 14), (2, 8)) + assert dl.location == pytest.approx(10) + + +def test_dragline_snapto_nan_gaps_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + + with pytest.raises(ValueError, match="bounds"): + _ = DragLine(ax, position=5, snap_to=ls) + + +def test_vertical_dragline_snapto_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=3, orientation=Orientation.VERTICAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) + + +def test_horizontal_dragline_snapto_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=3, orientation=Orientation.HORIZONTAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) + + +def test_vertical_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + _ = DragLine(ax, position=1, orientation=Orientation.VERTICAL, snap_to=DUMMY_LINE) + + +def test_horizontal_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + _ = DragLine(ax, position=11, orientation=Orientation.HORIZONTAL, snap_to=DUMMY_LINE) + + +def test_vertical_dragline_snapto_oob_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=101, orientation=Orientation.VERTICAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) + + +def test_horizontal_dragline_snapto_oob_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=1, orientation=Orientation.HORIZONTAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) + + +@SNAP_TO_DATA_PARAMS +def test_vertical_dragline_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + lo, hi, span = native_extents(ls, "x") + ax.set_xlim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes + + start = get_native_data(ls, "x")[1] + dl = DragLine(ax, position=start, orientation=Orientation.VERTICAL, snap_to=ls) + + drag(ax, (start, 2), (hi + span / 2, 2)) + assert dl.location == pytest.approx(hi, abs=span * 1e-6) + + drag(ax, (hi, 2), (lo - span / 2, 2)) + assert dl.location == pytest.approx(lo, abs=span * 1e-6) + + +@SNAP_TO_DATA_PARAMS +def test_horizontal_dragline_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(range(5), data)[0] + lo, hi, span = native_extents(ls, "y") + ax.set_ylim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes + + start = get_native_data(ls, "y")[1] + dl = DragLine(ax, position=start, orientation=Orientation.HORIZONTAL, snap_to=ls) + + drag(ax, (2, start), (2, hi + span / 2)) + assert dl.location == pytest.approx(hi, abs=span * 1e-6) + + drag(ax, (2, hi), (2, lo - span / 2)) + assert dl.location == pytest.approx(lo, abs=span * 1e-6) + + +@SNAP_TO_DATA_PARAMS +def test_dragline_snapto_data_types_out_of_bounds_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + _, hi, span = native_extents(ls, "x") + + with pytest.raises(ValueError, match="bounds"): + _ = DragLine(ax, position=(hi + span), snap_to=ls) + + +# Positions & widths may also be provided in the plotted data's own units (e.g. a datetime & +# timedelta), but are always reported back in the axes' native float units +@SNAP_TO_DATA_PARAMS +def test_vertical_dragline_data_type_position(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + native = get_native_data(ls, "x") + + dl = DragLine(ax, position=data[1], orientation=Orientation.VERTICAL, snap_to=ls) + assert isinstance(dl.location, float) + assert dl.location == pytest.approx(native[1]) + + +@SNAP_TO_DATA_PARAMS +def test_horizontal_dragline_data_type_position(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(range(5), data)[0] + native = get_native_data(ls, "y") + + dl = DragLine(ax, position=data[1], orientation=Orientation.HORIZONTAL, snap_to=ls) + assert isinstance(dl.location, float) + assert dl.location == pytest.approx(native[1]) diff --git a/tests/test_dragobj.py b/tests/test_dragobj.py new file mode 100644 index 0000000..e5f1ab3 --- /dev/null +++ b/tests/test_dragobj.py @@ -0,0 +1,21 @@ +import pytest + +from matplotlib_window.base import _DraggableObject +from tests.helpers import ALL_NAN_LINE, EMPTY_LINE + + +def test_dragobj_snapto_none_passthrough() -> None: + do = _DraggableObject() + assert do.validate_snap_to(None) is None + + +def test_dragobj_snapto_empty_data_raises() -> None: + do = _DraggableObject() + with pytest.raises(ValueError, match="empty"): + do.validate_snap_to(EMPTY_LINE) + + +def test_dragobj_snapto_all_nan_data_raises() -> None: + do = _DraggableObject() + with pytest.raises(ValueError, match="finite"): + do.validate_snap_to(ALL_NAN_LINE) diff --git a/tests/test_dragrect.py b/tests/test_dragrect.py new file mode 100644 index 0000000..c8e5648 --- /dev/null +++ b/tests/test_dragrect.py @@ -0,0 +1,189 @@ +import typing as t + +import pytest +from pytest_mock import MockerFixture + +from matplotlib_window.base import DragRect, get_native_data +from tests.conftest import PLOTOBJ_T +from tests.helpers import ( + ALL_NAN_LINE, + DUMMY_LINE, + EMPTY_LINE, + GAPPED_X, + GAPPED_Y, + SNAP_TO_DATA_PARAMS, + drag, + has_callback_to, + native_extents, +) + + +def test_dragrect_invalid_width_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + with pytest.raises(ValueError, match="greater than"): + _ = DragRect(ax=ax, position=0, width=0) + + +def test_dragrect_get_bounds(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + assert dr.bounds == (0, 1) + + +def test_dragrect_limit_change_respans(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0.2, width=0.3) + + ax.set_ylim(5, 15) + + assert dr.myobj.get_xy() == pytest.approx((0.2, 5)) + assert dr.myobj.get_height() == pytest.approx(10) + + +def test_dragrect_click_disable(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + + parent_canvas = dr.parent_canvas + assert has_callback_to(parent_canvas, "DragRect") + + dr._disable_click() + assert dr.click_press == -1 + assert not has_callback_to(parent_canvas, "DragRect") + + +def test_dragrect_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + dr = DragRect(ax=ax, position=0, width=1, redraw_callback=callback) + + ax.set_ylim(0, 10) # Limit change triggers a redraw + callback.assert_called() + assert dr.redraw_callback is callback + + +def test_dragrect_drag_after_external_move(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0.2, width=0.3) + + # Drag deltas should be relative to the rectangle's location when the drag starts, even if it + # was moved by something other than a previous drag + dr.myobj.set_x(0.4) + drag(ax, (0.55, 0.5), (0.65, 0.5)) + assert dr.bounds == pytest.approx((0.5, 0.8)) + + +def test_dragrect_snapto_none_passthrough(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + assert dr.validate_snap_to(None) is None + + +def test_dragrect_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + with pytest.raises(ValueError, match="empty"): + dr.validate_snap_to(EMPTY_LINE) + + +def test_dragrect_snapto_all_nan_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + with pytest.raises(ValueError, match="finite"): + dr.validate_snap_to(ALL_NAN_LINE) + + +def test_dragrect_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + ax.set_xlim(-4, 8) # Leave room to drag beyond the data inside the axes + dr = DragRect(ax, position=1, width=1, snap_to=ls) + + drag(ax, (1.5, 12), (5.5, 12)) + assert dr.bounds == pytest.approx((3, 4)) + + drag(ax, (3.5, 12), (-0.5, 12)) + assert dr.bounds == pytest.approx((0, 1)) + + +def test_dragrect_snapto_nan_gaps_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + + with pytest.raises(ValueError, match="bounds"): + _ = DragRect(ax, position=3, width=2, snap_to=ls) + + +RECT_BOUNDS_TEST_CASES = ( + (-1, 1), # Left edge out + (0, 3), # Right edge out + (-1, 4), # Both edges out +) + + +@pytest.mark.parametrize(("position", "width"), RECT_BOUNDS_TEST_CASES) +def test_dragrect_snapto_out_of_bounds_raises( + position: int, width: int, plotobj: PLOTOBJ_T +) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=position, width=width) + + with pytest.raises(ValueError, match="bounds"): + dr.validate_snap_to(DUMMY_LINE) + + +def test_dragrect_valid_snapto(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + _ = DragRect(ax=ax, position=0, width=1, snap_to=DUMMY_LINE) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + lo, hi, span = native_extents(ls, "x") + ax.set_xlim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes + + native = get_native_data(ls, "x") + width = native[2] - native[1] + dr = DragRect(ax, position=native[1], width=width, snap_to=ls) + + # Drag from the middle of the rectangle so the click lands on the patch; dragging by the full + # data span pushes the leading edge beyond the data while keeping the cursor inside the axes + mid = native[1] + width / 2 + drag(ax, (mid, 2), (mid + span, 2)) + assert dr.bounds == pytest.approx((hi - width, hi), abs=span * 1e-6) + + mid = hi - width / 2 + drag(ax, (mid, 2), (mid - span, 2)) + assert dr.bounds == pytest.approx((lo, lo + width), abs=span * 1e-6) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_snapto_data_types_out_of_bounds_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + _, hi, span = native_extents(ls, "x") + + with pytest.raises(ValueError, match="bounds"): + _ = DragRect(ax, position=hi, width=span, snap_to=ls) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_data_type_position_width(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + native = get_native_data(ls, "x") + + dr = DragRect(ax, position=data[1], width=(data[2] - data[1]), snap_to=ls) + assert all(isinstance(edge, float) for edge in dr.bounds) + assert dr.bounds == pytest.approx((native[1], native[2])) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_data_type_negative_width_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.plot(data, range(5)) + + with pytest.raises(ValueError, match="greater than"): + _ = DragRect(ax, position=data[2], width=(data[1] - data[2])) diff --git a/tests/test_flexrect.py b/tests/test_flexrect.py new file mode 100644 index 0000000..d1765c9 --- /dev/null +++ b/tests/test_flexrect.py @@ -0,0 +1,152 @@ +import typing as t + +import numpy as np +import pytest +from matplotlib.lines import Line2D +from pytest_mock import MockerFixture + +from matplotlib_window.base import FlexibleRect, get_native_data +from tests.conftest import PLOTOBJ_T +from tests.helpers import SNAP_TO_DATA_PARAMS, drag, has_callback_to + + +def test_flexrect_invalid_width_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + with pytest.raises(ValueError, match="greater than"): + _ = FlexibleRect(ax=ax, position=0, width=0) + + +def test_flexrect_get_bounds(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = FlexibleRect(ax=ax, position=0, width=1) + assert dr.bounds == (0, 1) + + +def test_flexrect_limit_change_respans(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3) + + ax.set_ylim(5, 15) + + assert fr.face.myobj.get_xy() == pytest.approx((0.2, 5)) + assert fr.face.myobj.get_height() == pytest.approx(10) + + +def test_flexrect_disables_click(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = FlexibleRect(ax=ax, position=0, width=1, allow_face_drag=False) + + parent_canvas = dr.face.parent_canvas + assert not has_callback_to(parent_canvas, "DragRect") + + +def test_flexrect_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + fr = FlexibleRect(ax=ax, position=0, width=1, redraw_callback=callback) + + ax.set_ylim(0, 10) # Limit change triggers a redraw + callback.assert_called() + assert fr.redraw_callback is callback + + +def test_flexrect_face_drag_disabled_by_default(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3) + + drag(ax, (0.35, 0.5), (0.45, 0.5)) + assert fr.bounds == pytest.approx((0.2, 0.5)) + assert fr.face.bounds == pytest.approx((0.2, 0.5)) + + +def test_flexrect_face_drag_moves_edges(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3, allow_face_drag=True) + + drag(ax, (0.35, 0.5), (0.45, 0.5)) + assert fr.bounds == pytest.approx((0.3, 0.6)) + assert fr.face.bounds == pytest.approx((0.3, 0.6)) + + +def test_flexrect_face_drag_after_edge_drag(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3, allow_face_drag=True) + + # Widen using the right edge, then drag the face; the face should retain the new width & not + # jump back to its pre-resize location + drag(ax, (0.5, 0.5), (0.7, 0.5)) + assert fr.bounds == pytest.approx((0.2, 0.7)) + + drag(ax, (0.45, 0.5), (0.55, 0.5)) + assert fr.bounds == pytest.approx((0.3, 0.8)) + assert fr.face.bounds == pytest.approx((0.3, 0.8)) + + +def test_flexrect_face_drag_crossed_edges(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3, allow_face_drag=True) + + # Drag the left edge past the right edge, then drag the face + drag(ax, (0.2, 0.5), (0.7, 0.5)) + assert fr.bounds == pytest.approx((0.5, 0.7)) + + drag(ax, (0.6, 0.5), (0.5, 0.5)) + assert fr.bounds == pytest.approx((0.4, 0.6)) + assert fr.face.bounds == pytest.approx((0.4, 0.6)) + + +FACE_DRAG_SNAP_TO = Line2D(xdata=np.array([0, 0.5, 1]), ydata=np.array([10, 11, 10])) + + +def test_flexrect_face_drag_snapto_clamps_right(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.set_xlim(-1, 2) # Leave room to drag beyond the snap_to data while still inside the axes + fr = FlexibleRect( + ax=ax, position=0.2, width=0.3, snap_to=FACE_DRAG_SNAP_TO, allow_face_drag=True + ) + + drag(ax, (0.35, 0.5), (1.35, 0.5)) + assert fr.bounds == pytest.approx((0.7, 1.0)) + assert fr.face.bounds == pytest.approx((0.7, 1.0)) + + +def test_flexrect_face_drag_snapto_clamps_left(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.set_xlim(-1, 2) # Leave room to drag beyond the snap_to data while still inside the axes + fr = FlexibleRect( + ax=ax, position=0.2, width=0.3, snap_to=FACE_DRAG_SNAP_TO, allow_face_drag=True + ) + + drag(ax, (0.35, 0.5), (-0.65, 0.5)) + assert fr.bounds == pytest.approx((0.0, 0.3)) + assert fr.face.bounds == pytest.approx((0.0, 0.3)) + + +def test_flexrect_face_drag_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + _ = FlexibleRect(ax=ax, position=0.2, width=0.3, redraw_callback=callback, allow_face_drag=True) + + drag(ax, (0.35, 0.5), (0.45, 0.5)) + callback.assert_called() + + +@SNAP_TO_DATA_PARAMS +def test_flexrect_data_type_position_width(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + native = get_native_data(ls, "x") + + fr = FlexibleRect(ax, position=data[1], width=(data[2] - data[1]), snap_to=ls) + assert all(isinstance(edge, float) for edge in fr.bounds) + assert fr.bounds == pytest.approx((native[1], native[2])) + assert fr.face.bounds == pytest.approx((native[1], native[2])) + + +@SNAP_TO_DATA_PARAMS +def test_flexrect_data_type_negative_width_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.plot(data, range(5)) + + with pytest.raises(ValueError, match="greater than"): + _ = FlexibleRect(ax, position=data[2], width=(data[1] - data[2])) diff --git a/tests/test_snapto.py b/tests/test_snapto.py deleted file mode 100644 index 58f0925..0000000 --- a/tests/test_snapto.py +++ /dev/null @@ -1,373 +0,0 @@ -import datetime as dt -import typing as t - -import numpy as np -import pytest -from matplotlib.lines import Line2D - -from matplotlib_window.base import ( - DragLine, - DragRect, - FlexibleRect, - Orientation, - _DraggableObject, - get_native_data, -) -from tests.conftest import PLOTOBJ_T -from tests.helpers import drag - - -def test_dragobj_snapto_none_passthrough() -> None: - do = _DraggableObject() - assert do.validate_snap_to(None) is None - - -def test_dragline_snapto_none_passthrough(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax=ax, position=0) - assert dl.validate_snap_to(None) is None - - -def test_dragrect_snapto_none_passthrough(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0, width=1) - assert dr.validate_snap_to(None) is None - - -EMPTY_LINE = Line2D(xdata=np.array([]), ydata=np.array([])) - - -def test_dragobj_snapto_empty_data_raises() -> None: - do = _DraggableObject() - with pytest.raises(ValueError, match="empty"): - do.validate_snap_to(EMPTY_LINE) - - -def test_dragline_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax=ax, position=0) - with pytest.raises(ValueError, match="empty"): - dl.validate_snap_to(EMPTY_LINE) - - -def test_dragrect_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0, width=1) - with pytest.raises(ValueError, match="empty"): - dr.validate_snap_to(EMPTY_LINE) - - -ALL_NAN_LINE = Line2D(xdata=np.full(3, np.nan), ydata=np.full(3, np.nan)) - - -def test_dragobj_snapto_all_nan_data_raises() -> None: - do = _DraggableObject() - with pytest.raises(ValueError, match="finite"): - do.validate_snap_to(ALL_NAN_LINE) - - -def test_dragline_snapto_all_nan_data_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax=ax, position=0) - with pytest.raises(ValueError, match="finite"): - dl.validate_snap_to(ALL_NAN_LINE) - - -def test_dragrect_snapto_all_nan_data_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0, width=1) - with pytest.raises(ValueError, match="finite"): - dr.validate_snap_to(ALL_NAN_LINE) - - -# NaN gaps, including at the ends, are a common way to represent missing data -GAPPED_X = np.array([np.nan, 0, 1, np.nan, 3, 4, np.nan]) -GAPPED_Y = np.array([np.nan, 10, 11, np.nan, 13, 14, np.nan]) - - -def test_vertical_dragline_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(GAPPED_X, GAPPED_Y)[0] - ax.set_xlim(-4, 8) # Leave room to drag beyond the data inside the axes - dl = DragLine(ax, position=2, orientation=Orientation.VERTICAL, snap_to=ls) - - drag(ax, (2, 12), (6, 12)) - assert dl.location == pytest.approx(4) - - drag(ax, (4, 12), (-2, 12)) - assert dl.location == pytest.approx(0) - - -def test_horizontal_dragline_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(GAPPED_X, GAPPED_Y)[0] - ax.set_ylim(6, 18) # Leave room to drag beyond the data inside the axes - dl = DragLine(ax, position=12, orientation=Orientation.HORIZONTAL, snap_to=ls) - - drag(ax, (2, 12), (2, 16)) - assert dl.location == pytest.approx(14) - - drag(ax, (2, 14), (2, 8)) - assert dl.location == pytest.approx(10) - - -def test_dragrect_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(GAPPED_X, GAPPED_Y)[0] - ax.set_xlim(-4, 8) # Leave room to drag beyond the data inside the axes - dr = DragRect(ax, position=1, width=1, snap_to=ls) - - drag(ax, (1.5, 12), (5.5, 12)) - assert dr.bounds == pytest.approx((3, 4)) - - drag(ax, (3.5, 12), (-0.5, 12)) - assert dr.bounds == pytest.approx((0, 1)) - - -def test_dragline_snapto_nan_gaps_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(GAPPED_X, GAPPED_Y)[0] - - with pytest.raises(ValueError, match="bounds"): - _ = DragLine(ax, position=5, snap_to=ls) - - -def test_dragrect_snapto_nan_gaps_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(GAPPED_X, GAPPED_Y)[0] - - with pytest.raises(ValueError, match="bounds"): - _ = DragRect(ax, position=3, width=2, snap_to=ls) - - -# Make sure x & y data are different so we're not masking issues -DUMMY_LINE = Line2D(xdata=np.array([0, 1, 2]), ydata=np.array([10, 11, 12])) - - -def test_vertical_dragline_snapto_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax, position=3, orientation=Orientation.VERTICAL) - - with pytest.raises(ValueError, match="bounds"): - dl.validate_snap_to(DUMMY_LINE) - - -def test_horizontal_dragline_snapto_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax, position=3, orientation=Orientation.HORIZONTAL) - - with pytest.raises(ValueError, match="bounds"): - dl.validate_snap_to(DUMMY_LINE) - - -def test_vertical_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - _ = DragLine(ax, position=1, orientation=Orientation.VERTICAL, snap_to=DUMMY_LINE) - - -def test_horizontal_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - _ = DragLine(ax, position=11, orientation=Orientation.HORIZONTAL, snap_to=DUMMY_LINE) - - -def test_vertical_dragline_snapto_oob_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax, position=101, orientation=Orientation.VERTICAL) - - with pytest.raises(ValueError, match="bounds"): - dl.validate_snap_to(DUMMY_LINE) - - -def test_horizontal_dragline_snapto_oob_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax, position=1, orientation=Orientation.HORIZONTAL) - - with pytest.raises(ValueError, match="bounds"): - dl.validate_snap_to(DUMMY_LINE) - - -RECT_BOUNDS_TEST_CASES = ( - (-1, 1), # Left edge out - (0, 3), # Right edge out - (-1, 4), # Both edges out -) - - -@pytest.mark.parametrize(("position", "width"), RECT_BOUNDS_TEST_CASES) -def test_dragrect_snapto_out_of_bounds_raises( - position: int, width: int, plotobj: PLOTOBJ_T -) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=position, width=width) - - with pytest.raises(ValueError, match="bounds"): - dr.validate_snap_to(DUMMY_LINE) - - -def test_dragrect_valid_snapto(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - _ = DragRect(ax=ax, position=0, width=1, snap_to=DUMMY_LINE) - - -# snap_to data should work for any type supported by matplotlib's unit conversion, so the tests -# below are run against a range of data types rather than special-casing each one -SNAP_TO_DATA: dict[str, t.Any] = { - "numeric": np.arange(5), - "datetime64[D]": np.arange("2026-01-01", "2026-01-06", dtype="datetime64[D]"), - "datetime64[h]": np.arange("2026-01-01T00", "2026-01-01T05", dtype="datetime64[h]"), - "datetime64[ns]": np.arange(5).astype("timedelta64[s]") + np.datetime64("2026-01-01", "ns"), - "timedelta64[D]": np.arange(1, 6).astype("timedelta64[D]"), # See: #8 - "timedelta64[s]": np.arange(1, 6).astype("timedelta64[s]"), - "datetime": [dt.datetime(2026, 1, day) for day in range(1, 6)], - "date": [dt.date(2026, 1, day) for day in range(1, 6)], -} -SNAP_TO_DATA_PARAMS = pytest.mark.parametrize( - "data", SNAP_TO_DATA.values(), ids=SNAP_TO_DATA.keys() -) - - -def _native_extents(line: Line2D, axis: t.Literal["x", "y"]) -> tuple[float, float, float]: - """Return the min, max, & span of the line's data along `axis`, in native axes units.""" - native = get_native_data(line, axis) - return native.min(), native.max(), (native.max() - native.min()) - - -@SNAP_TO_DATA_PARAMS -def test_vertical_dragline_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(data, range(5))[0] - lo, hi, span = _native_extents(ls, "x") - ax.set_xlim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes - - start = get_native_data(ls, "x")[1] - dl = DragLine(ax, position=start, orientation=Orientation.VERTICAL, snap_to=ls) - - drag(ax, (start, 2), (hi + span / 2, 2)) - assert dl.location == pytest.approx(hi, abs=span * 1e-6) - - drag(ax, (hi, 2), (lo - span / 2, 2)) - assert dl.location == pytest.approx(lo, abs=span * 1e-6) - - -@SNAP_TO_DATA_PARAMS -def test_horizontal_dragline_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(range(5), data)[0] - lo, hi, span = _native_extents(ls, "y") - ax.set_ylim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes - - start = get_native_data(ls, "y")[1] - dl = DragLine(ax, position=start, orientation=Orientation.HORIZONTAL, snap_to=ls) - - drag(ax, (2, start), (2, hi + span / 2)) - assert dl.location == pytest.approx(hi, abs=span * 1e-6) - - drag(ax, (2, hi), (2, lo - span / 2)) - assert dl.location == pytest.approx(lo, abs=span * 1e-6) - - -@SNAP_TO_DATA_PARAMS -def test_dragrect_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(data, range(5))[0] - lo, hi, span = _native_extents(ls, "x") - ax.set_xlim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes - - native = get_native_data(ls, "x") - width = native[2] - native[1] - dr = DragRect(ax, position=native[1], width=width, snap_to=ls) - - # Drag from the middle of the rectangle so the click lands on the patch; dragging by the full - # data span pushes the leading edge beyond the data while keeping the cursor inside the axes - mid = native[1] + width / 2 - drag(ax, (mid, 2), (mid + span, 2)) - assert dr.bounds == pytest.approx((hi - width, hi), abs=span * 1e-6) - - mid = hi - width / 2 - drag(ax, (mid, 2), (mid - span, 2)) - assert dr.bounds == pytest.approx((lo, lo + width), abs=span * 1e-6) - - -@SNAP_TO_DATA_PARAMS -def test_dragline_snapto_data_types_out_of_bounds_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(data, range(5))[0] - _, hi, span = _native_extents(ls, "x") - - with pytest.raises(ValueError, match="bounds"): - _ = DragLine(ax, position=(hi + span), snap_to=ls) - - -@SNAP_TO_DATA_PARAMS -def test_dragrect_snapto_data_types_out_of_bounds_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(data, range(5))[0] - _, hi, span = _native_extents(ls, "x") - - with pytest.raises(ValueError, match="bounds"): - _ = DragRect(ax, position=hi, width=span, snap_to=ls) - - -# Positions & widths may also be provided in the plotted data's own units (e.g. a datetime & -# timedelta), but are always reported back in the axes' native float units -@SNAP_TO_DATA_PARAMS -def test_vertical_dragline_data_type_position(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(data, range(5))[0] - native = get_native_data(ls, "x") - - dl = DragLine(ax, position=data[1], orientation=Orientation.VERTICAL, snap_to=ls) - assert isinstance(dl.location, float) - assert dl.location == pytest.approx(native[1]) - - -@SNAP_TO_DATA_PARAMS -def test_horizontal_dragline_data_type_position(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(range(5), data)[0] - native = get_native_data(ls, "y") - - dl = DragLine(ax, position=data[1], orientation=Orientation.HORIZONTAL, snap_to=ls) - assert isinstance(dl.location, float) - assert dl.location == pytest.approx(native[1]) - - -@SNAP_TO_DATA_PARAMS -def test_dragrect_data_type_position_width(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(data, range(5))[0] - native = get_native_data(ls, "x") - - dr = DragRect(ax, position=data[1], width=(data[2] - data[1]), snap_to=ls) - assert all(isinstance(edge, float) for edge in dr.bounds) - assert dr.bounds == pytest.approx((native[1], native[2])) - - -@SNAP_TO_DATA_PARAMS -def test_flexrect_data_type_position_width(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ls = ax.plot(data, range(5))[0] - native = get_native_data(ls, "x") - - fr = FlexibleRect(ax, position=data[1], width=(data[2] - data[1]), snap_to=ls) - assert all(isinstance(edge, float) for edge in fr.bounds) - assert fr.bounds == pytest.approx((native[1], native[2])) - assert fr.face.bounds == pytest.approx((native[1], native[2])) - - -@SNAP_TO_DATA_PARAMS -def test_dragrect_data_type_negative_width_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ax.plot(data, range(5)) - - with pytest.raises(ValueError, match="greater than"): - _ = DragRect(ax, position=data[2], width=(data[1] - data[2])) - - -@SNAP_TO_DATA_PARAMS -def test_flexrect_data_type_negative_width_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - ax.plot(data, range(5)) - - with pytest.raises(ValueError, match="greater than"): - _ = FlexibleRect(ax, position=data[2], width=(data[1] - data[2]))