From 87079119e89a7134f2beaf2246f7d4dab46a7440 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sun, 31 Aug 2025 09:12:27 +0100 Subject: [PATCH 1/4] Improve cursor icons with RectangleSelector --- lib/matplotlib/widgets.py | 56 +++++++++++++++++++++++++++++--------- lib/matplotlib/widgets.pyi | 3 ++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 034a9b4db7a0..53c1c9b117b8 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -150,6 +150,10 @@ def ignore(self, event): # docstring inherited return super().ignore(event) or self.canvas is None + def _set_cursor(self, cursor): + """Update the canvas cursor.""" + self.ax.get_figure(root=True).canvas.set_cursor(cursor) + class Button(AxesWidget): """ @@ -2643,7 +2647,7 @@ def _handles_artists(self): else: return () - def _set_cursor(self, enabled): + def _set_span_cursor(self, enabled): """Update the canvas cursor based on direction of the selector.""" if enabled: cursor = (backend_tools.Cursors.RESIZE_HORIZONTAL @@ -2652,7 +2656,7 @@ def _set_cursor(self, enabled): else: cursor = backend_tools.Cursors.POINTER - self.ax.get_figure(root=True).canvas.set_cursor(cursor) + self._set_cursor(cursor) def connect_default_events(self): # docstring inherited @@ -2662,7 +2666,7 @@ def connect_default_events(self): def _press(self, event): """Button press event handler.""" - self._set_cursor(True) + self._set_span_cursor(True) if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) else: @@ -2712,7 +2716,7 @@ def direction(self, direction): def _release(self, event): """Button release event handler.""" - self._set_cursor(False) + self._set_span_cursor(False) if not self._interactive: self._selection_artist.set_visible(False) @@ -2754,7 +2758,7 @@ def _hover(self, event): return _, e_dist = self._edge_handles.closest(event.x, event.y) - self._set_cursor(e_dist <= self.grab_range) + self._set_span_cursor(e_dist <= self.grab_range) def _onmove(self, event): """Motion notify event handler.""" @@ -3278,10 +3282,24 @@ def _press(self, event): self._rotation_on_press = self._rotation self._set_aspect_ratio_correction() + match self._get_action(): + case "rotate": + # TODO: set to a rotate cursor if possible? + pass + case "move": + self._set_cursor(backend_tools.cursors.MOVE) + case "resize": + # TODO: set to a resize cursor if possible? + pass + case "create": + self._set_cursor(backend_tools.cursors.SELECT_REGION) + + return False def _release(self, event): """Button release event handler.""" + self._set_cursor(backend_tools.Cursors.POINTER) if not self._interactive: self._selection_artist.set_visible(False) @@ -3325,9 +3343,23 @@ def _release(self, event): self.update() self._active_handle = None self._extents_on_press = None - return False + def _get_action(self): + """ + Return one of "rotate", "move", "resize", "create" + """ + state = self._state + if 'rotate' in state and self._active_handle in self._corner_order: + return 'rotate' + elif self._active_handle == 'C': + return 'move' + elif self._active_handle: + return 'resize' + + return 'create' + + def _onmove(self, event): """ Motion notify event handler. @@ -3342,12 +3374,10 @@ def _onmove(self, event): # The calculations are done for rotation at zero: we apply inverse # transformation to events except when we rotate and move state = self._state - rotate = 'rotate' in state and self._active_handle in self._corner_order - move = self._active_handle == 'C' - resize = self._active_handle and not move + action = self._get_action() xdata, ydata = self._get_data_coords(event) - if resize: + if action == "resize": inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3367,7 +3397,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if rotate: + if action == "rotate": # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3376,7 +3406,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif resize: + elif action == "resize": size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3427,7 +3457,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif move: + elif action == "move": x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index e143d0b2c96e..cd26ab84c49c 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -6,6 +6,7 @@ from .figure import Figure from .lines import Line2D from .patches import Polygon, Rectangle from .text import Text +from .backend_tools import Cursors import PIL.Image @@ -38,6 +39,7 @@ class AxesWidget(Widget): def canvas(self) -> FigureCanvasBase | None: ... def connect_event(self, event: Event, callback: Callable) -> None: ... def disconnect_events(self) -> None: ... + def _set_cursor(self, cursor: Cursors) -> None: ... class Button(AxesWidget): label: Text @@ -398,6 +400,7 @@ class RectangleSelector(_SelectorWidget): minspany: float spancoords: Literal["data", "pixels"] grab_range: float + _active_handle: None | Literal["C", "N", "NE", "E", "SE", "S", "SW", "W", "NW"] def __init__( self, ax: Axes, From ca02c4654532b4ff56eb82c528d24a5092e387d9 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 1 Sep 2025 12:02:55 +0100 Subject: [PATCH 2/4] Use an enum for RectangleSelector state --- lib/matplotlib/widgets.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 53c1c9b117b8..bd9a62508616 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -11,6 +11,7 @@ from contextlib import ExitStack import copy +import enum import itertools from numbers import Integral, Number @@ -3149,6 +3150,13 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) """ +class _RectangleSelectorState(enum.Enum): + ROTATE = enum.auto() + MOVE = enum.auto() + RESIZE = enum.auto() + CREATE = enum.auto() + + @_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace( '__ARTIST_NAME__', 'rectangle')) class RectangleSelector(_SelectorWidget): @@ -3283,18 +3291,17 @@ def _press(self, event): self._set_aspect_ratio_correction() match self._get_action(): - case "rotate": + case _RectangleSelectorState.ROTATE: # TODO: set to a rotate cursor if possible? pass - case "move": + case _RectangleSelectorState.MOVE: self._set_cursor(backend_tools.cursors.MOVE) - case "resize": + case _RectangleSelectorState.RESIZE: # TODO: set to a resize cursor if possible? pass - case "create": + case _RectangleSelectorState.CREATE: self._set_cursor(backend_tools.cursors.SELECT_REGION) - return False def _release(self, event): @@ -3346,18 +3353,15 @@ def _release(self, event): return False def _get_action(self): - """ - Return one of "rotate", "move", "resize", "create" - """ state = self._state if 'rotate' in state and self._active_handle in self._corner_order: - return 'rotate' + return _RectangleSelectorState.ROTATE elif self._active_handle == 'C': - return 'move' + return _RectangleSelectorState.MOVE elif self._active_handle: - return 'resize' + return _RectangleSelectorState.RESIZE - return 'create' + return _RectangleSelectorState.CREATE def _onmove(self, event): @@ -3377,7 +3381,7 @@ def _onmove(self, event): action = self._get_action() xdata, ydata = self._get_data_coords(event) - if action == "resize": + if action == _RectangleSelectorState.RESIZE: inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3397,7 +3401,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if action == "rotate": + if action == _RectangleSelectorState.ROTATE: # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3406,7 +3410,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif action == "resize": + elif action == _RectangleSelectorState.RESIZE: size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3457,7 +3461,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif action == "move": + elif action == _RectangleSelectorState.MOVE: x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata From 4d2e657f7b585ba43a8e908937b16346b8d3c49d Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 1 Sep 2025 12:05:46 +0100 Subject: [PATCH 3/4] Improve signature of set_span_cursor --- lib/matplotlib/widgets.py | 8 ++++---- lib/matplotlib/widgets.pyi | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index bd9a62508616..f03e1c1c030d 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2648,7 +2648,7 @@ def _handles_artists(self): else: return () - def _set_span_cursor(self, enabled): + def _set_span_cursor(self, *, enabled): """Update the canvas cursor based on direction of the selector.""" if enabled: cursor = (backend_tools.Cursors.RESIZE_HORIZONTAL @@ -2667,7 +2667,7 @@ def connect_default_events(self): def _press(self, event): """Button press event handler.""" - self._set_span_cursor(True) + self._set_span_cursor(enabled=True) if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) else: @@ -2717,7 +2717,7 @@ def direction(self, direction): def _release(self, event): """Button release event handler.""" - self._set_span_cursor(False) + self._set_span_cursor(enabled=False) if not self._interactive: self._selection_artist.set_visible(False) @@ -2759,7 +2759,7 @@ def _hover(self, event): return _, e_dist = self._edge_handles.closest(event.x, event.y) - self._set_span_cursor(e_dist <= self.grab_range) + self._set_span_cursor(enabled=e_dist <= self.grab_range) def _onmove(self, event): """Motion notify event handler.""" diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index cd26ab84c49c..f74b9c7f32bf 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -337,6 +337,7 @@ class SpanSelector(_SelectorWidget): _props: dict[str, Any] | None = ..., _init: bool = ..., ) -> None: ... + def _set_span_cursor(self, *, enabled: bool) -> None: ... def connect_default_events(self) -> None: ... @property def direction(self) -> Literal["horizontal", "vertical"]: ... From a3fafa63f9d01e9f9ed7e3bdca3032cf52b22a43 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 1 Sep 2025 12:07:58 +0100 Subject: [PATCH 4/4] Improve variable naming --- lib/matplotlib/widgets.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index f03e1c1c030d..d10de28c106a 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -3150,7 +3150,7 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) """ -class _RectangleSelectorState(enum.Enum): +class _RectangleSelectorAction(enum.Enum): ROTATE = enum.auto() MOVE = enum.auto() RESIZE = enum.auto() @@ -3291,15 +3291,15 @@ def _press(self, event): self._set_aspect_ratio_correction() match self._get_action(): - case _RectangleSelectorState.ROTATE: + case _RectangleSelectorAction.ROTATE: # TODO: set to a rotate cursor if possible? pass - case _RectangleSelectorState.MOVE: + case _RectangleSelectorAction.MOVE: self._set_cursor(backend_tools.cursors.MOVE) - case _RectangleSelectorState.RESIZE: + case _RectangleSelectorAction.RESIZE: # TODO: set to a resize cursor if possible? pass - case _RectangleSelectorState.CREATE: + case _RectangleSelectorAction.CREATE: self._set_cursor(backend_tools.cursors.SELECT_REGION) return False @@ -3355,13 +3355,13 @@ def _release(self, event): def _get_action(self): state = self._state if 'rotate' in state and self._active_handle in self._corner_order: - return _RectangleSelectorState.ROTATE + return _RectangleSelectorAction.ROTATE elif self._active_handle == 'C': - return _RectangleSelectorState.MOVE + return _RectangleSelectorAction.MOVE elif self._active_handle: - return _RectangleSelectorState.RESIZE + return _RectangleSelectorAction.RESIZE - return _RectangleSelectorState.CREATE + return _RectangleSelectorAction.CREATE def _onmove(self, event): @@ -3381,7 +3381,7 @@ def _onmove(self, event): action = self._get_action() xdata, ydata = self._get_data_coords(event) - if action == _RectangleSelectorState.RESIZE: + if action == _RectangleSelectorAction.RESIZE: inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3401,7 +3401,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if action == _RectangleSelectorState.ROTATE: + if action == _RectangleSelectorAction.ROTATE: # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3410,7 +3410,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif action == _RectangleSelectorState.RESIZE: + elif action == _RectangleSelectorAction.RESIZE: size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3461,7 +3461,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif action == _RectangleSelectorState.MOVE: + elif action == _RectangleSelectorAction.MOVE: x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata