From 084da07cd3d80bd1d4427de33bb3401ed3f74576 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Mon, 15 Nov 2021 00:47:57 +0100 Subject: [PATCH] Backport PR #21604: Fix centre square rectangle selector part 1 --- lib/matplotlib/tests/test_widgets.py | 267 +++++++++++++++++++++++++++ lib/matplotlib/widgets.py | 87 ++++++--- 2 files changed, 333 insertions(+), 21 deletions(-) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 0e4a7e7daa68..9955aad0512d 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -55,6 +55,19 @@ def test_rectangle_selector(): check_rectangle(props=dict(fill=True)) +def _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, + use_key=None): + do_event(tool, 'press', xdata=xdata, ydata=ydata, button=1) + if use_key is not None: + do_event(tool, 'on_key_press', key=use_key) + do_event(tool, 'onmove', xdata=xdata_new, ydata=ydata_new, button=1) + if use_key is not None: + do_event(tool, 'on_key_release', key=use_key) + do_event(tool, 'release', xdata=xdata_new, ydata=ydata_new, button=1) + + return tool + + @pytest.mark.parametrize('drag_from_anywhere, new_center', [[True, (60, 75)], [False, (30, 20)]]) @@ -118,6 +131,260 @@ def onselect(epress, erelease): assert artist.get_alpha() == 0.3 +def test_rectangle_resize(): + ax = get_ax() + + def onselect(epress, erelease): + pass + + tool = widgets.RectangleSelector(ax, onselect, interactive=True) + # Create rectangle + _resize_rectangle(tool, 0, 10, 100, 120) + assert tool.extents == (0.0, 100.0, 10.0, 120.0) + + # resize NE handle + extents = tool.extents + xdata, ydata = extents[1], extents[3] + xdata_new, ydata_new = xdata + 10, ydata + 5 + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (extents[0], xdata_new, extents[2], ydata_new) + + # resize E handle + extents = tool.extents + xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 + xdata_new, ydata_new = xdata + 10, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (extents[0], xdata_new, extents[2], extents[3]) + + # resize W handle + extents = tool.extents + xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 + xdata_new, ydata_new = xdata + 15, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (xdata_new, extents[1], extents[2], extents[3]) + + # resize SW handle + extents = tool.extents + xdata, ydata = extents[0], extents[2] + xdata_new, ydata_new = xdata + 20, ydata + 25 + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (xdata_new, extents[1], ydata_new, extents[3]) + + +@pytest.mark.parametrize('use_default_state', [True, False]) +def test_rectangle_resize_center(use_default_state): + ax = get_ax() + + def onselect(epress, erelease): + pass + + tool = widgets.RectangleSelector(ax, onselect, interactive=True) + # Create rectangle + _resize_rectangle(tool, 70, 65, 125, 130) + assert tool.extents == (70.0, 125.0, 65.0, 130.0) + + if use_default_state: + tool._default_state.add('center') + use_key = None + else: + use_key = 'control' + + # resize NE handle + extents = tool.extents + xdata, ydata = extents[1], extents[3] + xdiff, ydiff = 10, 5 + xdata_new, ydata_new = xdata + xdiff, ydata + ydiff + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (extents[0] - xdiff, xdata_new, + extents[2] - ydiff, ydata_new) + + # resize E handle + extents = tool.extents + xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = 10 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (extents[0] - xdiff, xdata_new, + extents[2], extents[3]) + + # resize E handle negative diff + extents = tool.extents + xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = -20 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (extents[0] - xdiff, xdata_new, + extents[2], extents[3]) + + # resize W handle + extents = tool.extents + xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = 15 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (xdata_new, extents[1] - xdiff, + extents[2], extents[3]) + + # resize W handle negative diff + extents = tool.extents + xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = -25 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (xdata_new, extents[1] - xdiff, + extents[2], extents[3]) + + # resize SW handle + extents = tool.extents + xdata, ydata = extents[0], extents[2] + xdiff, ydiff = 20, 25 + xdata_new, ydata_new = xdata + xdiff, ydata + ydiff + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (xdata_new, extents[1] - xdiff, + ydata_new, extents[3] - ydiff) + + +@pytest.mark.parametrize('use_default_state', [True, False]) +def test_rectangle_resize_square(use_default_state): + ax = get_ax() + + def onselect(epress, erelease): + pass + + tool = widgets.RectangleSelector(ax, onselect, interactive=True) + # Create rectangle + _resize_rectangle(tool, 70, 65, 120, 115) + assert tool.extents == (70.0, 120.0, 65.0, 115.0) + + if use_default_state: + tool._default_state.add('square') + use_key = None + else: + use_key = 'shift' + + # resize NE handle + extents = tool.extents + xdata, ydata = extents[1], extents[3] + xdiff, ydiff = 10, 5 + xdata_new, ydata_new = xdata + xdiff, ydata + ydiff + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (extents[0], xdata_new, + extents[2], extents[3] + xdiff) + + # resize E handle + extents = tool.extents + xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = 10 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (extents[0], xdata_new, + extents[2], extents[3] + xdiff) + + # resize E handle negative diff + extents = tool.extents + xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = -20 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (extents[0], xdata_new, + extents[2], extents[3] + xdiff) + + # resize W handle + extents = tool.extents + xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = 15 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (xdata_new, extents[1], + extents[2], extents[3] - xdiff) + + # resize W handle negative diff + extents = tool.extents + xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = -25 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (xdata_new, extents[1], + extents[2], extents[3] - xdiff) + + # resize SW handle + extents = tool.extents + xdata, ydata = extents[0], extents[2] + xdiff, ydiff = 20, 25 + xdata_new, ydata_new = xdata + xdiff, ydata + ydiff + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + assert tool.extents == (extents[0] + ydiff, extents[1], + ydata_new, extents[3]) + + +def test_rectangle_resize_square_center(): + ax = get_ax() + + def onselect(epress, erelease): + pass + + tool = widgets.RectangleSelector(ax, onselect, interactive=True) + # Create rectangle + _resize_rectangle(tool, 70, 65, 120, 115) + tool._default_state.add('square') + tool._default_state.add('center') + assert tool.extents == (70.0, 120.0, 65.0, 115.0) + + # resize NE handle + extents = tool.extents + xdata, ydata = extents[1], extents[3] + xdiff, ydiff = 10, 5 + xdata_new, ydata_new = xdata + xdiff, ydata + ydiff + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (extents[0] - xdiff, xdata_new, + extents[2] - xdiff, extents[3] + xdiff) + + # resize E handle + extents = tool.extents + xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = 10 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (extents[0] - xdiff, xdata_new, + extents[2] - xdiff, extents[3] + xdiff) + + # resize E handle negative diff + extents = tool.extents + xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = -20 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (extents[0] - xdiff, xdata_new, + extents[2] - xdiff, extents[3] + xdiff) + + # resize W handle + extents = tool.extents + xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = 5 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (xdata_new, extents[1] - xdiff, + extents[2] + xdiff, extents[3] - xdiff) + + # resize W handle negative diff + extents = tool.extents + xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 + xdiff = -25 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (xdata_new, extents[1] - xdiff, + extents[2] + xdiff, extents[3] - xdiff) + + # resize SW handle + extents = tool.extents + xdata, ydata = extents[0], extents[2] + xdiff, ydiff = 20, 25 + xdata_new, ydata_new = xdata + xdiff, ydata + ydiff + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert tool.extents == (extents[0] + ydiff, extents[1] - ydiff, + ydata_new, extents[3] - ydiff) + + def test_ellipse(): """For ellipse, test out the key modifiers""" ax = get_ax() diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index c34eaae792d3..de9b6644cacd 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -1826,6 +1826,7 @@ def __init__(self, ax, onselect, useblit=False, button=None, self._eventrelease = None self._prev_event = None self._state = set() + self._default_state = set() eventpress = _api.deprecate_privatize_attribute("3.5") eventrelease = _api.deprecate_privatize_attribute("3.5") @@ -2839,13 +2840,13 @@ def __init__(self, ax, onselect, drawtype='box', 'edgecolor', 'black'), **cbook.normalize_kwargs(handle_props, Line2D)} - self._corner_order = ['NW', 'NE', 'SE', 'SW'] + self._corner_order = ['SW', 'SE', 'NE', 'NW'] xc, yc = self.corners self._corner_handles = ToolHandles(self.ax, xc, yc, marker_props=self._handle_props, useblit=self.useblit) - self._edge_order = ['W', 'N', 'E', 'S'] + self._edge_order = ['W', 'S', 'E', 'N'] xe, ye = self.edge_centers self._edge_handles = ToolHandles(self.ax, xe, ye, marker='s', marker_props=self._handle_props, @@ -2885,6 +2886,7 @@ def _press(self, event): # button, ... if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) + self._extents_on_press = self.extents else: self._active_handle = None @@ -2950,22 +2952,76 @@ def _release(self, event): self.update() self._active_handle = None + self._extents_on_press = None return False def _onmove(self, event): """Motion notify event handler.""" + + state = self._state | self._default_state # resize an existing shape if self._active_handle and self._active_handle != 'C': x0, x1, y0, y1 = self._extents_on_press - if self._active_handle in ['E', 'W'] + self._corner_order: - x1 = event.xdata - if self._active_handle in ['N', 'S'] + self._corner_order: - y1 = event.ydata + size_on_press = [x1 - x0, y1 - y0] + center = [x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2] + dx = event.xdata - self._eventpress.xdata + dy = event.ydata - self._eventpress.ydata + + # change sign of relative changes to simplify calculation + # Switch variables so that only x1 and/or y1 are updated on move + x_factor = y_factor = 1 + if 'W' in self._active_handle: + x_factor *= -1 + dx *= x_factor + x0 = x1 + if 'S' in self._active_handle: + y_factor *= -1 + dy *= y_factor + y0 = y1 + + # Keeping the center fixed + if 'center' in state: + if 'square' in state: + # Force the same change in dx and dy + if self._active_handle in ['E', 'W']: + # using E, W handle we need to update dy accordingly + dy = dx + elif self._active_handle in ['S', 'N']: + # using S, N handle, we need to update dx accordingly + dx = dy + else: + dx = dy = max(dx, dy, key=abs) + + # new half-width and half-height + hw = size_on_press[0] / 2 + dx + hh = size_on_press[1] / 2 + dy + + if 'square' not in state: + # cancel changes in perpendicular direction + if self._active_handle in ['E', 'W']: + hh = size_on_press[1] / 2 + if self._active_handle in ['N', 'S']: + hw = size_on_press[0] / 2 + + x0, x1, y0, y1 = (center[0] - hw, center[0] + hw, + center[1] - hh, center[1] + hh) + + else: + # Keeping the opposite corner/edge fixed + if 'square' in state: + dx = dy = max(dx, dy, key=abs) + x1 = x0 + x_factor * (dx + size_on_press[0]) + y1 = y0 + y_factor * (dy + size_on_press[1]) + else: + if self._active_handle in ['E', 'W'] + self._corner_order: + x1 = event.xdata + if self._active_handle in ['N', 'S'] + self._corner_order: + y1 = event.ydata # move existing shape - elif (('move' in self._state or self._active_handle == 'C' or - (self.drag_from_anywhere and self._contains(event))) and + elif (self._active_handle == 'C' or + (self.drag_from_anywhere and self._contains(event)) and self._extents_on_press is not None): x0, x1, y0, y1 = self._extents_on_press dx = event.xdata - self._eventpress.xdata @@ -2987,7 +3043,7 @@ def _onmove(self, event): dy = (event.ydata - center[1]) / 2. # square shape - if 'square' in self._state: + if 'square' in state: dx_pix = abs(event.x - center_pix[0]) dy_pix = abs(event.y - center_pix[1]) if not dx_pix: @@ -2999,7 +3055,7 @@ def _onmove(self, event): dy *= maxd / (abs(dy_pix) + 1e-6) # from center - if 'center' in self._state: + if 'center' in state: dx *= 2 dy *= 2 @@ -3103,7 +3159,6 @@ def _set_active_handle(self, event): if 'move' in self._state: self._active_handle = 'C' - self._extents_on_press = self.extents # Set active handle as closest handle, if mouse click is close enough. elif m_dist < self.grab_range * 2: # Prioritise center handle over other handles @@ -3114,7 +3169,6 @@ def _set_active_handle(self, event): if self.drag_from_anywhere and self._contains(event): # Check if we've clicked inside the region self._active_handle = 'C' - self._extents_on_press = self.extents else: self._active_handle = None return @@ -3125,15 +3179,6 @@ def _set_active_handle(self, event): # Closest to an edge handle self._active_handle = self._edge_order[e_idx] - # Save coordinates of rectangle at the start of handle movement. - x0, x1, y0, y1 = self.extents - # Switch variables so that only x1 and/or y1 are updated on move. - if self._active_handle in ['W', 'SW', 'NW']: - x0, x1 = x1, event.xdata - if self._active_handle in ['N', 'NW', 'NE']: - y0, y1 = y1, event.ydata - self._extents_on_press = x0, x1, y0, y1 - def _contains(self, event): """Return True if event is within the patch.""" return self._selection_artist.contains(event, radius=0)[0]