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]; }