From cd37b7373c29549d24f8ba8a7726b5f990c1dec6 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 23 Jun 2024 13:28:03 +0200 Subject: [PATCH] Stop relying on dead-reckoning mouse buttons for motion_notify_event. Previously, for motion_notify_event, `event.attribute` was set by checking the last button_press_event/button_release event. This is brittle for the same reason as to why we introduced `event.modifiers` to improve over `event.key`, so introduce the more robust `event.buttons` (see detailed discussion in the attribute docstring). For a concrete example, consider e.g. from matplotlib import pyplot as plt from matplotlib.backends.qt_compat import QtWidgets def on_button_press(event): if event.button != 3: # Right-click. return menu = QtWidgets.QMenu() menu.addAction("Some menu action", lambda: None) menu.exec(event.guiEvent.globalPosition().toPoint()) fig = plt.figure() fig.canvas.mpl_connect("button_press_event", on_button_press) fig.add_subplot() plt.show() (connecting a contextual menu on right button click) where a right click while having selected zoom mode on the toolbar starts a zoom mode that stays on even after the mouse release (because the mouse release event is received by the menu widget, not by the main canvas). This PR does not fix the issue, but will allow a followup fix (where the motion_notify_event associated with zoom mode will be able to first check whether the button is indeed still pressed when the motion occurs). Limitations, on macOS only (everything works on Linux and Windows AFAICT): - tk only reports a single pressed button even if multiple buttons are pressed. - gtk4 spams the terminal with Gtk-WARNINGs: "Broken accounting of active state for widget ..." on right-clicks only; similar issues appear to have been reported a while ago to Gtk (https://gitlab.gnome.org/GNOME/gtk/-/issues/3356 and linked issues) but it's unclear whether any action was taken on their side. (Alternatively, some GUI toolkits have a "permissive" notion of drag events defined as mouse moves with a button pressed, which we could use as well to define event.button{,s} for motion_notify_event, but e.g. Qt attaches quite heavy semantics to drags which we probably don't want to bother with.) --- lib/matplotlib/backend_bases.py | 35 +++++++++- lib/matplotlib/backend_bases.pyi | 1 + lib/matplotlib/backends/_backend_tk.py | 33 +++++++-- lib/matplotlib/backends/backend_gtk3.py | 17 ++++- lib/matplotlib/backends/backend_gtk4.py | 25 ++++++- lib/matplotlib/backends/backend_qt.py | 8 +++ .../backends/backend_webagg_core.py | 19 +++-- lib/matplotlib/backends/backend_wx.py | 22 +++++- lib/matplotlib/backends/web_backend/js/mpl.js | 1 + src/_macosx.m | 70 ++++++++++++------- 10 files changed, 187 insertions(+), 44 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 95ed49612b35..42ace924c39d 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1324,6 +1324,28 @@ class MouseEvent(LocationEvent): If this is unset, *name* is "scroll_event", and *step* is nonzero, then this will be set to "up" or "down" depending on the sign of *step*. + buttons : None or frozenset + For 'motion_notify_event', the mouse buttons currently being pressed + (a set of zero or more MouseButtons); + for other events, None. + + .. note:: + For 'motion_notify_event', this attribute is more accurate than + the ``button`` (singular) attribute, which is obtained from the last + 'button_press_event' or 'button_release_event' that occurred within + the canvas (and thus 1. be wrong if the last change in mouse state + occurred when the canvas did not have focus, and 2. cannot report + when multiple buttons are pressed). + + This attribute is not set for 'button_press_event' and + 'button_release_event' because GUI toolkits are inconsistent as to + whether they report the button state *before* or *after* the + press/release occurred. + + .. warning:: + On macOS, the Tk backends only report a single button even if + multiple buttons are pressed. + key : None or str The key pressed when the mouse event triggered, e.g. 'shift'. See `KeyEvent`. @@ -1356,7 +1378,8 @@ def on_press(event): """ def __init__(self, name, canvas, x, y, button=None, key=None, - step=0, dblclick=False, guiEvent=None, *, modifiers=None): + step=0, dblclick=False, guiEvent=None, *, + buttons=None, modifiers=None): super().__init__( name, canvas, x, y, guiEvent=guiEvent, modifiers=modifiers) if button in MouseButton.__members__.values(): @@ -1367,6 +1390,16 @@ def __init__(self, name, canvas, x, y, button=None, key=None, elif step < 0: button = "down" self.button = button + if name == "motion_notify_event": + self.buttons = frozenset(buttons if buttons is not None else []) + else: + # We don't support 'buttons' for button_press/release_event because + # toolkits are inconsistent as to whether they report the state + # before or after the event. + if buttons: + raise ValueError( + "'buttons' is only supported for 'motion_notify_event'") + self.buttons = None self.key = key self.step = step self.dblclick = dblclick diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 70be504666fc..637f8b377848 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -258,6 +258,7 @@ class MouseEvent(LocationEvent): dblclick: bool = ..., guiEvent: Any | None = ..., *, + buttons: Iterable[MouseButton] | None = ..., modifiers: Iterable[str] | None = ..., ) -> None: ... diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index d7c2f59be6b9..33fc279bef06 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -19,7 +19,7 @@ from matplotlib import _api, backend_tools, cbook, _c_internal_utils from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, - TimerBase, ToolContainerBase, cursors, _Mode, + TimerBase, ToolContainerBase, cursors, _Mode, MouseButton, CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) from matplotlib._pylab_helpers import Gcf from . import _tkagg @@ -296,6 +296,7 @@ def _event_mpl_coords(self, event): def motion_notify_event(self, event): MouseEvent("motion_notify_event", self, *self._event_mpl_coords(event), + buttons=self._mpl_buttons(event), modifiers=self._mpl_modifiers(event), guiEvent=event)._process() @@ -357,13 +358,33 @@ def scroll_event_windows(self, event): x, y, step=step, modifiers=self._mpl_modifiers(event), guiEvent=event)._process() + @staticmethod + def _mpl_buttons(event): # See _mpl_modifiers. + # NOTE: This fails to report multiclicks on macOS; only one button is + # reported (multiclicks work correctly on Linux & Windows). + modifiers = [ + # macOS appears to swap right and middle (look for "Swap buttons + # 2/3" in tk/macosx/tkMacOSXMouseEvent.c). + (MouseButton.LEFT, 1 << 8), + (MouseButton.RIGHT, 1 << 9), + (MouseButton.MIDDLE, 1 << 10), + (MouseButton.BACK, 1 << 11), + (MouseButton.FORWARD, 1 << 12), + ] if sys.platform == "darwin" else [ + (MouseButton.LEFT, 1 << 8), + (MouseButton.MIDDLE, 1 << 9), + (MouseButton.RIGHT, 1 << 10), + (MouseButton.BACK, 1 << 11), + (MouseButton.FORWARD, 1 << 12), + ] + # State *before* press/release. + return [name for name, mask in modifiers if event.state & mask] + @staticmethod def _mpl_modifiers(event, *, exclude=None): - # add modifier keys to the key string. Bit details originate from - # http://effbot.org/tkinterbook/tkinter-events-and-bindings.htm - # BIT_SHIFT = 0x001; BIT_CAPSLOCK = 0x002; BIT_CONTROL = 0x004; - # BIT_LEFT_ALT = 0x008; BIT_NUMLOCK = 0x010; BIT_RIGHT_ALT = 0x080; - # BIT_MB_1 = 0x100; BIT_MB_2 = 0x200; BIT_MB_3 = 0x400; + # Add modifier keys to the key string. Bit values are inferred from + # the implementation of tkinter.Event.__repr__ (1, 2, 4, 8, ... = + # Shift, Lock, Control, Mod1, ..., Mod5, Button1, ..., Button5) # In general, the modifier key is excluded from the modifier flag, # however this is not the case on "darwin", so double check that # we aren't adding repeat modifier flags to a modifier key. diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 49d34f5794e4..d7583bfc1c02 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -6,8 +6,8 @@ import matplotlib as mpl from matplotlib import _api, backend_tools, cbook from matplotlib.backend_bases import ( - ToolContainerBase, CloseEvent, KeyEvent, LocationEvent, MouseEvent, - ResizeEvent) + ToolContainerBase, MouseButton, + CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) try: import gi @@ -156,6 +156,7 @@ def key_release_event(self, widget, event): def motion_notify_event(self, widget, event): MouseEvent("motion_notify_event", self, *self._mpl_coords(event), + buttons=self._mpl_buttons(event.state), modifiers=self._mpl_modifiers(event.state), guiEvent=event)._process() return False # finish event propagation? @@ -182,6 +183,18 @@ def size_allocate(self, widget, allocation): ResizeEvent("resize_event", self)._process() self.draw_idle() + @staticmethod + def _mpl_buttons(event_state): + modifiers = [ + (MouseButton.LEFT, Gdk.ModifierType.BUTTON1_MASK), + (MouseButton.MIDDLE, Gdk.ModifierType.BUTTON2_MASK), + (MouseButton.RIGHT, Gdk.ModifierType.BUTTON3_MASK), + (MouseButton.BACK, Gdk.ModifierType.BUTTON4_MASK), + (MouseButton.FORWARD, Gdk.ModifierType.BUTTON5_MASK), + ] + # State *before* press/release. + return [name for name, mask in modifiers if event_state & mask] + @staticmethod def _mpl_modifiers(event_state, *, exclude=None): modifiers = [ diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 272890940f81..d57ccffb3a8f 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -5,8 +5,8 @@ import matplotlib as mpl from matplotlib import _api, backend_tools, cbook from matplotlib.backend_bases import ( - ToolContainerBase, KeyEvent, LocationEvent, MouseEvent, ResizeEvent, - CloseEvent) + ToolContainerBase, MouseButton, + KeyEvent, LocationEvent, MouseEvent, ResizeEvent, CloseEvent) try: import gi @@ -155,6 +155,7 @@ def key_release_event(self, controller, keyval, keycode, state): def motion_notify_event(self, controller, x, y): MouseEvent( "motion_notify_event", self, *self._mpl_coords((x, y)), + buttons=self._mpl_buttons(controller), modifiers=self._mpl_modifiers(controller), guiEvent=controller.get_current_event(), )._process() @@ -182,6 +183,26 @@ def resize_event(self, area, width, height): ResizeEvent("resize_event", self)._process() self.draw_idle() + def _mpl_buttons(self, controller): + # NOTE: This spews "Broken accounting of active state" warnings on + # right click on macOS. + surface = self.get_native().get_surface() + is_over, x, y, event_state = surface.get_device_position( + self.get_display().get_default_seat().get_pointer()) + # NOTE: alternatively we could use + # event_state = controller.get_current_event_state() + # but for button_press/button_release this would report the state + # *prior* to the event rather than after it; the above reports the + # state *after* it. + mod_table = [ + (MouseButton.LEFT, Gdk.ModifierType.BUTTON1_MASK), + (MouseButton.MIDDLE, Gdk.ModifierType.BUTTON2_MASK), + (MouseButton.RIGHT, Gdk.ModifierType.BUTTON3_MASK), + (MouseButton.BACK, Gdk.ModifierType.BUTTON4_MASK), + (MouseButton.FORWARD, Gdk.ModifierType.BUTTON5_MASK), + ] + return {name for name, mask in mod_table if event_state & mask} + def _mpl_modifiers(self, controller=None): if controller is None: surface = self.get_native().get_surface() diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index bc37a15c7a67..e0d1feee17b0 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -329,6 +329,7 @@ def mouseMoveEvent(self, event): return MouseEvent("motion_notify_event", self, *self.mouseEventCoords(event), + buttons=self._mpl_buttons(event.buttons()), modifiers=self._mpl_modifiers(), guiEvent=event)._process() @@ -396,6 +397,13 @@ def sizeHint(self): def minimumSizeHint(self): return QtCore.QSize(10, 10) + @staticmethod + def _mpl_buttons(buttons): + buttons = _to_int(buttons) + # State *after* press/release. + return {button for mask, button in FigureCanvasQT.buttond.items() + if _to_int(mask) & buttons} + @staticmethod def _mpl_modifiers(modifiers=None, *, exclude=None): if modifiers is None: diff --git a/lib/matplotlib/backends/backend_webagg_core.py b/lib/matplotlib/backends/backend_webagg_core.py index 4ceac1699543..414a309ee0a6 100644 --- a/lib/matplotlib/backends/backend_webagg_core.py +++ b/lib/matplotlib/backends/backend_webagg_core.py @@ -22,7 +22,7 @@ from matplotlib import _api, backend_bases, backend_tools from matplotlib.backends import backend_agg from matplotlib.backend_bases import ( - _Backend, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) + _Backend, MouseButton, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) _log = logging.getLogger(__name__) @@ -283,10 +283,17 @@ def _handle_mouse(self, event): y = event['y'] y = self.get_renderer().height - y self._last_mouse_xy = x, y - # JavaScript button numbers and Matplotlib button numbers are off by 1. - button = event['button'] + 1 - e_type = event['type'] + button = event['button'] + 1 # JS numbers off by 1 compared to mpl. + buttons = { # JS ordering different compared to mpl. + button for button, mask in [ + (MouseButton.LEFT, 1), + (MouseButton.RIGHT, 2), + (MouseButton.MIDDLE, 4), + (MouseButton.BACK, 8), + (MouseButton.FORWARD, 16), + ] if event['buttons'] & mask # State *after* press/release. + } modifiers = event['modifiers'] guiEvent = event.get('guiEvent') if e_type in ['button_press', 'button_release']: @@ -300,10 +307,12 @@ def _handle_mouse(self, event): modifiers=modifiers, guiEvent=guiEvent)._process() elif e_type == 'motion_notify': MouseEvent(e_type + '_event', self, x, y, - modifiers=modifiers, guiEvent=guiEvent)._process() + buttons=buttons, modifiers=modifiers, guiEvent=guiEvent, + )._process() elif e_type in ['figure_enter', 'figure_leave']: LocationEvent(e_type + '_event', self, x, y, modifiers=modifiers, guiEvent=guiEvent)._process() + handle_button_press = handle_button_release = handle_dblclick = \ handle_figure_enter = handle_figure_leave = handle_motion_notify = \ handle_scroll = _handle_mouse diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index c7e26b92134a..e3de47ccbf13 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -685,6 +685,22 @@ def _on_size(self, event): ResizeEvent("resize_event", self)._process() self.draw_idle() + @staticmethod + def _mpl_buttons(): + state = wx.GetMouseState() + # NOTE: Alternatively, we could use event.LeftIsDown() / etc. but this + # fails to report multiclick drags on macOS (other OSes have not been + # verified). + mod_table = [ + (MouseButton.LEFT, state.LeftIsDown()), + (MouseButton.RIGHT, state.RightIsDown()), + (MouseButton.MIDDLE, state.MiddleIsDown()), + (MouseButton.BACK, state.Aux1IsDown()), + (MouseButton.FORWARD, state.Aux2IsDown()), + ] + # State *after* press/release. + return {button for button, flag in mod_table if flag} + @staticmethod def _mpl_modifiers(event=None, *, exclude=None): mod_table = [ @@ -794,9 +810,8 @@ def _on_mouse_button(self, event): MouseEvent("button_press_event", self, x, y, button, modifiers=modifiers, guiEvent=event)._process() elif event.ButtonDClick(): - MouseEvent("button_press_event", self, x, y, button, - dblclick=True, modifiers=modifiers, - guiEvent=event)._process() + MouseEvent("button_press_event", self, x, y, button, dblclick=True, + modifiers=modifiers, guiEvent=event)._process() elif event.ButtonUp(): MouseEvent("button_release_event", self, x, y, button, modifiers=modifiers, guiEvent=event)._process() @@ -826,6 +841,7 @@ def _on_motion(self, event): event.Skip() MouseEvent("motion_notify_event", self, *self._mpl_coords(event), + buttons=self._mpl_buttons(), modifiers=self._mpl_modifiers(event), guiEvent=event)._process() diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index 6e8ec449d92b..2d1f383e9839 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -644,6 +644,7 @@ mpl.figure.prototype.mouse_event = function (event, name) { y: y, button: event.button, step: event.step, + buttons: event.buttons, modifiers: getModifiers(event), guiEvent: simpleKeys(event), }); diff --git a/src/_macosx.m b/src/_macosx.m index 09838eccaf98..5bd75dd5fbff 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -257,20 +257,43 @@ static CGFloat _get_device_scale(CGContextRef cr) return pixelSize.width; } -bool -mpl_check_modifier( - NSUInteger modifiers, NSEventModifierFlags flag, - PyObject* list, char const* name) -{ - bool failed = false; - if (modifiers & flag) { - PyObject* py_name = NULL; - if (!(py_name = PyUnicode_FromString(name)) - || PyList_Append(list, py_name)) { - failed = true; - } - Py_XDECREF(py_name); +bool mpl_check_button(bool present, PyObject* set, char const* name) { + PyObject* module = NULL, * cls = NULL, * button = NULL; + bool failed = ( + present + && (!(module = PyImport_ImportModule("matplotlib.backend_bases")) + || !(cls = PyObject_GetAttrString(module, "MouseButton")) + || !(button = PyObject_GetAttrString(cls, name)) + || PySet_Add(set, button))); + Py_XDECREF(module); + Py_XDECREF(cls); + Py_XDECREF(button); + return failed; +} + +PyObject* mpl_buttons() +{ + PyGILState_STATE gstate = PyGILState_Ensure(); + PyObject* set = NULL; + NSUInteger buttons = [NSEvent pressedMouseButtons]; + if (!(set = PySet_New(NULL)) + || mpl_check_button(buttons & (1 << 0), set, "LEFT") + || mpl_check_button(buttons & (1 << 1), set, "RIGHT") + || mpl_check_button(buttons & (1 << 2), set, "MIDDLE")) { + Py_CLEAR(set); // On failure, return NULL with an exception set. } + PyGILState_Release(gstate); + return set; +} + +bool mpl_check_modifier(bool present, PyObject* list, char const* name) +{ + PyObject* py_name = NULL; + bool failed = ( + present + && (!(py_name = PyUnicode_FromString(name)) + || (PyList_Append(list, py_name)))); + Py_XDECREF(py_name); return failed; } @@ -278,17 +301,14 @@ static CGFloat _get_device_scale(CGContextRef cr) { PyGILState_STATE gstate = PyGILState_Ensure(); PyObject* list = NULL; - if (!(list = PyList_New(0))) { - goto exit; - } NSUInteger modifiers = [event modifierFlags]; - if (mpl_check_modifier(modifiers, NSEventModifierFlagControl, list, "ctrl") - || mpl_check_modifier(modifiers, NSEventModifierFlagOption, list, "alt") - || mpl_check_modifier(modifiers, NSEventModifierFlagShift, list, "shift") - || mpl_check_modifier(modifiers, NSEventModifierFlagCommand, list, "cmd")) { + if (!(list = PyList_New(0)) + || mpl_check_modifier(modifiers & NSEventModifierFlagControl, list, "ctrl") + || mpl_check_modifier(modifiers & NSEventModifierFlagOption, list, "alt") + || mpl_check_modifier(modifiers & NSEventModifierFlagShift, list, "shift") + || mpl_check_modifier(modifiers & NSEventModifierFlagCommand, list, "cmd")) { Py_CLEAR(list); // On failure, return NULL with an exception set. } -exit: PyGILState_Release(gstate); return list; } @@ -1448,9 +1468,9 @@ - (void)mouseMoved:(NSEvent *)event x = location.x * device_scale; y = location.y * device_scale; process_event( - "MouseEvent", "{s:s, s:O, s:i, s:i, s:N}", + "MouseEvent", "{s:s, s:O, s:i, s:i, s:N, s:N}", "name", "motion_notify_event", "canvas", canvas, "x", x, "y", y, - "modifiers", mpl_modifiers(event)); + "buttons", mpl_buttons(), "modifiers", mpl_modifiers(event)); } - (void)mouseDragged:(NSEvent *)event @@ -1461,9 +1481,9 @@ - (void)mouseDragged:(NSEvent *)event x = location.x * device_scale; y = location.y * device_scale; process_event( - "MouseEvent", "{s:s, s:O, s:i, s:i, s:N}", + "MouseEvent", "{s:s, s:O, s:i, s:i, s:N, s:N}", "name", "motion_notify_event", "canvas", canvas, "x", x, "y", y, - "modifiers", mpl_modifiers(event)); + "buttons", mpl_buttons(), "modifiers", mpl_modifiers(event)); } - (void)rightMouseDown:(NSEvent *)event { [self mouseDown: event]; }