From 79b7725cc5553d2d73fbf7a6b85dc015e917d3b5 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 01/11] 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/release/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/release/next_whats_new/scroll_to_zoom.rst diff --git a/doc/release/next_whats_new/scroll_to_zoom.rst b/doc/release/next_whats_new/scroll_to_zoom.rst new file mode 100644 index 000000000000..ac68fac0a558 --- /dev/null +++ b/doc/release/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 e8c99e5956c9b47f9e9532647a0f29bd67d049c6 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 02/11] Apply suggestions from code review Co-authored-by: Elliott Sales de Andrade --- doc/release/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/release/next_whats_new/scroll_to_zoom.rst b/doc/release/next_whats_new/scroll_to_zoom.rst index ac68fac0a558..0006afc192b5 100644 --- a/doc/release/next_whats_new/scroll_to_zoom.rst +++ b/doc/release/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 58d922f0f2d2c84f0623798199af1944a99abb16 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 03/11] Only scroll-to-zoom on rectilinear Axes --- doc/release/next_whats_new/scroll_to_zoom.rst | 2 ++ lib/matplotlib/backend_bases.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/doc/release/next_whats_new/scroll_to_zoom.rst b/doc/release/next_whats_new/scroll_to_zoom.rst index 0006afc192b5..13897b9b8045 100644 --- a/doc/release/next_whats_new/scroll_to_zoom.rst +++ b/doc/release/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 b19571b36b8ea5c31bbb93cdf82575e8945fcbed 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 04/11] 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 From 55f1d3483cc798e16ba12661a546fb8b0863aa53 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 4 Sep 2025 23:31:06 +0200 Subject: [PATCH 05/11] Scroll to zoom in screen coordinates Co-authored-by: Elliott Sales de Andrade --- lib/matplotlib/backend_bases.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 815c61a0ee34..10634d68a15c 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2593,10 +2593,11 @@ def scroll_handler(event, canvas=None, toolbar=None): xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim() + xmin, ymin = ax.transScale.transform((xmin, ymin)) + xmax, ymax = ax.transScale.transform((xmax, ymax)) - # mouse position in data coordinates - x = event.xdata - y = event.ydata + # mouse position in scaled (e.g., log) data coordinates + x, y = ax.transScale.transform((event.xdata, event.ydata)) scale_factor = 1.0 - 0.05 * event.step new_xmin = x - (x - xmin) * scale_factor @@ -2604,6 +2605,10 @@ def scroll_handler(event, canvas=None, toolbar=None): new_ymin = y - (y - ymin) * scale_factor new_ymax = y + (ymax - y) * scale_factor + inv_scale = ax.transScale.inverted() + new_xmin, new_ymin = inv_scale.transform((new_xmin, new_ymin)) + new_xmax, new_ymax = inv_scale.transform((new_xmax, new_ymax)) + ax.set_xlim(new_xmin, new_xmax) ax.set_ylim(new_ymin, new_ymax) From c883c9fc7d76effb588af53aaab3dbf2d789e226 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Thu, 4 Sep 2025 23:39:19 +0200 Subject: [PATCH 06/11] Increase zoom step from 5% to 10% --- lib/matplotlib/backend_bases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 10634d68a15c..729296d13e34 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2599,7 +2599,7 @@ def scroll_handler(event, canvas=None, toolbar=None): # mouse position in scaled (e.g., log) data coordinates x, y = ax.transScale.transform((event.xdata, event.ydata)) - scale_factor = 1.0 - 0.05 * event.step + scale_factor = 1.0 - 0.1 * event.step new_xmin = x - (x - xmin) * scale_factor new_xmax = x + (xmax - x) * scale_factor new_ymin = y - (y - ymin) * scale_factor From 3f9098d9640f17da5004cd953a0eda1860739754 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:37:53 +0200 Subject: [PATCH 07/11] Zooming in and out restores the exact original view --- lib/matplotlib/backend_bases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 729296d13e34..b7f1bf9b0670 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2599,7 +2599,7 @@ def scroll_handler(event, canvas=None, toolbar=None): # mouse position in scaled (e.g., log) data coordinates x, y = ax.transScale.transform((event.xdata, event.ydata)) - scale_factor = 1.0 - 0.1 * event.step + scale_factor = 0.9 ** event.step new_xmin = x - (x - xmin) * scale_factor new_xmax = x + (xmax - x) * scale_factor new_ymin = y - (y - ymin) * scale_factor From 8032f524a64e23f8f0f1e894d0ef58cafc7d6f70 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:39:02 +0200 Subject: [PATCH 08/11] Use one transform call for both x and y --- lib/matplotlib/backend_bases.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index b7f1bf9b0670..cdda00bdc253 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2593,8 +2593,8 @@ def scroll_handler(event, canvas=None, toolbar=None): xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim() - xmin, ymin = ax.transScale.transform((xmin, ymin)) - xmax, ymax = ax.transScale.transform((xmax, ymax)) + (xmin, ymin), (xmax, ymax) = ax.transScale.transform( + [(xmin, ymin), (xmax, ymax)]) # mouse position in scaled (e.g., log) data coordinates x, y = ax.transScale.transform((event.xdata, event.ydata)) @@ -2606,8 +2606,8 @@ def scroll_handler(event, canvas=None, toolbar=None): new_ymax = y + (ymax - y) * scale_factor inv_scale = ax.transScale.inverted() - new_xmin, new_ymin = inv_scale.transform((new_xmin, new_ymin)) - new_xmax, new_ymax = inv_scale.transform((new_xmax, new_ymax)) + (new_xmin, new_ymin), (new_xmax, new_ymax) = inv_scale.transform( + [(new_xmin, new_ymin), (new_xmax, new_ymax)]) ax.set_xlim(new_xmin, new_xmax) ax.set_ylim(new_ymin, new_ymax) From 413402b89270ef8667e3819eb67040d86a044861 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:53:41 +0200 Subject: [PATCH 09/11] Change zoom behavior - use Ctrl+Wheel to zoom - make this always on - independent of tool state --- doc/release/next_whats_new/scroll_to_zoom.rst | 11 ++++------- lib/matplotlib/backend_bases.py | 12 +++++------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/doc/release/next_whats_new/scroll_to_zoom.rst b/doc/release/next_whats_new/scroll_to_zoom.rst index 13897b9b8045..bafa312c32a5 100644 --- a/doc/release/next_whats_new/scroll_to_zoom.rst +++ b/doc/release/next_whats_new/scroll_to_zoom.rst @@ -1,11 +1,8 @@ -Scroll-to-zoom in GUIs -~~~~~~~~~~~~~~~~~~~~~~ +Zooming using mouse wheel +~~~~~~~~~~~~~~~~~~~~~~~~~ -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. +``Ctrl+MouseWheel`` can be used to zoom in the plot windows. -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. +The zoom focusses on the mouse pointer, and keeps the aspect ratio of the axes. Zooming is currently only supported on rectilinear Axes. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index cdda00bdc253..3705dc72801c 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2582,14 +2582,12 @@ def scroll_handler(event, canvas=None, toolbar=None): # zooming is currently only supported on rectilinear axes return - if toolbar is None: - toolbar = (canvas or event.canvas).toolbar - - if toolbar is None or toolbar.mode == _Mode.NONE: - return + if event.key == "control": # zoom towards the mouse position + if toolbar is None: + toolbar = (canvas or event.canvas).toolbar - if event.key is None: # zoom towards the mouse position - toolbar.push_current() + if toolbar is not None: + toolbar.push_current() # update view history xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim() From f5ba730a9310cfb1058a17c0bdab4d51b643f079 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:07:37 +0200 Subject: [PATCH 10/11] Fine tune zoom factor --- lib/matplotlib/backend_bases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 3705dc72801c..711dc08aab6f 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2597,7 +2597,7 @@ def scroll_handler(event, canvas=None, toolbar=None): # mouse position in scaled (e.g., log) data coordinates x, y = ax.transScale.transform((event.xdata, event.ydata)) - scale_factor = 0.9 ** event.step + scale_factor = 0.85 ** event.step new_xmin = x - (x - xmin) * scale_factor new_xmax = x + (xmax - x) * scale_factor new_ymin = y - (y - ymin) * scale_factor From 3ee98fd07382b1f4ed1bafaa7a14fb0ab7be174e Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:24:58 +0200 Subject: [PATCH 11/11] Still require toolbar for interactive navigation --- lib/matplotlib/backend_bases.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 711dc08aab6f..7560db80d2c1 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2582,12 +2582,18 @@ def scroll_handler(event, canvas=None, toolbar=None): # zooming is currently only supported on rectilinear axes return - if event.key == "control": # zoom towards the mouse position - if toolbar is None: - toolbar = (canvas or event.canvas).toolbar + if toolbar is None: + toolbar = (canvas or event.canvas).toolbar + + if toolbar is None: + # technically we do not need a toolbar, but until wheel zoom was + # introduced, any interactive modification was only possible through + # the toolbar tools. For now, we keep the restriction that a toolbar + # is required for interactive navigation. + return - if toolbar is not None: - toolbar.push_current() # update view history + if event.key == "control": # zoom towards the mouse position + toolbar.push_current() xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim()