From b9c7ec4a867b689c059729a98a4d2cdc1aeb2a10 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Sun, 14 Sep 2025 01:09:02 +1000 Subject: [PATCH 01/22] Implement single axis zoom --- lib/matplotlib/backend_bases.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 7560db80d2c1..115e969447d7 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3228,6 +3228,12 @@ def drag_zoom(self, event): elif key == "y": x1, x2 = ax.bbox.intervalx + # Single-axis zooms by moving less than 20 pixels + if (abs(event.x - start_xy[0]) < 20): + x1, x2 = ax.bbox.intervalx + elif (abs(event.y - start_xy[1]) < 20): + y1, y2 = ax.bbox.intervaly + self.draw_rubberband(event, x1, y1, x2, y2) def release_zoom(self, event): @@ -3249,12 +3255,6 @@ def release_zoom(self, event): key = "x" elif self._zoom_info.cbar == "vertical": key = "y" - # Ignore single clicks: 5 pixels is a threshold that allows the user to - # "cancel" a zoom action by zooming by less than 5 pixels. - if ((abs(event.x - start_x) < 5 and key != "y") or - (abs(event.y - start_y) < 5 and key != "x")): - self._cleanup_post_zoom() - return for i, ax in enumerate(self._zoom_info.axes): # Detect whether this Axes is twinned with an earlier Axes in the @@ -3263,8 +3263,14 @@ def release_zoom(self, event): for prev in self._zoom_info.axes[:i]) twiny = any(ax.get_shared_y_axes().joined(ax, prev) for prev in self._zoom_info.axes[:i]) + # Handle release of single axis zooms + end_x, end_y = event.x, event.y + if abs(end_x - start_x) < 20: + start_x, end_x = ax.bbox.intervalx + if abs(end_y - start_y) < 20: + start_y, end_y = ax.bbox.intervaly ax._set_view_from_bbox( - (start_x, start_y, event.x, event.y), + (start_x, start_y, end_x, end_y), direction, key, twinx, twiny) self._cleanup_post_zoom() From fba4e59c734c459307dce62d05d911b0a0674ac4 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Sun, 14 Sep 2025 01:31:31 +1000 Subject: [PATCH 02/22] Add whats-new --- doc/release/next_whats_new/single_axis_zoom.rst | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 doc/release/next_whats_new/single_axis_zoom.rst diff --git a/doc/release/next_whats_new/single_axis_zoom.rst b/doc/release/next_whats_new/single_axis_zoom.rst new file mode 100644 index 000000000000..47af9de9eb41 --- /dev/null +++ b/doc/release/next_whats_new/single_axis_zoom.rst @@ -0,0 +1,5 @@ +Single Axis Zoom +---------------- + +Zooming in a single axis (horizontal or vertical) can be done by dragging the +zoom rectangle in one direction only. From a93ddba3eae3be9548a29759dd2f3b10d5621fdf Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Sun, 14 Sep 2025 01:35:31 +1000 Subject: [PATCH 03/22] Tune algo to reduce flicker --- lib/matplotlib/backend_bases.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 115e969447d7..26455acf21a3 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3228,10 +3228,10 @@ def drag_zoom(self, event): elif key == "y": x1, x2 = ax.bbox.intervalx - # Single-axis zooms by moving less than 20 pixels - if (abs(event.x - start_xy[0]) < 20): + # Single-axis zooms by moving less than 10 pixels + if (abs(event.x - start_xy[0]) < 10) and (abs(event.y - start_xy[1]) > 20): x1, x2 = ax.bbox.intervalx - elif (abs(event.y - start_xy[1]) < 20): + elif (abs(event.y - start_xy[1]) < 10) and (abs(event.x - start_xy[0]) > 20): y1, y2 = ax.bbox.intervaly self.draw_rubberband(event, x1, y1, x2, y2) @@ -3265,9 +3265,9 @@ def release_zoom(self, event): for prev in self._zoom_info.axes[:i]) # Handle release of single axis zooms end_x, end_y = event.x, event.y - if abs(end_x - start_x) < 20: + if (abs(end_x - start_x) < 10) and (abs(end_y - start_y) > 20): start_x, end_x = ax.bbox.intervalx - if abs(end_y - start_y) < 20: + if (abs(end_y - start_y) < 10) and (abs(end_x - start_x) > 20): start_y, end_y = ax.bbox.intervaly ax._set_view_from_bbox( (start_x, start_y, end_x, end_y), From 9dbce1c79cbd5cb529648ce09747214815a10e18 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Sun, 14 Sep 2025 23:09:18 +1000 Subject: [PATCH 04/22] Whiskers on single-axis zoom PyQt --- lib/matplotlib/backend_bases.py | 15 +++++++ lib/matplotlib/backend_bases.pyi | 4 ++ lib/matplotlib/backends/backend_qt.py | 48 ++++++++++++++++++++++ lib/matplotlib/backends/backend_qtagg.py | 1 + lib/matplotlib/backends/backend_qtcairo.py | 1 + 5 files changed, 69 insertions(+) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 26455acf21a3..f980e2623eb7 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2935,6 +2935,16 @@ def draw_rubberband(self, event, x0, y0, x1, y1): def remove_rubberband(self): """Remove the rubberband.""" + def draw_whiskers(self, event, x0, y0, x1, y1): + """ + Draw line with whiskers to indicate single axis zoom + + We expect that ``x0 == x1`` or ``y0 == y1``. Else nothing will draw + """ + + def remove_whiskers(self): + """Remove the whiskers.""" + def home(self, *args): """ Restore the original view. @@ -3231,8 +3241,12 @@ def drag_zoom(self, event): # Single-axis zooms by moving less than 10 pixels if (abs(event.x - start_xy[0]) < 10) and (abs(event.y - start_xy[1]) > 20): x1, x2 = ax.bbox.intervalx + self.draw_whiskers(event, start_xy[0], y1, start_xy[0], y2) elif (abs(event.y - start_xy[1]) < 10) and (abs(event.x - start_xy[0]) > 20): y1, y2 = ax.bbox.intervaly + self.draw_whiskers(event, x1, start_xy[1], x2, start_xy[1]) + else: + self.remove_whiskers() self.draw_rubberband(event, x1, y1, x2, y2) @@ -3245,6 +3259,7 @@ def release_zoom(self, event): # by (pressing and) releasing another mouse button. self.canvas.mpl_disconnect(self._zoom_info.cid) self.remove_rubberband() + self.remove_whiskers() start_x, start_y = self._zoom_info.start_xy direction = "in" if self._zoom_info.button == 1 else "out" diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 7a2b28262249..ef1cb92a04b5 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -457,6 +457,10 @@ class NavigationToolbar2: self, event: Event, x0: float, y0: float, x1: float, y1: float ) -> None: ... def remove_rubberband(self) -> None: ... + def draw_whiskers( + self, event: Event, x0: float, y0: float, x1: float, y1: float + ) -> None: ... + def remove_whiskers(self) -> None: ... def home(self, *args) -> None: ... def back(self, *args) -> None: ... def forward(self, *args) -> None: ... diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index d0aded5fff63..6ea759c96e9a 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -235,6 +235,7 @@ def __init__(self, figure=None): self._draw_pending = False self._is_drawing = False self._draw_rect_callback = lambda painter: None + self._draw_whisker_callback = lambda painter: None self._in_resize_event = False self.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent) @@ -525,6 +526,43 @@ def _draw_idle(self): # Uncaught exceptions are fatal for PyQt5, so catch them. traceback.print_exc() + def drawWhiskers(self, line, ws=20): + # Draw single axis zoom whiskers + if line is None: + def _draw_whisker_callback(painter): + return + else: + x0, y0, x1, y1 = [int(pt / self.device_pixel_ratio) for pt in line] + ws = int(ws / self.device_pixel_ratio) + if x0 == x1: # vertical line + def _draw_whisker_callback(painter): + pen = QtGui.QPen( + QtGui.QColor("black"), + 2 / self.device_pixel_ratio + ) + + painter.setPen(pen) + painter.drawLine(x0 - ws // 2, y0, x0 + ws // 2, y0) + painter.drawLine(x0 - ws // 2, y1, x0 + ws // 2, y1) + painter.drawLine(x0, y0, x0, y1) + + elif y0 == y1: # horizontal line + def _draw_whisker_callback(painter): + pen = QtGui.QPen( + QtGui.QColor("black"), + 2 / self.device_pixel_ratio + ) + + painter.setPen(pen) + painter.drawLine(x0, y0 - ws // 2, x0, y0 + ws // 2) + painter.drawLine(x1, y0 - ws // 2, x1, y0 + ws // 2) + painter.drawLine(x0, y0, x1, y0) + else: + def _draw_whisker_callback(painter): + return + self._draw_whisker_callback = _draw_whisker_callback + self.update() + def drawRectangle(self, rect): # Draw the zoom rectangle to the QPainter. _draw_rect_callback needs # to be called at the end of paintEvent. @@ -815,9 +853,19 @@ def draw_rubberband(self, event, x0, y0, x1, y1): rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)] self.canvas.drawRectangle(rect) + def draw_whiskers(self, event, x0, y0, x1, y1): + height = self.canvas.figure.bbox.height + y1 = height - y1 + y0 = height - y0 + whisk = [int(val) for val in (x0, y0, x1, y1)] + self.canvas.drawWhiskers(whisk) + def remove_rubberband(self): self.canvas.drawRectangle(None) + def remove_whiskers(self): + self.canvas.drawWhiskers(None) + def configure_subplots(self): if self._subplot_dialog is None: self._subplot_dialog = SubplotToolQt( diff --git a/lib/matplotlib/backends/backend_qtagg.py b/lib/matplotlib/backends/backend_qtagg.py index 256e50a3d1c3..7e486ce7389b 100644 --- a/lib/matplotlib/backends/backend_qtagg.py +++ b/lib/matplotlib/backends/backend_qtagg.py @@ -68,6 +68,7 @@ def paintEvent(self, event): ctypes.c_long.from_address(id(buf)).value = 1 self._draw_rect_callback(painter) + self._draw_whisker_callback(painter) finally: painter.end() diff --git a/lib/matplotlib/backends/backend_qtcairo.py b/lib/matplotlib/backends/backend_qtcairo.py index 72eb2dc70b90..912276dc720f 100644 --- a/lib/matplotlib/backends/backend_qtcairo.py +++ b/lib/matplotlib/backends/backend_qtcairo.py @@ -38,6 +38,7 @@ def paintEvent(self, event): painter.eraseRect(event.rect()) painter.drawImage(0, 0, qimage) self._draw_rect_callback(painter) + self._draw_whisker_callback(painter) painter.end() From c379571f074f1a1a8503d4a452fffaa6b5e4cdaa Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Sun, 14 Sep 2025 23:20:28 +1000 Subject: [PATCH 05/22] Explicitly set black rectangle color --- lib/matplotlib/backends/_backend_tk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 813e0c60620f..85a8fbbd6aed 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -760,7 +760,7 @@ def draw_rubberband(self, event, x0, y0, x1, y1): y1 = height - y1 self.canvas._rubberband_rect_black = ( self.canvas._tkcanvas.create_rectangle( - x0, y0, x1, y1)) + x0, y0, x1, y1, outline='black')) self.canvas._rubberband_rect_white = ( self.canvas._tkcanvas.create_rectangle( x0, y0, x1, y1, outline='white', dash=(3, 3))) From 961028e6c9cf4b62033f6030043fcf14c095917a Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Sun, 14 Sep 2025 23:32:41 +1000 Subject: [PATCH 06/22] Whiskers on single-axis zoom plot Tk backend --- lib/matplotlib/backends/_backend_tk.py | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 85a8fbbd6aed..e02e4c9926ce 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -259,6 +259,9 @@ def filter_destroy(event): self._rubberband_rect_black = None self._rubberband_rect_white = None + self._whisker_line = None + self._whisker_cap1 = None + self._whisker_cap2 = None def _update_device_pixel_ratio(self, event=None): ratio = None @@ -765,6 +768,41 @@ def draw_rubberband(self, event, x0, y0, x1, y1): self.canvas._tkcanvas.create_rectangle( x0, y0, x1, y1, outline='white', dash=(3, 3))) + def draw_whiskers(self, event, x0, y0, x1, y1, ws=20): + if self.canvas._whisker_line: + self.canvas._tkcanvas.delete(self.canvas._whisker_line) + if self.canvas._whisker_cap1: + self.canvas._tkcanvas.delete(self.canvas._whisker_cap1) + if self.canvas._whisker_cap2: + self.canvas._tkcanvas.delete(self.canvas._whisker_cap2) + height = self.canvas.figure.bbox.height + y0 = height - y0 + y1 = height - y1 + self.canvas._whisker_line = ( + self.canvas._tkcanvas.create_line(x0, y0, x1, y1, fill='black', width=2) + ) + if x1 == x0: # vertical line + self.canvas._whisker_cap1 = ( + self.canvas._tkcanvas.create_line( + x0 - ws//2, y0, x0 + ws//2, y0, fill='black', width=2) + ) + self.canvas._whisker_cap2 = ( + self.canvas._tkcanvas.create_line( + x1 - ws//2, y1, x1 + ws//2, y1, fill='black', width=2) + ) + elif y1 == y0: # horizontal line + self.canvas._whisker_cap1 = ( + self.canvas._tkcanvas.create_line( + x0, y0 - ws//2, x0, y0 + ws//2, fill='black', width=2) + ) + self.canvas._whisker_cap2 = ( + self.canvas._tkcanvas.create_line( + x1, y1 - ws//2, x1, y1 + ws//2, fill='black', width=2) + ) + else: # Don't draw anything + self.canvas._tkcanvas.delete(self.canvas._whisker_line) + self.canvas._whisker_line = None + def remove_rubberband(self): if self.canvas._rubberband_rect_white: self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_white) @@ -773,6 +811,17 @@ def remove_rubberband(self): self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_black) self.canvas._rubberband_rect_black = None + def remove_whiskers(self): + if self.canvas._whisker_line: + self.canvas._tkcanvas.delete(self.canvas._whisker_line) + self.canvas._whisker_line = None + if self.canvas._whisker_cap1: + self.canvas._tkcanvas.delete(self.canvas._whisker_cap1) + self.canvas._whisker_cap1 = None + if self.canvas._whisker_cap2: + self.canvas._tkcanvas.delete(self.canvas._whisker_cap2) + self.canvas._whisker_cap2 = None + def _set_image_for_button(self, button): """ Set the image for a button based on its pixel size. From 5d1c8f8c783e3794368d5f189a2ffd5650d295aa Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Sun, 14 Sep 2025 23:32:58 +1000 Subject: [PATCH 07/22] Ensure whiskers are drawn over rubber band --- lib/matplotlib/backend_bases.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index f980e2623eb7..014e187e991e 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3241,14 +3241,17 @@ def drag_zoom(self, event): # Single-axis zooms by moving less than 10 pixels if (abs(event.x - start_xy[0]) < 10) and (abs(event.y - start_xy[1]) > 20): x1, x2 = ax.bbox.intervalx - self.draw_whiskers(event, start_xy[0], y1, start_xy[0], y2) + whisk = (start_xy[0], y1, start_xy[0], y2) elif (abs(event.y - start_xy[1]) < 10) and (abs(event.x - start_xy[0]) > 20): y1, y2 = ax.bbox.intervaly - self.draw_whiskers(event, x1, start_xy[1], x2, start_xy[1]) + whisk = (x1, start_xy[1], x2, start_xy[1], 20) else: + whisk = None self.remove_whiskers() self.draw_rubberband(event, x1, y1, x2, y2) + if whisk: + self.draw_whiskers(event, *whisk, ws=20) def release_zoom(self, event): """Callback for mouse button release in zoom to rect mode.""" From a7ea80c7461d3287a44b2c622086b58a99c9751f Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Mon, 15 Sep 2025 00:07:11 +1000 Subject: [PATCH 08/22] Whiskers for single axis zoom gtk and wx --- lib/matplotlib/backend_bases.py | 5 +- lib/matplotlib/backend_bases.pyi | 2 +- lib/matplotlib/backends/_backend_gtk.py | 11 ++++ lib/matplotlib/backends/backend_gtk3.py | 83 +++++++++++++++-------- lib/matplotlib/backends/backend_gtk4.py | 87 ++++++++++++++++--------- lib/matplotlib/backends/backend_wx.py | 29 +++++++++ 6 files changed, 156 insertions(+), 61 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 014e187e991e..42f7d56b446b 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2935,11 +2935,12 @@ def draw_rubberband(self, event, x0, y0, x1, y1): def remove_rubberband(self): """Remove the rubberband.""" - def draw_whiskers(self, event, x0, y0, x1, y1): + def draw_whiskers(self, event, x0, y0, x1, y1, ws): """ Draw line with whiskers to indicate single axis zoom We expect that ``x0 == x1`` or ``y0 == y1``. Else nothing will draw + `ws` is the whisker size in pixels. """ def remove_whiskers(self): @@ -3244,7 +3245,7 @@ def drag_zoom(self, event): whisk = (start_xy[0], y1, start_xy[0], y2) elif (abs(event.y - start_xy[1]) < 10) and (abs(event.x - start_xy[0]) > 20): y1, y2 = ax.bbox.intervaly - whisk = (x1, start_xy[1], x2, start_xy[1], 20) + whisk = (x1, start_xy[1], x2, start_xy[1]) else: whisk = None self.remove_whiskers() diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index ef1cb92a04b5..9932114a013e 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -458,7 +458,7 @@ class NavigationToolbar2: ) -> None: ... def remove_rubberband(self) -> None: ... def draw_whiskers( - self, event: Event, x0: float, y0: float, x1: float, y1: float + self, event: Event, x0: float, y0: float, x1: float, y1: float, ws: float ) -> None: ... def remove_whiskers(self) -> None: ... def home(self, *args) -> None: ... diff --git a/lib/matplotlib/backends/_backend_gtk.py b/lib/matplotlib/backends/_backend_gtk.py index ac443730e28a..d8ce3054c126 100644 --- a/lib/matplotlib/backends/_backend_gtk.py +++ b/lib/matplotlib/backends/_backend_gtk.py @@ -280,9 +280,20 @@ def draw_rubberband(self, event, x0, y0, x1, y1): rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)] self.canvas._draw_rubberband(rect) + def draw_whiskers(self, event, x0, y0, x1, y1, ws=20): + height = self.canvas.figure.bbox.height + y1 = height - y1 + y0 = height - y0 + x0, y0, x1, y1, ws = [int(val) for val in (x0, y0, x1, y1, ws)] + whisk = (x0, y0, x1, y1) + self.canvas._draw_whiskers(whisk, ws) + def remove_rubberband(self): self.canvas._draw_rubberband(None) + def remove_whiskers(self): + self.canvas._draw_whiskers(None) + def _update_buttons_checked(self): for name, active in [("Pan", "PAN"), ("Zoom", "ZOOM")]: button = self._gtk_ids.get(name) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 4e05119aa0f6..20e313a45b5d 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -68,6 +68,8 @@ def __init__(self, figure=None): self._idle_draw_id = 0 self._rubberband_rect = None + self._whiskers = None + self._whisker_size = 20 self.connect('scroll_event', self.scroll_event) self.connect('button_press_event', self.button_press_event) @@ -249,35 +251,60 @@ def _draw_rubberband(self, rect): # TODO: Only update the rubberband area. self.queue_draw() - def _post_draw(self, widget, ctx): - if self._rubberband_rect is None: - return + def _draw_whiskers(self, whisk, ws=20): + self._whiskers = whisk # x0, y0, x1, y1 + self._whisker_size = ws + self.queue_draw() - x0, y0, w, h = (dim / self.device_pixel_ratio - for dim in self._rubberband_rect) - x1 = x0 + w - y1 = y0 + h - - # Draw the lines from x0, y0 towards x1, y1 so that the - # dashes don't "jump" when moving the zoom box. - ctx.move_to(x0, y0) - ctx.line_to(x0, y1) - ctx.move_to(x0, y0) - ctx.line_to(x1, y0) - ctx.move_to(x0, y1) - ctx.line_to(x1, y1) - ctx.move_to(x1, y0) - ctx.line_to(x1, y1) - - ctx.set_antialias(1) - ctx.set_line_width(1) - ctx.set_dash((3, 3), 0) - ctx.set_source_rgb(0, 0, 0) - ctx.stroke_preserve() - - ctx.set_dash((3, 3), 3) - ctx.set_source_rgb(1, 1, 1) - ctx.stroke() + def _post_draw(self, widget, ctx): + if self._rubberband_rect: + + x0, y0, w, h = (dim / self.device_pixel_ratio + for dim in self._rubberband_rect) + x1 = x0 + w + y1 = y0 + h + + # Draw the lines from x0, y0 towards x1, y1 so that the + # dashes don't "jump" when moving the zoom box. + ctx.move_to(x0, y0) + ctx.line_to(x0, y1) + ctx.move_to(x0, y0) + ctx.line_to(x1, y0) + ctx.move_to(x0, y1) + ctx.line_to(x1, y1) + ctx.move_to(x1, y0) + ctx.line_to(x1, y1) + + ctx.set_antialias(1) + ctx.set_line_width(1) + ctx.set_dash((3, 3), 0) + ctx.set_source_rgb(0, 0, 0) + ctx.stroke_preserve() + + ctx.set_dash((3, 3), 3) + ctx.set_source_rgb(1, 1, 1) + ctx.stroke() + + if self._whiskers: + x0, y0, x1, y1 = (dim / self.device_pixel_ratio + for dim in self._whiskers) + ws = self._whisker_size / self.device_pixel_ratio + + ctx.set_antialias(1) + ctx.set_line_width(2) + ctx.set_source_rgb(0, 0, 0) + + # vertical line + ctx.move_to(x0, y0) + ctx.line_to(x0, y1) + # horizontal line + ctx.move_to(x0 - ws, y0) + ctx.line_to(x0 + ws, y0) + # horizontal line + ctx.move_to(x1 - ws, y1) + ctx.line_to(x1 + ws, y1) + + ctx.stroke() def on_draw_event(self, widget, ctx): # to be overwritten by GTK3Agg or GTK3Cairo diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index a45fa0bc490f..8298de07b3a1 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -53,6 +53,8 @@ def __init__(self, figure=None): self._idle_draw_id = 0 self._rubberband_rect = None + self._whiskers = None + self._whisker_size = 20 self.set_draw_func(self._draw_func) self.connect('resize', self.resize_event) @@ -269,41 +271,66 @@ def _draw_rubberband(self, rect): # TODO: Only update the rubberband area. self.queue_draw() + def _draw_whiskers(self, whisk, ws=20): + self._whiskers = whisk + self._whisker_size = ws + self.queue_draw() + def _draw_func(self, drawing_area, ctx, width, height): self.on_draw_event(self, ctx) self._post_draw(self, ctx) def _post_draw(self, widget, ctx): - if self._rubberband_rect is None: - return - - lw = 1 - dash = 3 - x0, y0, w, h = (dim / self.device_pixel_ratio - for dim in self._rubberband_rect) - x1 = x0 + w - y1 = y0 + h - - # Draw the lines from x0, y0 towards x1, y1 so that the - # dashes don't "jump" when moving the zoom box. - ctx.move_to(x0, y0) - ctx.line_to(x0, y1) - ctx.move_to(x0, y0) - ctx.line_to(x1, y0) - ctx.move_to(x0, y1) - ctx.line_to(x1, y1) - ctx.move_to(x1, y0) - ctx.line_to(x1, y1) - - ctx.set_antialias(1) - ctx.set_line_width(lw) - ctx.set_dash((dash, dash), 0) - ctx.set_source_rgb(0, 0, 0) - ctx.stroke_preserve() - - ctx.set_dash((dash, dash), dash) - ctx.set_source_rgb(1, 1, 1) - ctx.stroke() + if self._rubberband_rect: + + lw = 1 + dash = 3 + x0, y0, w, h = (dim / self.device_pixel_ratio + for dim in self._rubberband_rect) + x1 = x0 + w + y1 = y0 + h + + # Draw the lines from x0, y0 towards x1, y1 so that the + # dashes don't "jump" when moving the zoom box. + ctx.move_to(x0, y0) + ctx.line_to(x0, y1) + ctx.move_to(x0, y0) + ctx.line_to(x1, y0) + ctx.move_to(x0, y1) + ctx.line_to(x1, y1) + ctx.move_to(x1, y0) + ctx.line_to(x1, y1) + + ctx.set_antialias(1) + ctx.set_line_width(lw) + ctx.set_dash((dash, dash), 0) + ctx.set_source_rgb(0, 0, 0) + ctx.stroke_preserve() + + ctx.set_dash((dash, dash), dash) + ctx.set_source_rgb(1, 1, 1) + ctx.stroke() + + if self._whiskers: + x0, y0, x1, y1 = (dim / self.device_pixel_ratio + for dim in self._whiskers) + ws = self._whisker_size / self.device_pixel_ratio + + ctx.set_antialias(1) + ctx.set_line_width(2) + ctx.set_source_rgb(0, 0, 0) + + # vertical line + ctx.move_to(x0, y0) + ctx.line_to(x0, y1) + # horizontal line + ctx.move_to(x0 - ws, y0) + ctx.line_to(x0 + ws, y0) + # horizontal line + ctx.move_to(x1 - ws, y1) + ctx.line_to(x1 + ws, y1) + + ctx.stroke() def on_draw_event(self, widget, ctx): # to be overwritten by GTK4Agg or GTK4Cairo diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index f83a69d8361e..509fce048928 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -484,6 +484,9 @@ def __init__(self, parent, id, figure=None): self._rubberband_rect = None self._rubberband_pen_black = wx.Pen('BLACK', 1, wx.PENSTYLE_SHORT_DASH) self._rubberband_pen_white = wx.Pen('WHITE', 1, wx.PENSTYLE_SOLID) + self._whiskers = None + self._whiskers_size = 20 + self._whiskers_pen = wx.Pen('BLACK', 2, wx.PENSTYLE_SOLID) self.Bind(wx.EVT_SIZE, self._on_size) self.Bind(wx.EVT_PAINT, self._on_paint) @@ -616,6 +619,20 @@ def gui_repaint(self, drawDC=None): (x0, y0, x0, y1), (x0, y1, x1, y1)] drawDC.DrawLineList(rect, self._rubberband_pen_white) drawDC.DrawLineList(rect, self._rubberband_pen_black) + if self._whiskers is not None: + x0, y0, x1, y1 = map(round, self._whiskers) + lines = [(x0, y0, x1, y1)] + if x0 == x1: # vertical line + lines += [(x0 - self._whiskers_size, y0, x0 + self._whiskers_size, y0), + (x1 - self._whiskers_size, y1, x1 + self._whiskers_size, y1)] + elif y0 == y1: # horizontal line + lines += [(x0, y0 - self._whiskers_size, x0, y0 + self._whiskers_size), + (x1, y1 - self._whiskers_size, x1, y1 + self._whiskers_size)] + else: # Don't draw + lines = [] + + drawDC.DrawLineList(lines, self._whiskers_pen) + filetypes = { **FigureCanvasBase.filetypes, @@ -1174,10 +1191,22 @@ def draw_rubberband(self, event, x0, y0, x1, y1): x1/sf, (height - y1)/sf) self.canvas.Refresh() + def draw_whiskers(self, event, x0, y0, x1, y1, ws=20): + height = self.canvas.figure.bbox.height + sf = 1 if wx.Platform == '__WXMSW__' else self.canvas.GetDPIScaleFactor() + self.canvas._whiskers = (x0/sf, (height - y0)/sf, + x1/sf, (height - y1)/sf) + self.canvas._whiskers_size = int(ws/sf) + self.canvas.Refresh() + def remove_rubberband(self): self.canvas._rubberband_rect = None self.canvas.Refresh() + def remove_whiskers(self): + self.canvas._whiskers = None + self.canvas.Refresh() + def set_message(self, s): if self._coordinates: self._label_text.SetLabel(s) From 8dda19a57ea0a14b4ff5d26e4829e4019c00b7bc Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Mon, 15 Sep 2025 13:55:55 +1000 Subject: [PATCH 09/22] Update lib/matplotlib/backend_bases.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- 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 42f7d56b446b..49eb875f97b9 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2940,7 +2940,7 @@ def draw_whiskers(self, event, x0, y0, x1, y1, ws): Draw line with whiskers to indicate single axis zoom We expect that ``x0 == x1`` or ``y0 == y1``. Else nothing will draw - `ws` is the whisker size in pixels. + *ws* is the whisker size in pixels. """ def remove_whiskers(self): From b4cdbd3565ae24f3ba868f18f0d04476275f85b3 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Mon, 15 Sep 2025 14:52:24 +1000 Subject: [PATCH 10/22] MacOS whisker implementation --- lib/matplotlib/backends/backend_macosx.py | 6 ++ src/_macosx.m | 100 ++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 6ea437a90ca1..54b11e0d4202 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -128,9 +128,15 @@ def __init__(self, canvas): def draw_rubberband(self, event, x0, y0, x1, y1): self.canvas.set_rubberband(int(x0), int(y0), int(x1), int(y1)) + def draw_whiskers(self, event, x0, y0, x1, y1, ws=20): + self.canvas.set_whiskers(int(x0), int(y0), int(x1), int(y1), int(ws)) + def remove_rubberband(self): self.canvas.remove_rubberband() + def remove_whiskers(self): + self.canvas.remove_whiskers() + def save_figure(self, *args): directory = os.path.expanduser(mpl.rcParams['savefig.directory']) filename = _macosx.choose_save_file('Save the figure', diff --git a/src/_macosx.m b/src/_macosx.m index 1372157bc80d..a77d1ac16b8c 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -138,6 +138,7 @@ - (BOOL)closeButtonPressed; @interface View : NSView { PyObject* canvas; NSRect rubberband; + NSRect whiskers; @public double device_scale; } - (void)dealloc; @@ -162,7 +163,9 @@ - (void)otherMouseDown:(NSEvent*)event; - (void)otherMouseUp:(NSEvent*)event; - (void)otherMouseDragged:(NSEvent*)event; - (void)setRubberband:(NSRect)rect; +- (void)setWhiskers:(NSRect)rect; - (void)removeRubberband; +- (void)removeWhiskers; - (const char*)convertKeyEvent:(NSEvent*)event; - (void)keyDown:(NSEvent*)event; - (void)keyUp:(NSEvent*)event; @@ -475,6 +478,37 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) Py_RETURN_NONE; } +static PyObject* +FigureCanvas_set_whiskers(FigureCanvas* self, PyObject* args) +{ + View* view = self->view; + if (!view) { + PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); + return NULL; + } + int x0, y0, x1, y1, ws; + if (!PyArg_ParseTuple(args, "iiiii", &x0, &y0, &x1, &y1, &ws)) { + return NULL; + } + x0 /= view->device_scale; + x1 /= view->device_scale; + y0 /= view->device_scale; + y1 /= view->device_scale; + ws /= view->device_scale; + NSRect whiskers = NSZeroRect; + if (x0 == x1) { // vertical line + x0 -= ws/2; + whiskers = NSMakeRect(x0, y0 < y1 ? y0 : y1, + ws, abs(y1 - y0)); + } else if (y0 == y1) { // horizontal line + y0 -= ws/2; + whiskers = NSMakeRect(x0 < x1 ? x0 : x1, y0, + abs(x1 - x0), ws); + } + [view setWhiskers: whiskers]; + Py_RETURN_NONE; +} + static PyObject* FigureCanvas_remove_rubberband(FigureCanvas* self) { @@ -482,6 +516,13 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) Py_RETURN_NONE; } +static PyObject* +FigureCanvas_remove_whiskers(FigureCanvas* self) +{ + [self->view removeWhiskers]; + Py_RETURN_NONE; +} + static PyObject* FigureCanvas__start_event_loop(FigureCanvas* self, PyObject* args, PyObject* keywords) { @@ -556,10 +597,18 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) (PyCFunction)FigureCanvas_set_rubberband, METH_VARARGS, PyDoc_STR("Specify a new rubberband rectangle and invalidate it.")}, + {"set_whiskers", + (PyCFunction)FigureCanvas_set_whiskers, + METH_VARARGS, + PyDoc_STR("Specify new whiskers and invalidate them.")}, {"remove_rubberband", (PyCFunction)FigureCanvas_remove_rubberband, METH_NOARGS, PyDoc_STR("Remove the current rubberband rectangle.")}, + {"remove_whiskers", + (PyCFunction)FigureCanvas_remove_whiskers, + METH_NOARGS, + PyDoc_STR("Remove the current whiskers.")}, {"_start_event_loop", (PyCFunction)FigureCanvas__start_event_loop, METH_KEYWORDS | METH_VARARGS, @@ -1283,6 +1332,45 @@ -(void)drawRect:(NSRect)rect [[NSColor blackColor] setStroke]; [black_path stroke]; } + if (!NSIsEmptyRect(whiskers)) { + // Whiskers are stored as a rectangle. Draw a center line along the rectangle's + // long axis and short perpendicular caps at each end. The rectangle is + // constructed so its longer side corresponds to the zoom direction. + if (whiskers.size.width < whiskers.size.height) { // Vertical whiskers + int ws = whiskers.size.width; + int x = whiskers.origin.x + ws/2; + int y1 = whiskers.origin.y; + int y2 = whiskers.origin.y + whiskers.size.height; + // Draw top and bottom edges + NSBezierPath *path = [NSBezierPath bezierPath]; + [path setLineWidth: 2.0]; + [[NSColor blackColor] setStroke]; + [path moveToPoint: NSMakePoint(x, y1)]; + [path lineToPoint: NSMakePoint(x, y2)]; + [path moveToPoint: NSMakePoint(x - ws/2, y1)]; + [path lineToPoint: NSMakePoint(x + ws/2, y1)]; + [path moveToPoint: NSMakePoint(x - ws/2, y2)]; + [path lineToPoint: NSMakePoint(x + ws/2, y2)]; + [path stroke]; + } + if (whiskers.size.width >= whiskers.size.height) { // Horizontal whiskers + int hs = whiskers.size.height; + int y = whiskers.origin.y + hs/2; + int x1 = whiskers.origin.x; + int x2 = whiskers.origin.x + whiskers.size.width; + // Draw left and right edges + NSBezierPath *path = [NSBezierPath bezierPath]; + [path setLineWidth: 2.0]; + [[NSColor blackColor] setStroke]; + [path moveToPoint: NSMakePoint(x1, y)]; + [path lineToPoint: NSMakePoint(x2, y)]; + [path moveToPoint: NSMakePoint(x1, y - hs/2)]; + [path lineToPoint: NSMakePoint(x1, y + hs/2)]; + [path moveToPoint: NSMakePoint(x2, y - hs/2)]; + [path lineToPoint: NSMakePoint(x2, y + hs/2)]; + [path stroke]; + } + } exit: Py_XDECREF(renderer_buffer); @@ -1507,6 +1595,12 @@ - (void)setRubberband:(NSRect)rect rubberband = rect; } +- (void)setWhiskers:(NSRect)rect +{ + // Redrawing handled by setRubberband + whiskers = rect; +} + - (void)removeRubberband { if (NSIsEmptyRect(rubberband)) { return; } @@ -1514,6 +1608,12 @@ - (void)removeRubberband rubberband = NSZeroRect; } +- (void)removeWhiskers +{ + if (NSIsEmptyRect(whiskers)) { return; } + whiskers = NSZeroRect; +} + - (const char*)convertKeyEvent:(NSEvent*)event { NSMutableString* returnkey = [NSMutableString string]; From 938330899841ec5102775750e98e50411780c6df Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Mon, 15 Sep 2025 14:57:43 +1000 Subject: [PATCH 11/22] Increase whisker size slightly --- lib/matplotlib/backend_bases.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 49eb875f97b9..ac6498304e96 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3239,11 +3239,11 @@ def drag_zoom(self, event): elif key == "y": x1, x2 = ax.bbox.intervalx - # Single-axis zooms by moving less than 10 pixels - if (abs(event.x - start_xy[0]) < 10) and (abs(event.y - start_xy[1]) > 20): + # Single-axis zooms by moving less than 15 pixels + if (abs(event.x - start_xy[0]) < 15) and (abs(event.y - start_xy[1]) > 30): x1, x2 = ax.bbox.intervalx whisk = (start_xy[0], y1, start_xy[0], y2) - elif (abs(event.y - start_xy[1]) < 10) and (abs(event.x - start_xy[0]) > 20): + elif (abs(event.y - start_xy[1]) < 15) and (abs(event.x - start_xy[0]) > 30): y1, y2 = ax.bbox.intervaly whisk = (x1, start_xy[1], x2, start_xy[1]) else: @@ -3252,7 +3252,7 @@ def drag_zoom(self, event): self.draw_rubberband(event, x1, y1, x2, y2) if whisk: - self.draw_whiskers(event, *whisk, ws=20) + self.draw_whiskers(event, *whisk, ws=30) def release_zoom(self, event): """Callback for mouse button release in zoom to rect mode.""" @@ -3284,9 +3284,9 @@ def release_zoom(self, event): for prev in self._zoom_info.axes[:i]) # Handle release of single axis zooms end_x, end_y = event.x, event.y - if (abs(end_x - start_x) < 10) and (abs(end_y - start_y) > 20): + if (abs(end_x - start_x) < 15) and (abs(end_y - start_y) > 30): start_x, end_x = ax.bbox.intervalx - if (abs(end_y - start_y) < 10) and (abs(end_x - start_x) > 20): + if (abs(end_y - start_y) < 15) and (abs(end_x - start_x) > 30): start_y, end_y = ax.bbox.intervaly ax._set_view_from_bbox( (start_x, start_y, end_x, end_y), From 4f6a4eb9bb254b898da30fcf3c42537cad527d28 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Mon, 15 Sep 2025 15:22:24 +1000 Subject: [PATCH 12/22] WebAgg Implementation --- .../backends/backend_webagg_core.py | 6 ++++ lib/matplotlib/backends/web_backend/js/mpl.js | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/lib/matplotlib/backends/backend_webagg_core.py b/lib/matplotlib/backends/backend_webagg_core.py index 4afe088db8d1..046f2869600b 100644 --- a/lib/matplotlib/backends/backend_webagg_core.py +++ b/lib/matplotlib/backends/backend_webagg_core.py @@ -433,9 +433,15 @@ def set_message(self, message): def draw_rubberband(self, event, x0, y0, x1, y1): self.canvas.send_event("rubberband", x0=x0, y0=y0, x1=x1, y1=y1) + def draw_whiskers(self, event, x0, y0, x1, y1, ws=20): + self.canvas.send_event("whiskers", x0=x0, y0=y0, x1=x1, y1=y1, ws=ws) + def remove_rubberband(self): self.canvas.send_event("rubberband", x0=-1, y0=-1, x1=-1, y1=-1) + def remove_whiskers(self): + self.canvas.send_event("whiskers", x0=-1, y0=-1, x1=-1, y1=-1, ws=20) + def save_figure(self, *args): """Save the current figure.""" self.canvas.send_event('save') diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index f2bfc43bd0e4..e471748f63b9 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -474,6 +474,9 @@ mpl.figure.prototype.handle_rubberband = function (fig, msg) { var width = Math.abs(x1 - x0); var height = Math.abs(y1 - y0); + fig.rubberband_context.setLineDash([6]); + fig.rubberband_context.strokeStyle = '#000000'; + fig.rubberband_context.lineWidth = 1; fig.rubberband_context.clearRect( 0, 0, @@ -484,6 +487,36 @@ mpl.figure.prototype.handle_rubberband = function (fig, msg) { fig.rubberband_context.strokeRect(min_x, min_y, width, height); }; +mpl.figure.prototype.handle_whiskers = function (fig, msg) { + var x0 = msg['x0'] / fig.ratio; + var y0 = (fig.canvas.height - msg['y0']) / fig.ratio; + var x1 = msg['x1'] / fig.ratio; + var y1 = (fig.canvas.height - msg['y1']) / fig.ratio; + var ws = msg['ws'] / fig.ratio; + + // Draw line + fig.rubberband_context.setLineDash([]); + fig.rubberband_context.strokeStyle = '#000000'; + fig.rubberband_context.lineWidth = 2; + fig.rubberband_context.beginPath(); + if (x0 == x1) { // Vertical line + fig.rubberband_context.moveTo(x0, y0); + fig.rubberband_context.lineTo(x1, y1); + fig.rubberband_context.moveTo(x0 - ws/2, y0); + fig.rubberband_context.lineTo(x0 + ws/2, y0); + fig.rubberband_context.moveTo(x1 - ws/2, y1); + fig.rubberband_context.lineTo(x1 + ws/2, y1); + } else if (y0 == y1) { // Horizontal line + fig.rubberband_context.moveTo(x0, y0); + fig.rubberband_context.lineTo(x1, y1); + fig.rubberband_context.moveTo(x0, y0 - ws/2); + fig.rubberband_context.lineTo(x0, y0 + ws/2); + fig.rubberband_context.moveTo(x1, y1 - ws/2); + fig.rubberband_context.lineTo(x1, y1 + ws/2); + } + fig.rubberband_context.stroke(); +}; + mpl.figure.prototype.handle_figure_label = function (fig, msg) { // Updates the figure title. fig.header.textContent = msg['label']; From 847ece6b2194fb5b709fd15f7f8f179abe0fa487 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Mon, 15 Sep 2025 18:31:13 +1000 Subject: [PATCH 13/22] Minor bufixes --- lib/matplotlib/backends/backend_qt.py | 4 ++-- lib/matplotlib/backends/backend_wx.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 6ea759c96e9a..a6c7db4ab786 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -853,12 +853,12 @@ def draw_rubberband(self, event, x0, y0, x1, y1): rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)] self.canvas.drawRectangle(rect) - def draw_whiskers(self, event, x0, y0, x1, y1): + def draw_whiskers(self, event, x0, y0, x1, y1, ws=20): height = self.canvas.figure.bbox.height y1 = height - y1 y0 = height - y0 whisk = [int(val) for val in (x0, y0, x1, y1)] - self.canvas.drawWhiskers(whisk) + self.canvas.drawWhiskers(whisk, ws) def remove_rubberband(self): self.canvas.drawRectangle(None) diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 509fce048928..3dc1d6ad68d4 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -623,11 +623,15 @@ def gui_repaint(self, drawDC=None): x0, y0, x1, y1 = map(round, self._whiskers) lines = [(x0, y0, x1, y1)] if x0 == x1: # vertical line - lines += [(x0 - self._whiskers_size, y0, x0 + self._whiskers_size, y0), - (x1 - self._whiskers_size, y1, x1 + self._whiskers_size, y1)] + lines += [(x0 - self._whiskers_size//2, y0, + x0 + self._whiskers_size//2, y0), + (x1 - self._whiskers_size//2, y1, + x1 + self._whiskers_size//2, y1)] elif y0 == y1: # horizontal line - lines += [(x0, y0 - self._whiskers_size, x0, y0 + self._whiskers_size), - (x1, y1 - self._whiskers_size, x1, y1 + self._whiskers_size)] + lines += [(x0, y0 - self._whiskers_size//2, x0, + y0 + self._whiskers_size//2), + (x1, y1 - self._whiskers_size//2, x1, + y1 + self._whiskers_size//2)] else: # Don't draw lines = [] From 8503cf17b1f1229aeda4d6527bfede611b8d3372 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Mon, 15 Sep 2025 18:33:25 +1000 Subject: [PATCH 14/22] GTK fixes --- lib/matplotlib/backends/backend_gtk3.py | 21 +++++++++++++-------- lib/matplotlib/backends/backend_gtk4.py | 22 ++++++++++++++-------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 20e313a45b5d..cf9a777f2306 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -292,17 +292,22 @@ def _post_draw(self, widget, ctx): ctx.set_antialias(1) ctx.set_line_width(2) + ctx.set_dash([], 0) ctx.set_source_rgb(0, 0, 0) - # vertical line + # main line ctx.move_to(x0, y0) - ctx.line_to(x0, y1) - # horizontal line - ctx.move_to(x0 - ws, y0) - ctx.line_to(x0 + ws, y0) - # horizontal line - ctx.move_to(x1 - ws, y1) - ctx.line_to(x1 + ws, y1) + ctx.line_to(x1, y1) + if x0 == x1: # vertical line + ctx.move_to(x0 - ws//2, y0) + ctx.line_to(x0 + ws//2, y0) + ctx.move_to(x1 - ws//2, y1) + ctx.line_to(x1 + ws//2, y1) + if y0 == y1: # horizontal line + ctx.move_to(x0, y0 - ws//2) + ctx.line_to(x0, y0 + ws//2) + ctx.move_to(x1, y1 - ws//2) + ctx.line_to(x1, y1 + ws//2) ctx.stroke() diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 8298de07b3a1..d0041de9f16b 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -318,17 +318,23 @@ def _post_draw(self, widget, ctx): ctx.set_antialias(1) ctx.set_line_width(2) + ctx.set_dash([], 0) ctx.set_source_rgb(0, 0, 0) - # vertical line + # main line ctx.move_to(x0, y0) - ctx.line_to(x0, y1) - # horizontal line - ctx.move_to(x0 - ws, y0) - ctx.line_to(x0 + ws, y0) - # horizontal line - ctx.move_to(x1 - ws, y1) - ctx.line_to(x1 + ws, y1) + ctx.line_to(x1, y1) + + if x0 == x1: # vertical line + ctx.move_to(x0 - ws//2, y0) + ctx.line_to(x0 + ws//2, y0) + ctx.move_to(x1 - ws//2, y1) + ctx.line_to(x1 + ws//2, y1) + if y0 == y1: # horizontal line + ctx.move_to(x0, y0 - ws//2) + ctx.line_to(x0, y0 + ws//2) + ctx.move_to(x1, y1 - ws//2) + ctx.line_to(x1, y1 + ws//2) ctx.stroke() From 63e89566762f2b36eda348ca50af13b36eb1ae73 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Wed, 19 Aug 2026 17:18:12 +1000 Subject: [PATCH 15/22] Fix wx backend lint --- lib/matplotlib/backends/backend_wx.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 50e336e0c6f3..4796090fd90d 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -637,7 +637,6 @@ def gui_repaint(self, drawDC=None): drawDC.DrawLineList(lines, self._whiskers_pen) - filetypes = { **FigureCanvasBase.filetypes, 'bmp': 'Windows bitmap', From f945db3cb11ef7257edd7fadd6a5c96b9128180c Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Wed, 19 Aug 2026 17:34:40 +1000 Subject: [PATCH 16/22] Keep zoom whiskers optional for backends --- lib/matplotlib/backend_bases.py | 4 ++++ lib/matplotlib/tests/test_backend_bases.py | 24 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index c391b6c310f0..b13c2d58a5ba 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2942,6 +2942,10 @@ class NavigationToolbar2: :meth:`draw_rubberband` (optional) Draw the zoom to rect "rubberband" rectangle. + :meth:`draw_whiskers` (optional) + Draw the single-axis zoom indicator. Backends that do not override + this method retain the standard rubberband without an indicator. + :meth:`set_message` (optional) Display message. diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 09b803ce58c6..0928022465a1 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -210,6 +210,30 @@ def test_interactive_zoom(): assert not ax.get_autoscalex_on() and not ax.get_autoscaley_on() +def test_interactive_zoom_without_whisker_support(): + fig, ax = plt.subplots() + ax.set(xlim=(0, 10), ylim=(0, 20)) + fig.canvas.draw() + + start = ax.transData.transform((5, 5)).astype(int) + stop = start + (0, 50) + button = MouseButton.LEFT + start_event = MouseEvent("button_press_event", fig.canvas, *start, button) + drag_event = MouseEvent( + "motion_notify_event", fig.canvas, *stop, button, buttons={button}) + stop_event = MouseEvent("button_release_event", fig.canvas, *stop, button) + + # The base toolbar's optional whisker methods are no-ops. + tb = NavigationToolbar2(fig.canvas) + tb.zoom() + tb.press_zoom(start_event) + tb.drag_zoom(drag_event) + tb.release_zoom(stop_event) + + assert ax.get_xlim() == pytest.approx((0, 10)) + assert ax.get_ylim() != pytest.approx((0, 20)) + + def test_widgetlock_zoompan(): fig, ax = plt.subplots() ax.plot([0, 1], [0, 1]) From 7b452a866d1a219e3844e671b818f942a83c42a8 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Wed, 19 Aug 2026 17:36:03 +1000 Subject: [PATCH 17/22] Gate WebAgg zoom whiskers by client support --- lib/matplotlib/backends/backend_nbagg.py | 8 +++++--- lib/matplotlib/backends/backend_webagg.py | 8 +++++--- lib/matplotlib/backends/backend_webagg_core.py | 4 +++- lib/matplotlib/backends/web_backend/js/mpl.js | 1 + lib/matplotlib/tests/test_backend_webagg.py | 18 +++++++++++++++++- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/backends/backend_nbagg.py b/lib/matplotlib/backends/backend_nbagg.py index 3ffec0910d79..ee9eb8119c40 100644 --- a/lib/matplotlib/backends/backend_nbagg.py +++ b/lib/matplotlib/backends/backend_nbagg.py @@ -173,6 +173,7 @@ class CommSocket: """ def __init__(self, manager): self.supports_binary = None + self.supports_zoom_whiskers = False self.manager = manager self.uuid = str(uuid.uuid4()) # Publish an output area with a unique ID. The javascript can then @@ -223,9 +224,8 @@ def send_binary(self, blob): self.comm.send({'data': data_uri}) def on_message(self, message): - # The 'supports_binary' message is relevant to the - # websocket itself. The other messages get passed along - # to matplotlib as-is. + # Capability messages are relevant to the websocket itself. The + # other messages get passed along to matplotlib as-is. # Every message has a "type" and a "figure_id". message = json.loads(message['content']['data']) @@ -234,6 +234,8 @@ def on_message(self, message): self.manager.clearup_closed() elif message['type'] == 'supports_binary': self.supports_binary = message['value'] + elif message['type'] == 'supports_zoom_whiskers': + self.supports_zoom_whiskers = message['value'] else: self.manager.handle_json(message) diff --git a/lib/matplotlib/backends/backend_webagg.py b/lib/matplotlib/backends/backend_webagg.py index e4808e8d0d32..5aa962e13216 100644 --- a/lib/matplotlib/backends/backend_webagg.py +++ b/lib/matplotlib/backends/backend_webagg.py @@ -127,6 +127,7 @@ def get(self, fignum, fmt): class WebSocket(tornado.websocket.WebSocketHandler): supports_binary = True + supports_zoom_whiskers = False def open(self, fignum): self.fignum = int(fignum) @@ -140,11 +141,12 @@ def on_close(self): def on_message(self, message): message = json.loads(message) - # The 'supports_binary' message is on a client-by-client - # basis. The others affect the (shared) canvas as a - # whole. + # Capability messages are on a client-by-client basis. The + # others affect the (shared) canvas as a whole. if message['type'] == 'supports_binary': self.supports_binary = message['value'] + elif message['type'] == 'supports_zoom_whiskers': + self.supports_zoom_whiskers = message['value'] else: manager = Gcf.get_fig_manager(self.fignum) # It is possible for a figure to be closed, diff --git a/lib/matplotlib/backends/backend_webagg_core.py b/lib/matplotlib/backends/backend_webagg_core.py index 096796a5b6d8..e706d8147505 100644 --- a/lib/matplotlib/backends/backend_webagg_core.py +++ b/lib/matplotlib/backends/backend_webagg_core.py @@ -552,7 +552,9 @@ def get_static_file_path(cls): def _send_event(self, event_type, **kwargs): payload = {'type': event_type, **kwargs} for s in self.web_sockets: - s.send_json(payload) + if (event_type != "whiskers" + or getattr(s, "supports_zoom_whiskers", False)): + s.send_json(payload) @_Backend.export diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index 34cf288b40fd..8c50f0aa61ad 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -63,6 +63,7 @@ mpl.figure = function (figure_id, websocket, ondownload, parent_element) { this.ws.onopen = function () { fig.send_message('supports_binary', { value: fig.supports_binary }); + fig.send_message('supports_zoom_whiskers', { value: true }); fig.send_message('send_image_mode', {}); fig.send_message('set_device_pixel_ratio', { device_pixel_ratio: fig.ratio, diff --git a/lib/matplotlib/tests/test_backend_webagg.py b/lib/matplotlib/tests/test_backend_webagg.py index c63534ad20e3..459348ae9ce5 100644 --- a/lib/matplotlib/tests/test_backend_webagg.py +++ b/lib/matplotlib/tests/test_backend_webagg.py @@ -6,7 +6,7 @@ import matplotlib.backends.backend_webagg_core from matplotlib.backends.backend_webagg_core import ( - FigureCanvasWebAggCore, NavigationToolbar2WebAgg, + FigureCanvasWebAggCore, FigureManagerWebAgg, NavigationToolbar2WebAgg, ) from matplotlib.testing import subprocess_run_for_testing @@ -37,6 +37,22 @@ def test_webagg_core_no_toolbar(): assert fm._toolbar2_class is None +def test_zoom_whiskers_require_client_support(): + supported = MagicMock() + supported.supports_zoom_whiskers = True + unsupported = MagicMock(spec=["send_json"]) + manager = FigureManagerWebAgg.__new__(FigureManagerWebAgg) + manager.web_sockets = {supported, unsupported} + + manager._send_event("whiskers", x0=0, y0=0, x1=1, y1=1, ws=20) + supported.send_json.assert_called_once() + unsupported.send_json.assert_not_called() + + manager._send_event("rubberband", x0=0, y0=0, x1=1, y1=1) + assert supported.send_json.call_count == 2 + unsupported.send_json.assert_called_once() + + def test_toolbar_button_dispatch_allowlist(): """Only declared toolbar items should be dispatched.""" fig = MagicMock() From ffbbe5841fc664d415162b00f91fe738596699c1 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Wed, 19 Aug 2026 17:37:19 +1000 Subject: [PATCH 18/22] Document optional zoom whisker API --- doc/api/next_api_changes/development/30554_REC.rst | 10 ++++++++++ doc/release/next_whats_new/single_axis_zoom.rst | 3 ++- galleries/users_explain/figure/backends.rst | 6 ++++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 doc/api/next_api_changes/development/30554_REC.rst diff --git a/doc/api/next_api_changes/development/30554_REC.rst b/doc/api/next_api_changes/development/30554_REC.rst new file mode 100644 index 000000000000..626ab722cb33 --- /dev/null +++ b/doc/api/next_api_changes/development/30554_REC.rst @@ -0,0 +1,10 @@ +Single-axis zoom indicators are optional for backends +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`NavigationToolbar2.draw_whiskers` and `NavigationToolbar2.remove_whiskers` are +optional backend API methods for drawing the single-axis zoom indicator. Their +default implementations do nothing, so third-party backends continue to zoom +without displaying the indicator. + +WebAgg clients must advertise the ``supports_zoom_whiskers`` capability to +receive ``whiskers`` events. diff --git a/doc/release/next_whats_new/single_axis_zoom.rst b/doc/release/next_whats_new/single_axis_zoom.rst index 47af9de9eb41..3d0c76f52f49 100644 --- a/doc/release/next_whats_new/single_axis_zoom.rst +++ b/doc/release/next_whats_new/single_axis_zoom.rst @@ -2,4 +2,5 @@ Single Axis Zoom ---------------- Zooming in a single axis (horizontal or vertical) can be done by dragging the -zoom rectangle in one direction only. +zoom rectangle in one direction only. Backends may optionally mark the active +single-axis region; other backends retain the standard zoom rectangle. diff --git a/galleries/users_explain/figure/backends.rst b/galleries/users_explain/figure/backends.rst index 98cf6740cf21..92efd61c27d8 100644 --- a/galleries/users_explain/figure/backends.rst +++ b/galleries/users_explain/figure/backends.rst @@ -279,6 +279,12 @@ The following backend API versions exist *hatchcolor*. The presence of the parameter is inferred by introspection, so that matplotlib 3.11+ will still work with backends implementing API version 1.0. + * - 1.2 + - Matplotlib 3.12 + - `NavigationToolbar2.draw_whiskers` and + `NavigationToolbar2.remove_whiskers` optionally draw the single-axis zoom + indicator. Backends implementing older versions continue to work without the + indicator. There is currently no plan to remove support for older API versions. From 0b45eeeacfdc0ed56fd797c62f526970b7a91a7d Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Wed, 19 Aug 2026 17:41:37 +1000 Subject: [PATCH 19/22] Make zoom whiskers visible on any background --- lib/matplotlib/backends/_backend_tk.py | 53 +++++-------------- lib/matplotlib/backends/backend_gtk3.py | 7 ++- lib/matplotlib/backends/backend_gtk4.py | 7 ++- lib/matplotlib/backends/backend_qt.py | 44 ++++++--------- lib/matplotlib/backends/backend_wx.py | 6 ++- lib/matplotlib/backends/web_backend/js/mpl.js | 7 ++- lib/matplotlib/tests/test_backend_tk.py | 21 +++++++- src/_macosx.m | 20 +++---- 8 files changed, 77 insertions(+), 88 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index c032e238758d..1394711af1be 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -259,9 +259,6 @@ def filter_destroy(event): self._rubberband_rect_black = None self._rubberband_rect_white = None - self._whisker_line = None - self._whisker_cap1 = None - self._whisker_cap2 = None def _update_device_pixel_ratio(self, event=None): ratio = None @@ -662,6 +659,8 @@ def full_screen_toggle(self): class NavigationToolbar2Tk(NavigationToolbar2, tk.Frame): + _whiskers_tag = "_matplotlib_zoom_whiskers" + def __init__(self, canvas, window=None, *, pack_toolbar=True): """ Parameters @@ -789,39 +788,23 @@ def draw_rubberband(self, event, x0, y0, x1, y1): x0, y0, x1, y1, outline='white', dash=(3, 3))) def draw_whiskers(self, event, x0, y0, x1, y1, ws=20): - if self.canvas._whisker_line: - self.canvas._tkcanvas.delete(self.canvas._whisker_line) - if self.canvas._whisker_cap1: - self.canvas._tkcanvas.delete(self.canvas._whisker_cap1) - if self.canvas._whisker_cap2: - self.canvas._tkcanvas.delete(self.canvas._whisker_cap2) + self.remove_whiskers() height = self.canvas.figure.bbox.height y0 = height - y0 y1 = height - y1 - self.canvas._whisker_line = ( - self.canvas._tkcanvas.create_line(x0, y0, x1, y1, fill='black', width=2) - ) + lines = [(x0, y0, x1, y1)] if x1 == x0: # vertical line - self.canvas._whisker_cap1 = ( - self.canvas._tkcanvas.create_line( - x0 - ws//2, y0, x0 + ws//2, y0, fill='black', width=2) - ) - self.canvas._whisker_cap2 = ( - self.canvas._tkcanvas.create_line( - x1 - ws//2, y1, x1 + ws//2, y1, fill='black', width=2) - ) + lines += [(x0 - ws//2, y0, x0 + ws//2, y0), + (x1 - ws//2, y1, x1 + ws//2, y1)] elif y1 == y0: # horizontal line - self.canvas._whisker_cap1 = ( - self.canvas._tkcanvas.create_line( - x0, y0 - ws//2, x0, y0 + ws//2, fill='black', width=2) - ) - self.canvas._whisker_cap2 = ( + lines += [(x0, y0 - ws//2, x0, y0 + ws//2), + (x1, y1 - ws//2, x1, y1 + ws//2)] + else: + return + for color, width in [("white", 3), ("black", 1)]: + for line in lines: self.canvas._tkcanvas.create_line( - x1, y1 - ws//2, x1, y1 + ws//2, fill='black', width=2) - ) - else: # Don't draw anything - self.canvas._tkcanvas.delete(self.canvas._whisker_line) - self.canvas._whisker_line = None + *line, fill=color, width=width, tags=self._whiskers_tag) def remove_rubberband(self): if self.canvas._rubberband_rect_white: @@ -832,15 +815,7 @@ def remove_rubberband(self): self.canvas._rubberband_rect_black = None def remove_whiskers(self): - if self.canvas._whisker_line: - self.canvas._tkcanvas.delete(self.canvas._whisker_line) - self.canvas._whisker_line = None - if self.canvas._whisker_cap1: - self.canvas._tkcanvas.delete(self.canvas._whisker_cap1) - self.canvas._whisker_cap1 = None - if self.canvas._whisker_cap2: - self.canvas._tkcanvas.delete(self.canvas._whisker_cap2) - self.canvas._whisker_cap2 = None + self.canvas._tkcanvas.delete(self._whiskers_tag) def _set_image_for_button(self, button): """ diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 63402a8ac534..21fff45d2af0 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -286,9 +286,7 @@ def _post_draw(self, widget, ctx): ws = self._whisker_size / self.device_pixel_ratio ctx.set_antialias(1) - ctx.set_line_width(2) ctx.set_dash([], 0) - ctx.set_source_rgb(0, 0, 0) # main line ctx.move_to(x0, y0) @@ -304,6 +302,11 @@ def _post_draw(self, widget, ctx): ctx.move_to(x1, y1 - ws//2) ctx.line_to(x1, y1 + ws//2) + ctx.set_line_width(3) + ctx.set_source_rgb(1, 1, 1) + ctx.stroke_preserve() + ctx.set_line_width(1) + ctx.set_source_rgb(0, 0, 0) ctx.stroke() def on_draw_event(self, widget, ctx): diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 039ccc621f19..4a6c0a67535d 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -313,9 +313,7 @@ def _post_draw(self, widget, ctx): ws = self._whisker_size / self.device_pixel_ratio ctx.set_antialias(1) - ctx.set_line_width(2) ctx.set_dash([], 0) - ctx.set_source_rgb(0, 0, 0) # main line ctx.move_to(x0, y0) @@ -332,6 +330,11 @@ def _post_draw(self, widget, ctx): ctx.move_to(x1, y1 - ws//2) ctx.line_to(x1, y1 + ws//2) + ctx.set_line_width(3) + ctx.set_source_rgb(1, 1, 1) + ctx.stroke_preserve() + ctx.set_line_width(1) + ctx.set_source_rgb(0, 0, 0) ctx.stroke() def on_draw_event(self, widget, ctx): diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 463c9e2ba359..0ac67d83004e 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -530,39 +530,27 @@ def _draw_idle(self): traceback.print_exc() def drawWhiskers(self, line, ws=20): - # Draw single axis zoom whiskers - if line is None: - def _draw_whisker_callback(painter): - return - else: + lines = [] + if line is not None: x0, y0, x1, y1 = [int(pt / self.device_pixel_ratio) for pt in line] ws = int(ws / self.device_pixel_ratio) + lines = [(x0, y0, x1, y1)] if x0 == x1: # vertical line - def _draw_whisker_callback(painter): - pen = QtGui.QPen( - QtGui.QColor("black"), - 2 / self.device_pixel_ratio - ) - - painter.setPen(pen) - painter.drawLine(x0 - ws // 2, y0, x0 + ws // 2, y0) - painter.drawLine(x0 - ws // 2, y1, x0 + ws // 2, y1) - painter.drawLine(x0, y0, x0, y1) - + lines += [(x0 - ws // 2, y0, x0 + ws // 2, y0), + (x1 - ws // 2, y1, x1 + ws // 2, y1)] elif y0 == y1: # horizontal line - def _draw_whisker_callback(painter): - pen = QtGui.QPen( - QtGui.QColor("black"), - 2 / self.device_pixel_ratio - ) - - painter.setPen(pen) - painter.drawLine(x0, y0 - ws // 2, x0, y0 + ws // 2) - painter.drawLine(x1, y0 - ws // 2, x1, y0 + ws // 2) - painter.drawLine(x0, y0, x1, y0) + lines += [(x0, y0 - ws // 2, x0, y0 + ws // 2), + (x1, y1 - ws // 2, x1, y1 + ws // 2)] else: - def _draw_whisker_callback(painter): - return + lines = [] + + def _draw_whisker_callback(painter): + for color, width in [("white", 3), ("black", 1)]: + painter.setPen(QtGui.QPen( + QtGui.QColor(color), width / self.device_pixel_ratio)) + for whisker_line in lines: + painter.drawLine(*whisker_line) + self._draw_whisker_callback = _draw_whisker_callback self.update() diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 4796090fd90d..4a582b2f0571 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -486,7 +486,8 @@ def __init__(self, parent, id, figure=None): self._rubberband_pen_white = wx.Pen('WHITE', 1, wx.PENSTYLE_SOLID) self._whiskers = None self._whiskers_size = 20 - self._whiskers_pen = wx.Pen('BLACK', 2, wx.PENSTYLE_SOLID) + self._whiskers_pen_white = wx.Pen('WHITE', 3, wx.PENSTYLE_SOLID) + self._whiskers_pen_black = wx.Pen('BLACK', 1, wx.PENSTYLE_SOLID) self.Bind(wx.EVT_SIZE, self._on_size) self.Bind(wx.EVT_PAINT, self._on_paint) @@ -635,7 +636,8 @@ def gui_repaint(self, drawDC=None): else: # Don't draw lines = [] - drawDC.DrawLineList(lines, self._whiskers_pen) + drawDC.DrawLineList(lines, self._whiskers_pen_white) + drawDC.DrawLineList(lines, self._whiskers_pen_black) filetypes = { **FigureCanvasBase.filetypes, diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index 8c50f0aa61ad..ee598d7b69b5 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -500,8 +500,6 @@ mpl.figure.prototype.handle_whiskers = function (fig, msg) { // Draw line fig.rubberband_context.setLineDash([]); - fig.rubberband_context.strokeStyle = '#000000'; - fig.rubberband_context.lineWidth = 2; fig.rubberband_context.beginPath(); if (x0 == x1) { // Vertical line fig.rubberband_context.moveTo(x0, y0); @@ -518,6 +516,11 @@ mpl.figure.prototype.handle_whiskers = function (fig, msg) { fig.rubberband_context.moveTo(x1, y1 - ws/2); fig.rubberband_context.lineTo(x1, y1 + ws/2); } + fig.rubberband_context.strokeStyle = '#ffffff'; + fig.rubberband_context.lineWidth = 3; + fig.rubberband_context.stroke(); + fig.rubberband_context.strokeStyle = '#000000'; + fig.rubberband_context.lineWidth = 1; fig.rubberband_context.stroke(); }; diff --git a/lib/matplotlib/tests/test_backend_tk.py b/lib/matplotlib/tests/test_backend_tk.py index 839d299f3d48..25c7ad6cf29b 100644 --- a/lib/matplotlib/tests/test_backend_tk.py +++ b/lib/matplotlib/tests/test_backend_tk.py @@ -4,7 +4,8 @@ import platform import subprocess import sys -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import MagicMock, patch import pytest @@ -15,6 +16,24 @@ _test_timeout = 60 # A reasonably safe value for slower architectures. +def test_zoom_whiskers_contrast(): + toolbar_cls = pytest.importorskip( + "matplotlib.backends._backend_tk").NavigationToolbar2Tk + toolbar = toolbar_cls.__new__(toolbar_cls) + toolbar.canvas = SimpleNamespace( + _tkcanvas=MagicMock(), + figure=SimpleNamespace(bbox=SimpleNamespace(height=100)), + ) + + toolbar.draw_whiskers(None, 10, 20, 10, 80, ws=20) + + calls = toolbar.canvas._tkcanvas.create_line.call_args_list + assert [(call.kwargs["fill"], call.kwargs["width"]) for call in calls] == [ + ("white", 3), ("white", 3), ("white", 3), + ("black", 1), ("black", 1), ("black", 1), + ] + + def _isolated_tk_test(success_count, func=None): """ A decorator to run *func* in a subprocess and assert that it prints diff --git a/src/_macosx.m b/src/_macosx.m index 17d5f0ef16c1..60deff384780 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -1441,40 +1441,36 @@ -(void)drawRect:(NSRect)rect // Whiskers are stored as a rectangle. Draw a center line along the rectangle's // long axis and short perpendicular caps at each end. The rectangle is // constructed so its longer side corresponds to the zoom direction. + NSBezierPath *path = [NSBezierPath bezierPath]; if (whiskers.size.width < whiskers.size.height) { // Vertical whiskers int ws = whiskers.size.width; int x = whiskers.origin.x + ws/2; int y1 = whiskers.origin.y; int y2 = whiskers.origin.y + whiskers.size.height; - // Draw top and bottom edges - NSBezierPath *path = [NSBezierPath bezierPath]; - [path setLineWidth: 2.0]; - [[NSColor blackColor] setStroke]; [path moveToPoint: NSMakePoint(x, y1)]; [path lineToPoint: NSMakePoint(x, y2)]; [path moveToPoint: NSMakePoint(x - ws/2, y1)]; [path lineToPoint: NSMakePoint(x + ws/2, y1)]; [path moveToPoint: NSMakePoint(x - ws/2, y2)]; [path lineToPoint: NSMakePoint(x + ws/2, y2)]; - [path stroke]; - } - if (whiskers.size.width >= whiskers.size.height) { // Horizontal whiskers + } else { // Horizontal whiskers int hs = whiskers.size.height; int y = whiskers.origin.y + hs/2; int x1 = whiskers.origin.x; int x2 = whiskers.origin.x + whiskers.size.width; - // Draw left and right edges - NSBezierPath *path = [NSBezierPath bezierPath]; - [path setLineWidth: 2.0]; - [[NSColor blackColor] setStroke]; [path moveToPoint: NSMakePoint(x1, y)]; [path lineToPoint: NSMakePoint(x2, y)]; [path moveToPoint: NSMakePoint(x1, y - hs/2)]; [path lineToPoint: NSMakePoint(x1, y + hs/2)]; [path moveToPoint: NSMakePoint(x2, y - hs/2)]; [path lineToPoint: NSMakePoint(x2, y + hs/2)]; - [path stroke]; } + [path setLineWidth: 3.0]; + [[NSColor whiteColor] setStroke]; + [path stroke]; + [path setLineWidth: 1.0]; + [[NSColor blackColor] setStroke]; + [path stroke]; } exit: From 5dcad2b116958abcafee2b8cc49b076b3f8987ec Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Wed, 19 Aug 2026 17:55:54 +1000 Subject: [PATCH 20/22] Fix zoom whisker cleanup --- lib/matplotlib/backend_bases.py | 1 + lib/matplotlib/tests/test_backend_bases.py | 19 +++++++++++++++++++ src/_macosx.m | 4 +++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index b13c2d58a5ba..54b03bf758b6 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3383,6 +3383,7 @@ def _cleanup_post_zoom(self): # by (pressing and) releasing another mouse button. self.canvas.mpl_disconnect(self._zoom_info.cid) self.remove_rubberband() + self.remove_whiskers() self.canvas.draw_idle() self._zoom_info = None diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 0928022465a1..e36d3d05cce7 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -234,6 +234,25 @@ def test_interactive_zoom_without_whisker_support(): assert ax.get_ylim() != pytest.approx((0, 20)) +def test_cancel_zoom_removes_whiskers(): + fig, ax = plt.subplots() + fig.canvas.draw() + start = ax.transData.transform((0.5, 0.5)).astype(int) + button = MouseButton.LEFT + + tb = NavigationToolbar2(fig.canvas) + tb.zoom() + tb.press_zoom(MouseEvent( + "button_press_event", fig.canvas, *start, button)) + removed = [] + tb.remove_whiskers = lambda: removed.append(True) + tb.drag_zoom(MouseEvent( + "motion_notify_event", fig.canvas, *start, button, buttons=set())) + + assert removed + assert tb._zoom_info is None + + def test_widgetlock_zoompan(): fig, ax = plt.subplots() ax.plot([0, 1], [0, 1]) diff --git a/src/_macosx.m b/src/_macosx.m index 60deff384780..2b0db27a8253 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -1691,7 +1691,8 @@ - (void)setRubberband:(NSRect)rect - (void)setWhiskers:(NSRect)rect { - // Redrawing handled by setRubberband + [self setNeedsDisplayInRect: + NSInsetRect(NSUnionRect(rect, whiskers), -2, -2)]; whiskers = rect; } @@ -1705,6 +1706,7 @@ - (void)removeRubberband - (void)removeWhiskers { if (NSIsEmptyRect(whiskers)) { return; } + [self setNeedsDisplayInRect: NSInsetRect(whiskers, -2, -2)]; whiskers = NSZeroRect; } From d7fc3125b1a0f0b7e416d8ed55a91b2988feb25b Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Wed, 19 Aug 2026 18:11:56 +1000 Subject: [PATCH 21/22] Coalesce WebAgg zoom overlay draws --- lib/matplotlib/backends/web_backend/js/mpl.js | 122 +++++++++++------- 1 file changed, 76 insertions(+), 46 deletions(-) diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index ee598d7b69b5..cfbd8844f396 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -43,6 +43,9 @@ mpl.figure = function (figure_id, websocket, ondownload, parent_element) { this.canvas = undefined; this.rubberband_canvas = undefined; this.rubberband_context = undefined; + this._rubberband = null; + this._whiskers = null; + this._overlay_draw_pending = false; this.format_dropdown = undefined; this.image_mode = 'full'; @@ -449,22 +452,49 @@ mpl.figure.prototype.handle_resize = function (fig, msg) { }; mpl.figure.prototype.handle_rubberband = function (fig, msg) { - var x0 = msg['x0'] / fig.ratio; - var y0 = (fig.canvas.height - msg['y0']) / fig.ratio; - var x1 = msg['x1'] / fig.ratio; - var y1 = (fig.canvas.height - msg['y1']) / fig.ratio; - x0 = Math.floor(x0) + 0.5; - y0 = Math.floor(y0) + 0.5; - x1 = Math.floor(x1) + 0.5; - y1 = Math.floor(y1) + 0.5; - - var ctx = fig.rubberband_context; + fig._rubberband = msg['x0'] < 0 ? null : msg; + fig._schedule_overlay_draw(); +}; + +mpl.figure.prototype.handle_whiskers = function (fig, msg) { + fig._whiskers = msg['x0'] < 0 ? null : msg; + fig._schedule_overlay_draw(); +}; + +mpl.figure.prototype._schedule_overlay_draw = function () { + if (this._overlay_draw_pending) { + return; + } + this._overlay_draw_pending = true; + var fig = this; + window.requestAnimationFrame(function () { + fig._overlay_draw_pending = false; + fig._draw_overlay(); + }); +}; + +mpl.figure.prototype._draw_overlay = function () { + var ctx = this.rubberband_context; ctx.clearRect( 0, 0, - fig.canvas.width / fig.ratio, - fig.canvas.height / fig.ratio + this.canvas.width / this.ratio, + this.canvas.height / this.ratio ); + if (this._rubberband !== null) { + this._draw_rubberband(this._rubberband); + } + if (this._whiskers !== null) { + this._draw_whiskers(this._whiskers); + } +}; + +mpl.figure.prototype._draw_rubberband = function (msg) { + var x0 = Math.floor(msg['x0'] / this.ratio) + 0.5; + var y0 = Math.floor((this.canvas.height - msg['y0']) / this.ratio) + 0.5; + var x1 = Math.floor(msg['x1'] / this.ratio) + 0.5; + var y1 = Math.floor((this.canvas.height - msg['y1']) / this.ratio) + 0.5; + var ctx = this.rubberband_context; var drawRubberband = function () { // Draw the lines from x0, y0 towards x1, y1 so that the @@ -481,47 +511,47 @@ mpl.figure.prototype.handle_rubberband = function (fig, msg) { ctx.stroke(); }; - fig.rubberband_context.lineWidth = 1; - fig.rubberband_context.setLineDash([3]); - fig.rubberband_context.lineDashOffset = 0; - fig.rubberband_context.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.setLineDash([3]); + ctx.lineDashOffset = 0; + ctx.strokeStyle = '#000000'; drawRubberband(); - fig.rubberband_context.strokeStyle = '#ffffff'; - fig.rubberband_context.lineDashOffset = 3; + ctx.strokeStyle = '#ffffff'; + ctx.lineDashOffset = 3; drawRubberband(); }; -mpl.figure.prototype.handle_whiskers = function (fig, msg) { - var x0 = msg['x0'] / fig.ratio; - var y0 = (fig.canvas.height - msg['y0']) / fig.ratio; - var x1 = msg['x1'] / fig.ratio; - var y1 = (fig.canvas.height - msg['y1']) / fig.ratio; - var ws = msg['ws'] / fig.ratio; - - // Draw line - fig.rubberband_context.setLineDash([]); - fig.rubberband_context.beginPath(); +mpl.figure.prototype._draw_whiskers = function (msg) { + var x0 = Math.floor(msg['x0'] / this.ratio) + 0.5; + var y0 = Math.floor((this.canvas.height - msg['y0']) / this.ratio) + 0.5; + var x1 = Math.floor(msg['x1'] / this.ratio) + 0.5; + var y1 = Math.floor((this.canvas.height - msg['y1']) / this.ratio) + 0.5; + var ws = msg['ws'] / this.ratio; + var ctx = this.rubberband_context; + + ctx.setLineDash([]); + ctx.beginPath(); if (x0 == x1) { // Vertical line - fig.rubberband_context.moveTo(x0, y0); - fig.rubberband_context.lineTo(x1, y1); - fig.rubberband_context.moveTo(x0 - ws/2, y0); - fig.rubberband_context.lineTo(x0 + ws/2, y0); - fig.rubberband_context.moveTo(x1 - ws/2, y1); - fig.rubberband_context.lineTo(x1 + ws/2, y1); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.moveTo(x0 - ws/2, y0); + ctx.lineTo(x0 + ws/2, y0); + ctx.moveTo(x1 - ws/2, y1); + ctx.lineTo(x1 + ws/2, y1); } else if (y0 == y1) { // Horizontal line - fig.rubberband_context.moveTo(x0, y0); - fig.rubberband_context.lineTo(x1, y1); - fig.rubberband_context.moveTo(x0, y0 - ws/2); - fig.rubberband_context.lineTo(x0, y0 + ws/2); - fig.rubberband_context.moveTo(x1, y1 - ws/2); - fig.rubberband_context.lineTo(x1, y1 + ws/2); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.moveTo(x0, y0 - ws/2); + ctx.lineTo(x0, y0 + ws/2); + ctx.moveTo(x1, y1 - ws/2); + ctx.lineTo(x1, y1 + ws/2); } - fig.rubberband_context.strokeStyle = '#ffffff'; - fig.rubberband_context.lineWidth = 3; - fig.rubberband_context.stroke(); - fig.rubberband_context.strokeStyle = '#000000'; - fig.rubberband_context.lineWidth = 1; - fig.rubberband_context.stroke(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 3; + ctx.stroke(); + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.stroke(); }; mpl.figure.prototype.handle_figure_label = function (fig, msg) { From 510091a80d4e2973b53bfb061f498f21df17c30b Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Wed, 19 Aug 2026 18:51:53 +1000 Subject: [PATCH 22/22] Fix zoom whisker documentation references --- doc/api/next_api_changes/development/30554_REC.rst | 5 +++-- galleries/users_explain/figure/backends.rst | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/doc/api/next_api_changes/development/30554_REC.rst b/doc/api/next_api_changes/development/30554_REC.rst index 626ab722cb33..f50a913991f3 100644 --- a/doc/api/next_api_changes/development/30554_REC.rst +++ b/doc/api/next_api_changes/development/30554_REC.rst @@ -1,8 +1,9 @@ Single-axis zoom indicators are optional for backends ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -`NavigationToolbar2.draw_whiskers` and `NavigationToolbar2.remove_whiskers` are -optional backend API methods for drawing the single-axis zoom indicator. Their +`~matplotlib.backend_bases.NavigationToolbar2.draw_whiskers` and +`~matplotlib.backend_bases.NavigationToolbar2.remove_whiskers` are optional +backend API methods for drawing the single-axis zoom indicator. Their default implementations do nothing, so third-party backends continue to zoom without displaying the indicator. diff --git a/galleries/users_explain/figure/backends.rst b/galleries/users_explain/figure/backends.rst index 92efd61c27d8..163f0021a2b8 100644 --- a/galleries/users_explain/figure/backends.rst +++ b/galleries/users_explain/figure/backends.rst @@ -281,8 +281,9 @@ The following backend API versions exist 1.0. * - 1.2 - Matplotlib 3.12 - - `NavigationToolbar2.draw_whiskers` and - `NavigationToolbar2.remove_whiskers` optionally draw the single-axis zoom + - `~matplotlib.backend_bases.NavigationToolbar2.draw_whiskers` and + `~matplotlib.backend_bases.NavigationToolbar2.remove_whiskers` optionally draw + the single-axis zoom indicator. Backends implementing older versions continue to work without the indicator.