diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index 208c426ba68e..678673b40fec 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -68,7 +68,7 @@ def _contour_labeler_event_handler(cs, inline, inline_spacing, event): elif (is_button and event.button == MouseButton.LEFT # On macOS/gtk, some keys return None. or is_key and event.key is not None): - if event.inaxes == cs.axes: + if cs.axes.contains(event)[0]: cs.add_label_near(event.x, event.y, transform=False, inline=inline, inline_spacing=inline_spacing) canvas.draw() diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 2f6910f5685e..e66758d394ac 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -643,6 +643,13 @@ def test_span_selector(ax, orientation, onmove_callback, kwargs): if onmove_callback: kwargs['onmove_callback'] = onmove + # While at it, also test that span selectors work in the presence of twin axes on + # top of the axes that contain the selector. Note that we need to unforce the axes + # aspect here, otherwise the twin axes forces the original axes' limits (to respect + # aspect=1) which makes some of the values below go out of bounds. + ax.set_aspect("auto") + tax = ax.twinx() + tool = widgets.SpanSelector(ax, onselect, orientation, **kwargs) do_event(tool, 'press', xdata=100, ydata=100, button=1) # move outside of axis @@ -925,7 +932,7 @@ def mean(vmin, vmax): # Change span selector and check that the line is drawn/updated after its # value was updated by the callback - press_data = [4, 2] + press_data = [4, 0] move_data = [5, 2] release_data = [5, 2] do_event(span, 'press', xdata=press_data[0], ydata=press_data[1], button=1) @@ -1033,7 +1040,7 @@ def test_TextBox(ax, toolbar): assert submit_event.call_count == 2 - do_event(tool, '_click') + do_event(tool, '_click', xdata=.5, ydata=.5) # Ensure the click is in the axes. do_event(tool, '_keypress', key='+') do_event(tool, '_keypress', key='5') @@ -1632,7 +1639,8 @@ def test_polygon_selector_verts_setter(fig_test, fig_ref, draw_bounding_box): def test_polygon_selector_box(ax): - # Create a diamond shape + # Create a diamond (adjusting axes lims s.t. the diamond lies within axes limits). + ax.set(xlim=(-10, 50), ylim=(-10, 50)) verts = [(20, 0), (0, 20), (20, 40), (40, 20)] event_sequence = [ *polygon_place_vertex(*verts[0]), diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 61e5a5d7cf76..e2d610498d40 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -149,6 +149,16 @@ def disconnect_events(self): for c in self._cids: self.canvas.mpl_disconnect(c) + def _get_data_coords(self, event): + """Return *event*'s data coordinates in this widget's Axes.""" + # This method handles the possibility that event.inaxes != self.ax (which may + # occur if multiple axes are overlaid), in which case event.xdata/.ydata will + # be wrong. Note that we still special-case the common case where + # event.inaxes == self.ax and avoid re-running the inverse data transform, + # because that can introduce floating point errors for synthetic events. + return ((event.xdata, event.ydata) if event.inaxes is self.ax + else self.ax.transData.inverted().transform((event.x, event.y))) + class Button(AxesWidget): """ @@ -215,7 +225,7 @@ def __init__(self, ax, label, image=None, self.hovercolor = hovercolor def _click(self, event): - if self.ignore(event) or event.inaxes != self.ax or not self.eventson: + if not self.eventson or self.ignore(event) or not self.ax.contains(event)[0]: return if event.canvas.mouse_grabber != self.ax: event.canvas.grab_mouse(self.ax) @@ -224,13 +234,13 @@ def _release(self, event): if self.ignore(event) or event.canvas.mouse_grabber != self.ax: return event.canvas.release_mouse(self.ax) - if self.eventson and event.inaxes == self.ax: + if self.eventson and self.ax.contains(event)[0]: self._observers.process('clicked', event) def _motion(self, event): if self.ignore(event): return - c = self.hovercolor if event.inaxes == self.ax else self.color + c = self.hovercolor if self.ax.contains(event)[0] else self.color if not colors.same_color(c, self.ax.get_facecolor()): self.ax.set_facecolor(c) if self.drawon: @@ -531,23 +541,22 @@ def _update(self, event): if self.ignore(event) or event.button != 1: return - if event.name == 'button_press_event' and event.inaxes == self.ax: + if event.name == 'button_press_event' and self.ax.contains(event)[0]: self.drag_active = True event.canvas.grab_mouse(self.ax) if not self.drag_active: return - elif ((event.name == 'button_release_event') or - (event.name == 'button_press_event' and - event.inaxes != self.ax)): + if (event.name == 'button_release_event' + or event.name == 'button_press_event' and not self.ax.contains(event)[0]): self.drag_active = False event.canvas.release_mouse(self.ax) return - if self.orientation == 'vertical': - val = self._value_in_bounds(event.ydata) - else: - val = self._value_in_bounds(event.xdata) + + xdata, ydata = self._get_data_coords(event) + val = self._value_in_bounds( + xdata if self.orientation == 'horizontal' else ydata) if val not in [None, self.val]: self.set_val(val) @@ -869,30 +878,26 @@ def _update(self, event): if self.ignore(event) or event.button != 1: return - if event.name == "button_press_event" and event.inaxes == self.ax: + if event.name == "button_press_event" and self.ax.contains(event)[0]: self.drag_active = True event.canvas.grab_mouse(self.ax) if not self.drag_active: return - elif (event.name == "button_release_event") or ( - event.name == "button_press_event" and event.inaxes != self.ax - ): + if (event.name == "button_release_event" + or event.name == "button_press_event" and not self.ax.contains(event)[0]): self.drag_active = False event.canvas.release_mouse(self.ax) self._active_handle = None return # determine which handle was grabbed - if self.orientation == "vertical": - handle_index = np.argmin( - np.abs([h.get_ydata()[0] - event.ydata for h in self._handles]) - ) - else: - handle_index = np.argmin( - np.abs([h.get_xdata()[0] - event.xdata for h in self._handles]) - ) + xdata, ydata = self._get_data_coords(event) + handle_index = np.argmin(np.abs( + [h.get_xdata()[0] - xdata for h in self._handles] + if self.orientation == "horizontal" else + [h.get_ydata()[0] - ydata for h in self._handles])) handle = self._handles[handle_index] # these checks ensure smooth behavior if the handles swap which one @@ -900,10 +905,7 @@ def _update(self, event): if handle is not self._active_handle: self._active_handle = handle - if self.orientation == "vertical": - self._update_val_from_pos(event.ydata) - else: - self._update_val_from_pos(event.xdata) + self._update_val_from_pos(xdata if self.orientation == "horizontal" else ydata) def _format(self, val): """Pretty-print *val*.""" @@ -1119,7 +1121,7 @@ def _clear(self, event): self.ax.draw_artist(l2) def _clicked(self, event): - if self.ignore(event) or event.button != 1 or event.inaxes != self.ax: + if self.ignore(event) or event.button != 1 or not self.ax.contains(event)[0]: return pclicked = self.ax.transAxes.inverted().transform((event.x, event.y)) distances = {} @@ -1551,7 +1553,7 @@ def stop_typing(self): def _click(self, event): if self.ignore(event): return - if event.inaxes != self.ax: + if not self.ax.contains(event)[0]: self.stop_typing() return if not self.eventson: @@ -1569,7 +1571,7 @@ def _resize(self, event): def _motion(self, event): if self.ignore(event): return - c = self.hovercolor if event.inaxes == self.ax else self.color + c = self.hovercolor if self.ax.contains(event)[0] else self.color if not colors.same_color(c, self.ax.get_facecolor()): self.ax.set_facecolor(c) if self.drawon: @@ -1733,7 +1735,7 @@ def _clear(self, event): self.ax.draw_artist(circle) def _clicked(self, event): - if self.ignore(event) or event.button != 1 or event.inaxes != self.ax: + if self.ignore(event) or event.button != 1 or not self.ax.contains(event)[0]: return pclicked = self.ax.transAxes.inverted().transform((event.x, event.y)) _, inds = self._buttons.contains(event) @@ -2006,7 +2008,7 @@ def onmove(self, event): return if not self.canvas.widgetlock.available(self): return - if event.inaxes != self.ax: + if not self.ax.contains(event)[0]: self.linev.set_visible(False) self.lineh.set_visible(False) @@ -2016,10 +2018,10 @@ def onmove(self, event): return self.needclear = True - self.linev.set_xdata((event.xdata, event.xdata)) + xdata, ydata = self._get_data_coords(event) + self.linev.set_xdata((xdata, xdata)) self.linev.set_visible(self.visible and self.vertOn) - - self.lineh.set_ydata((event.ydata, event.ydata)) + self.lineh.set_ydata((ydata, ydata)) self.lineh.set_visible(self.visible and self.horizOn) if self.visible and (self.vertOn or self.horizOn): @@ -2139,15 +2141,17 @@ def clear(self, event): info["background"] = canvas.copy_from_bbox(canvas.figure.bbox) def onmove(self, event): - if (self.ignore(event) - or event.inaxes not in self.axes - or not event.canvas.widgetlock.available(self)): + axs = [ax for ax in self.axes if ax.contains(event)[0]] + if self.ignore(event) or not axs or not event.canvas.widgetlock.available(self): return + ax = cbook._topmost_artist(axs) + xdata, ydata = ((event.xdata, event.ydata) if event.inaxes is ax + else ax.transData.inverted().transform((event.x, event.y))) for line in self.vlines: - line.set_xdata((event.xdata, event.xdata)) + line.set_xdata((xdata, xdata)) line.set_visible(self.visible and self.vertOn) for line in self.hlines: - line.set_ydata((event.ydata, event.ydata)) + line.set_ydata((ydata, ydata)) line.set_visible(self.visible and self.horizOn) if self.visible and (self.vertOn or self.horizOn): self._update() @@ -2271,15 +2275,14 @@ def ignore(self, event): if (self.validButtons is not None and event.button not in self.validButtons): return True - # If no button was pressed yet ignore the event if it was out - # of the Axes + # If no button was pressed yet ignore the event if it was out of the Axes. if self._eventpress is None: - return event.inaxes != self.ax + return not self.ax.contains(event)[0] # If a button was pressed, check if the release-button is the same. if event.button == self._eventpress.button: return False # If a button was pressed, check if the release-button is the same. - return (event.inaxes != self.ax or + return (not self.ax.contains(event)[0] or event.button != self._eventpress.button) def update(self): @@ -2307,8 +2310,9 @@ def _get_data(self, event): """Get the xdata and ydata for event, with limits.""" if event.xdata is None: return None, None - xdata = np.clip(event.xdata, *self.ax.get_xbound()) - ydata = np.clip(event.ydata, *self.ax.get_ybound()) + xdata, ydata = self._get_data_coords(event) + xdata = np.clip(xdata, *self.ax.get_xbound()) + ydata = np.clip(ydata, *self.ax.get_ybound()) return xdata, ydata def _clean_event(self, event): @@ -2316,7 +2320,8 @@ def _clean_event(self, event): Preprocess an event: - Replace *event* by the previous event if *event* has no ``xdata``. - - Clip ``xdata`` and ``ydata`` to the axes limits. + - Get ``xdata`` and ``ydata`` from this widget's axes, and clip them to the axes + limits. - Update the previous event. """ if event.xdata is None: @@ -2746,7 +2751,8 @@ def _press(self, event): # Clear previous rectangle before drawing new rectangle. self.update() - v = event.xdata if self.direction == 'horizontal' else event.ydata + xdata, ydata = self._get_data_coords(event) + v = xdata if self.direction == 'horizontal' else ydata if self._active_handle is None and not self.ignore_event_outside: # when the press event outside the span, we initially set the @@ -2832,10 +2838,12 @@ def _hover(self, event): def _onmove(self, event): """Motion notify event handler.""" - v = event.xdata if self.direction == 'horizontal' else event.ydata + xdata, ydata = self._get_data_coords(event) if self.direction == 'horizontal': + v = xdata vpress = self._eventpress.xdata else: + v = ydata vpress = self._eventpress.ydata # move existing span @@ -3317,8 +3325,7 @@ def _init_shape(self, **props): def _press(self, event): """Button press event handler.""" - # make the drawn box/line visible get the click-coordinates, - # button, ... + # make the drawn box/line visible get the click-coordinates, button, ... if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) else: @@ -3331,8 +3338,7 @@ def _press(self, event): if (self._active_handle is None and not self.ignore_event_outside and self._allow_creation): - x = event.xdata - y = event.ydata + x, y = self._get_data_coords(event) self._visible = False self.extents = x, x, y, y self._visible = True @@ -3407,21 +3413,19 @@ def _onmove(self, event): # The calculations are done for rotation at zero: we apply inverse # transformation to events except when we rotate and move state = self._state - rotate = ('rotate' in state and - self._active_handle in self._corner_order) + rotate = 'rotate' in state and self._active_handle in self._corner_order move = self._active_handle == 'C' resize = self._active_handle and not move + xdata, ydata = self._get_data_coords(event) if resize: inv_tr = self._get_rotation_transform().inverted() - event.xdata, event.ydata = inv_tr.transform( - [event.xdata, event.ydata]) + xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( - [eventpress.xdata, eventpress.ydata] - ) + (eventpress.xdata, eventpress.ydata)) - dx = event.xdata - eventpress.xdata - dy = event.ydata - eventpress.ydata + dx = xdata - eventpress.xdata + dy = ydata - eventpress.ydata # refmax is used when moving the corner handle with the square state # and is the maximum between refx and refy refmax = None @@ -3436,16 +3440,16 @@ def _onmove(self, event): # rotate an existing shape if rotate: # calculate angle abc - a = np.array([eventpress.xdata, eventpress.ydata]) - b = np.array(self.center) - c = np.array([event.xdata, event.ydata]) + a = (eventpress.xdata, eventpress.ydata) + b = self.center + c = (xdata, ydata) angle = (np.arctan2(c[1]-b[1], c[0]-b[0]) - np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) elif resize: size_on_press = [x1 - x0, y1 - y0] - center = [x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2] + center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) # Keeping the center fixed if 'center' in state: @@ -3455,19 +3459,19 @@ def _onmove(self, event): if self._active_handle in self._corner_order: refmax = max(refx, refy, key=abs) if self._active_handle in ['E', 'W'] or refmax == refx: - hw = event.xdata - center[0] + hw = xdata - center[0] hh = hw / self._aspect_ratio_correction else: - hh = event.ydata - center[1] + hh = ydata - center[1] hw = hh * self._aspect_ratio_correction else: hw = size_on_press[0] / 2 hh = size_on_press[1] / 2 # cancel changes in perpendicular direction if self._active_handle in ['E', 'W'] + self._corner_order: - hw = abs(event.xdata - center[0]) + hw = abs(xdata - center[0]) if self._active_handle in ['N', 'S'] + self._corner_order: - hh = abs(event.ydata - center[1]) + hh = abs(ydata - center[1]) x0, x1, y0, y1 = (center[0] - hw, center[0] + hw, center[1] - hh, center[1] + hh) @@ -3480,26 +3484,24 @@ def _onmove(self, event): if 'S' in self._active_handle: y0 = y1 if self._active_handle in ['E', 'W'] + self._corner_order: - x1 = event.xdata + x1 = xdata if self._active_handle in ['N', 'S'] + self._corner_order: - y1 = event.ydata + y1 = ydata if 'square' in state: # when using a corner, find which reference to use if self._active_handle in self._corner_order: refmax = max(refx, refy, key=abs) if self._active_handle in ['E', 'W'] or refmax == refx: - sign = np.sign(event.ydata - y0) - y1 = y0 + sign * abs(x1 - x0) / \ - self._aspect_ratio_correction + sign = np.sign(ydata - y0) + y1 = y0 + sign * abs(x1 - x0) / self._aspect_ratio_correction else: - sign = np.sign(event.xdata - x0) - x1 = x0 + sign * abs(y1 - y0) * \ - self._aspect_ratio_correction + sign = np.sign(xdata - x0) + x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction elif move: x0, x1, y0, y1 = self._extents_on_press - dx = event.xdata - eventpress.xdata - dy = event.ydata - eventpress.ydata + dx = xdata - eventpress.xdata + dy = ydata - eventpress.ydata x0 += dx x1 += dx y0 += dy @@ -3514,8 +3516,8 @@ def _onmove(self, event): not self._allow_creation): return center = [eventpress.xdata, eventpress.ydata] - dx = (event.xdata - center[0]) / 2. - dy = (event.ydata - center[1]) / 2. + dx = (xdata - center[0]) / 2 + dy = (ydata - center[1]) / 2 # square shape if 'square' in state: @@ -4058,7 +4060,7 @@ def _release(self, event): elif (not self._selection_completed and 'move_all' not in self._state and 'move_vertex' not in self._state): - self._xys.insert(-1, (event.xdata, event.ydata)) + self._xys.insert(-1, self._get_data_coords(event)) if self._selection_completed: self.onselect(self.verts) @@ -4080,16 +4082,17 @@ def _onmove(self, event): # Move the active vertex (ToolHandle). if self._active_handle_idx >= 0: idx = self._active_handle_idx - self._xys[idx] = event.xdata, event.ydata + self._xys[idx] = self._get_data_coords(event) # Also update the end of the polygon line if the first vertex is # the active handle and the polygon is completed. if idx == 0 and self._selection_completed: - self._xys[-1] = event.xdata, event.ydata + self._xys[-1] = self._get_data_coords(event) # Move all vertices. elif 'move_all' in self._state and self._eventpress: - dx = event.xdata - self._eventpress.xdata - dy = event.ydata - self._eventpress.ydata + xdata, ydata = self._get_data_coords(event) + dx = xdata - self._eventpress.xdata + dy = ydata - self._eventpress.ydata for k in range(len(self._xys)): x_at_press, y_at_press = self._xys_at_press[k] self._xys[k] = x_at_press + dx, y_at_press + dy @@ -4109,7 +4112,7 @@ def _onmove(self, event): if len(self._xys) > 3 and v0_dist < self.grab_range: self._xys[-1] = self._xys[0] else: - self._xys[-1] = event.xdata, event.ydata + self._xys[-1] = self._get_data_coords(event) self._draw_polygon() @@ -4131,12 +4134,12 @@ def _on_key_release(self, event): and (event.key == self._state_modifier_keys.get('move_vertex') or event.key == self._state_modifier_keys.get('move_all'))): - self._xys.append((event.xdata, event.ydata)) + self._xys.append(self._get_data_coords(event)) self._draw_polygon() # Reset the polygon if the released key is the 'clear' key. elif event.key == self._state_modifier_keys.get('clear'): event = self._clean_event(event) - self._xys = [(event.xdata, event.ydata)] + self._xys = [self._get_data_coords(event)] self._selection_completed = False self._remove_box() self.set_visible(True) @@ -4232,7 +4235,7 @@ def onrelease(self, event): if self.ignore(event): return if self.verts is not None: - self.verts.append((event.xdata, event.ydata)) + self.verts.append(self._get_data_coords(event)) if len(self.verts) > 2: self.callback(self.verts) self.line.remove() @@ -4240,16 +4243,12 @@ def onrelease(self, event): self.disconnect_events() def onmove(self, event): - if self.ignore(event): - return - if self.verts is None: - return - if event.inaxes != self.ax: - return - if event.button != 1: + if (self.ignore(event) + or self.verts is None + or event.button != 1 + or not self.ax.contains(event)[0]): return - self.verts.append((event.xdata, event.ydata)) - + self.verts.append(self._get_data_coords(event)) self.line.set_data(list(zip(*self.verts))) if self.useblit: