From e3395211abfa0f1bbef784de58dd966c74b7a2ea Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:11:21 +0200 Subject: [PATCH 1/4] ENH: Scroll to zoom Implements a minimal version of #20317, in particular https://github .com/matplotlib/matplotlib/pull/20317#issuecomment-2233156558: When any of the axes manipulation tools is active (pan or zoom tool), a mouse scroll results in a zoom towards the cursor, keeping aspect ratio. I've decided to require an active manipulation tool, so that without any active tool the plot cannot be changed (accidentally) - as before. For convenience, scroll-to-zoom is allowed with both the zoom and pan tools. Limiting further feels unnecessarily restrictive. Zooming is also limited to not having a modifier key pressed. This is because we might later want to add scroll+modifiers for other operations . It's better for now not to react to these at all to not introduce behaviors we later want to change. --- doc/users/next_whats_new/scroll_to_zoom.rst | 9 +++++ lib/matplotlib/backend_bases.py | 38 +++++++++++++++++++++ lib/matplotlib/backend_bases.pyi | 6 ++++ 3 files changed, 53 insertions(+) create mode 100644 doc/users/next_whats_new/scroll_to_zoom.rst diff --git a/doc/users/next_whats_new/scroll_to_zoom.rst b/doc/users/next_whats_new/scroll_to_zoom.rst new file mode 100644 index 000000000000..ac68fac0a558 --- /dev/null +++ b/doc/users/next_whats_new/scroll_to_zoom.rst @@ -0,0 +1,9 @@ +Scroll-to-zoom in GUIs +~~~~~~~~~~~~~~~~~~~~~~ + +When a plot manipulation tool (pan or zoom tool) in plot windows is selected, +a mouse scroll operation results in a zoom towards the mouse pointer, keeping the +aspect ratio of the axes. + +There is no effect if no manipulation tool is selected. This is intentional to +keep a state in which accidental manipulation of the plot is excluded. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 8107471955fe..4ca2f122ec63 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2574,6 +2574,41 @@ def button_press_handler(event, canvas=None, toolbar=None): toolbar.forward() +def scroll_handler(event, canvas=None, toolbar=None): + ax = event.inaxes + if ax is None: + return + + if toolbar is None: + if canvas is None: + canvas = event.canvas + toolbar = canvas.toolbar + + if toolbar is None or toolbar.mode == _Mode.NONE: + return + + if event.key is None: # zoom towards the mouse position + toolbar.push_current() + + xmin, xmax = ax.get_xlim() + ymin, ymax = ax.get_ylim() + + # mouse position in data coordinates + x = event.xdata + y = event.ydata + + scale_factor = 1.0 - 0.05 * event.step + new_xmin = x - (x - xmin) * scale_factor + new_xmax = x + (xmax - x) * scale_factor + new_ymin = y - (y - ymin) * scale_factor + new_ymax = y + (ymax - y) * scale_factor + + ax.set_xlim(new_xmin, new_xmax) + ax.set_ylim(new_ymin, new_ymax) + + ax.figure.canvas.draw_idle() + + class NonGuiException(Exception): """Raised when trying show a figure in a non-GUI backend.""" pass @@ -2653,11 +2688,14 @@ def __init__(self, canvas, num): self.key_press_handler_id = None self.button_press_handler_id = None + self.scroll_handler_id = None if rcParams['toolbar'] != 'toolmanager': self.key_press_handler_id = self.canvas.mpl_connect( 'key_press_event', key_press_handler) self.button_press_handler_id = self.canvas.mpl_connect( 'button_press_event', button_press_handler) + self.scroll_handler_id = self.canvas.mpl_connect( + 'scroll_event', scroll_handler) self.toolmanager = (ToolManager(canvas.figure) if mpl.rcParams['toolbar'] == 'toolmanager' diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index c65d39415472..1aa5d5405989 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -407,6 +407,11 @@ def button_press_handler( canvas: FigureCanvasBase | None = ..., toolbar: NavigationToolbar2 | None = ..., ) -> None: ... +def scroll_handler( + event: MouseEvent, + canvas: FigureCanvasBase | None = ..., + toolbar: NavigationToolbar2 | None = ..., +) -> None: ... class NonGuiException(Exception): ... @@ -415,6 +420,7 @@ class FigureManagerBase: num: int | str key_press_handler_id: int | None button_press_handler_id: int | None + scroll_handler_id: int | None toolmanager: ToolManager | None toolbar: NavigationToolbar2 | ToolContainerBase | None def __init__(self, canvas: FigureCanvasBase, num: int | str) -> None: ... From c830dd8dc2786148df57ef98bf90fa9e039f9773 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:00:41 +0200 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Elliott Sales de Andrade --- doc/users/next_whats_new/scroll_to_zoom.rst | 6 +++--- lib/matplotlib/backend_bases.pyi | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/users/next_whats_new/scroll_to_zoom.rst b/doc/users/next_whats_new/scroll_to_zoom.rst index ac68fac0a558..0006afc192b5 100644 --- a/doc/users/next_whats_new/scroll_to_zoom.rst +++ b/doc/users/next_whats_new/scroll_to_zoom.rst @@ -1,9 +1,9 @@ Scroll-to-zoom in GUIs ~~~~~~~~~~~~~~~~~~~~~~ -When a plot manipulation tool (pan or zoom tool) in plot windows is selected, -a mouse scroll operation results in a zoom towards the mouse pointer, keeping the +When a plot manipulation tool (pan or zoom tool) in plot windows is enabled, +a mouse scroll operation results in a zoom focussing on the mouse pointer, keeping the aspect ratio of the axes. There is no effect if no manipulation tool is selected. This is intentional to -keep a state in which accidental manipulation of the plot is excluded. +keep a state in which accidental manipulation of the plot is avoided. diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 1aa5d5405989..7a2b28262249 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -409,8 +409,8 @@ def button_press_handler( ) -> None: ... def scroll_handler( event: MouseEvent, - canvas: FigureCanvasBase | None = ..., - toolbar: NavigationToolbar2 | None = ..., + canvas: FigureCanvasBase | None = ..., + toolbar: NavigationToolbar2 | None = ..., ) -> None: ... class NonGuiException(Exception): ... From aabfe7102b1e27aef9965944a39cfee682dfd9fc Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 8 Aug 2025 19:55:15 +0200 Subject: [PATCH 3/4] Only scroll-to-zoom on rectilinear Axes --- doc/users/next_whats_new/scroll_to_zoom.rst | 2 ++ lib/matplotlib/backend_bases.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/doc/users/next_whats_new/scroll_to_zoom.rst b/doc/users/next_whats_new/scroll_to_zoom.rst index 0006afc192b5..13897b9b8045 100644 --- a/doc/users/next_whats_new/scroll_to_zoom.rst +++ b/doc/users/next_whats_new/scroll_to_zoom.rst @@ -7,3 +7,5 @@ aspect ratio of the axes. There is no effect if no manipulation tool is selected. This is intentional to keep a state in which accidental manipulation of the plot is avoided. + +Zooming is currently only supported on rectilinear Axes. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 4ca2f122ec63..459879d34982 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2578,6 +2578,9 @@ def scroll_handler(event, canvas=None, toolbar=None): ax = event.inaxes if ax is None: return + if ax.name != "rectilinear": + # zooming is currently only supported on rectilinear axes + return if toolbar is None: if canvas is None: From 4e9540266b227412670fef044d0559961daacd3c Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 8 Aug 2025 19:55:32 +0200 Subject: [PATCH 4/4] Minor simplification --- lib/matplotlib/backend_bases.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 459879d34982..815c61a0ee34 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2583,9 +2583,7 @@ def scroll_handler(event, canvas=None, toolbar=None): return if toolbar is None: - if canvas is None: - canvas = event.canvas - toolbar = canvas.toolbar + toolbar = (canvas or event.canvas).toolbar if toolbar is None or toolbar.mode == _Mode.NONE: return