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..f50a913991f3 --- /dev/null +++ b/doc/api/next_api_changes/development/30554_REC.rst @@ -0,0 +1,11 @@ +Single-axis zoom indicators are optional for backends +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`~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. + +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 new file mode 100644 index 000000000000..3d0c76f52f49 --- /dev/null +++ b/doc/release/next_whats_new/single_axis_zoom.rst @@ -0,0 +1,6 @@ +Single Axis Zoom +---------------- + +Zooming in a single axis (horizontal or vertical) can be done by dragging the +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..163f0021a2b8 100644 --- a/galleries/users_explain/figure/backends.rst +++ b/galleries/users_explain/figure/backends.rst @@ -279,6 +279,13 @@ 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 + - `~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. There is currently no plan to remove support for older API versions. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 384987e3d036..54b03bf758b6 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. @@ -3014,6 +3018,17 @@ 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, 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): + """Remove the whiskers.""" + def home(self, *args): """ Restore the original view. @@ -3307,7 +3322,20 @@ def drag_zoom(self, event): elif key == "y": x1, x2 = ax.bbox.intervalx + # 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]) < 15) and (abs(event.x - start_xy[0]) > 30): + y1, y2 = ax.bbox.intervaly + whisk = (x1, start_xy[1], x2, start_xy[1]) + else: + whisk = None + self.remove_whiskers() + self.draw_rubberband(event, x1, y1, x2, y2) + if whisk: + self.draw_whiskers(event, *whisk, ws=30) def release_zoom(self, event): """Callback for mouse button release in zoom to rect mode.""" @@ -3318,6 +3346,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" @@ -3328,12 +3357,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 @@ -3342,8 +3365,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) < 15) and (abs(end_y - start_y) > 30): + start_x, end_x = ax.bbox.intervalx + 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, event.x, event.y), + (start_x, start_y, end_x, end_y), direction, key, twinx, twiny) self._cleanup_post_zoom() @@ -3354,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/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index fe492f0dde66..b49b6c23d9d8 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -458,6 +458,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, ws: 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_gtk.py b/lib/matplotlib/backends/_backend_gtk.py index 85c05b3e1c10..5bf8ba768999 100644 --- a/lib/matplotlib/backends/_backend_gtk.py +++ b/lib/matplotlib/backends/_backend_gtk.py @@ -281,9 +281,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_tk.py b/lib/matplotlib/backends/_backend_tk.py index 97edbfa8bd06..1394711af1be 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -659,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 @@ -785,6 +787,25 @@ 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): + self.remove_whiskers() + height = self.canvas.figure.bbox.height + y0 = height - y0 + y1 = height - y1 + lines = [(x0, y0, x1, y1)] + if x1 == x0: # vertical line + lines += [(x0 - ws//2, y0, x0 + ws//2, y0), + (x1 - ws//2, y1, x1 + ws//2, y1)] + elif y1 == y0: # horizontal line + 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( + *line, fill=color, width=width, tags=self._whiskers_tag) + def remove_rubberband(self): if self.canvas._rubberband_rect_white: self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_white) @@ -793,6 +814,9 @@ def remove_rubberband(self): self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_black) self.canvas._rubberband_rect_black = None + def remove_whiskers(self): + self.canvas._tkcanvas.delete(self._whiskers_tag) + def _set_image_for_button(self, button): """ Set the image for a button based on its pixel size. diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 0cb54b31eab7..21fff45d2af0 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -62,6 +62,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) @@ -244,35 +246,68 @@ 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_dash([], 0) + + # main line + ctx.move_to(x0, y0) + 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.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): # to be overwritten by GTK3Agg or GTK3Cairo diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 05594a76d5a2..4a6c0a67535d 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -49,6 +49,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) @@ -265,41 +267,75 @@ 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_dash([], 0) + + # main line + ctx.move_to(x0, y0) + 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.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): # to be overwritten by GTK4Agg or GTK4Cairo diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index b8d4a4a9cc01..e9d40846e456 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/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_qt.py b/lib/matplotlib/backends/backend_qt.py index 9c407a419e11..0ac67d83004e 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -238,6 +238,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) @@ -528,6 +529,31 @@ def _draw_idle(self): # Uncaught exceptions are fatal for PyQt5, so catch them. traceback.print_exc() + def drawWhiskers(self, line, ws=20): + 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 + lines += [(x0 - ws // 2, y0, x0 + ws // 2, y0), + (x1 - ws // 2, y1, x1 + ws // 2, y1)] + elif y0 == y1: # horizontal line + lines += [(x0, y0 - ws // 2, x0, y0 + ws // 2), + (x1, y1 - ws // 2, x1, y1 + ws // 2)] + else: + 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() + def drawRectangle(self, rect): # Draw the zoom rectangle to the QPainter. _draw_rect_callback needs # to be called at the end of paintEvent. @@ -923,9 +949,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, 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, ws) + 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 54efb134c2b1..7f1d42da774b 100644 --- a/lib/matplotlib/backends/backend_qtagg.py +++ b/lib/matplotlib/backends/backend_qtagg.py @@ -62,6 +62,7 @@ def paintEvent(self, event): origin = QtCore.QPoint(rect.left(), rect.top()) painter.drawImage(origin, qimage) 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 866f16e3ae5b..4fb8e13eba51 100644 --- a/lib/matplotlib/backends/backend_qtcairo.py +++ b/lib/matplotlib/backends/backend_qtcairo.py @@ -33,6 +33,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() 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 f1c6ae641feb..e706d8147505 100644 --- a/lib/matplotlib/backends/backend_webagg_core.py +++ b/lib/matplotlib/backends/backend_webagg_core.py @@ -436,9 +436,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') @@ -546,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/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 7591a6575806..4a582b2f0571 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -484,6 +484,10 @@ 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_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) @@ -616,6 +620,24 @@ 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//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//2, x0, + y0 + self._whiskers_size//2), + (x1, y1 - self._whiskers_size//2, x1, + y1 + self._whiskers_size//2)] + else: # Don't draw + lines = [] + + drawDC.DrawLineList(lines, self._whiskers_pen_white) + drawDC.DrawLineList(lines, self._whiskers_pen_black) filetypes = { **FigureCanvasBase.filetypes, @@ -1181,10 +1203,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) diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index b3e91bba54f0..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'; @@ -63,6 +66,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, @@ -448,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 @@ -480,16 +511,49 @@ 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._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 + 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 + 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); + } + 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) { // Updates the figure title. fig.header.textContent = msg['label']; diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 09b803ce58c6..e36d3d05cce7 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -210,6 +210,49 @@ 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_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/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/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() diff --git a/src/_macosx.m b/src/_macosx.m index 5b227fa1281a..2b0db27a8253 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -170,6 +170,7 @@ - (NSRect)constrainFrameRect:(NSRect)rect toScreen:(NSScreen*)screen; @interface View : NSView { NSRect rubberband; + NSRect whiskers; @public double device_scale; } - (void)drawRect:(NSRect)rect; @@ -190,7 +191,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; - (NSString*)convertKeyEvent:(NSEvent*)event; - (void)keyDown:(NSEvent*)event; - (void)keyUp:(NSEvent*)event; @@ -531,6 +534,37 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) RETURN_NULL_OR_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) { @@ -540,6 +574,13 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) RETURN_NULL_OR_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) { @@ -624,10 +665,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, @@ -1388,6 +1437,41 @@ -(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. + 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; + [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)]; + } 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; + [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 setLineWidth: 3.0]; + [[NSColor whiteColor] setStroke]; + [path stroke]; + [path setLineWidth: 1.0]; + [[NSColor blackColor] setStroke]; + [path stroke]; + } exit: Py_XDECREF(renderer_buffer); @@ -1605,6 +1689,13 @@ - (void)setRubberband:(NSRect)rect rubberband = rect; } +- (void)setWhiskers:(NSRect)rect +{ + [self setNeedsDisplayInRect: + NSInsetRect(NSUnionRect(rect, whiskers), -2, -2)]; + whiskers = rect; +} + - (void)removeRubberband { if (NSIsEmptyRect(rubberband)) { return; } @@ -1612,6 +1703,13 @@ - (void)removeRubberband rubberband = NSZeroRect; } +- (void)removeWhiskers +{ + if (NSIsEmptyRect(whiskers)) { return; } + [self setNeedsDisplayInRect: NSInsetRect(whiskers, -2, -2)]; + whiskers = NSZeroRect; +} + - (NSString*)convertKeyEvent:(NSEvent*)event { NSMutableString* returnkey = [NSMutableString string];