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. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 7560db80d2c1..ac6498304e96 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2935,6 +2935,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. @@ -3228,7 +3239,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.""" @@ -3239,6 +3263,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" @@ -3249,12 +3274,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 +3282,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() diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 7a2b28262249..9932114a013e 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, 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 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_tk.py b/lib/matplotlib/backends/_backend_tk.py index 813e0c60620f..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 @@ -760,11 +763,46 @@ 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))) + 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. diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 4e05119aa0f6..cf9a777f2306 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,65 @@ 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_dash([], 0) + ctx.set_source_rgb(0, 0, 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.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..d0041de9f16b 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,72 @@ 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_dash([], 0) + ctx.set_source_rgb(0, 0, 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.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 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/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index d0aded5fff63..a6c7db4ab786 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, 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 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() 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/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index f83a69d8361e..3dc1d6ad68d4 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,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) + filetypes = { **FigureCanvasBase.filetypes, @@ -1174,10 +1195,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 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']; 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];