From b9c7ec4a867b689c059729a98a4d2cdc1aeb2a10 Mon Sep 17 00:00:00 2001 From: Lucas Gruwez Date: Sun, 14 Sep 2025 01:09:02 +1000 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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()