From 065a172b69bbb7e74e8991f4404ae310dc5a7d95 Mon Sep 17 00:00:00 2001 From: Alex Burger Date: Mon, 17 Aug 2026 20:00:44 -0400 Subject: [PATCH 1/2] keybindings: Don't trigger modifier-only bindings after zoom scrolling Scroll events consumed for the a11y mouse-wheel zoom bypass meta_keybindings_process_event entirely, so nothing cleared the modifier-only key state: pressing Super, zooming with the wheel and releasing Super still looked like a bare modifier tap and activated the overlay-key binding, popping up the Cinnamon menu after every zoom. Cancel the pending modifier-only state when a zoom scroll is consumed, and treat scroll events like button presses and touches when tracking modifier-only key state. Ref: #695 Ref: linuxmint/cinnamon#12587 Co-Authored-By: Claude Fable 5 --- src/core/events.c | 4 ++++ src/core/keybindings-private.h | 1 + src/core/keybindings.c | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/src/core/events.c b/src/core/events.c index 1ae1e933e..f6a3a54c5 100644 --- a/src/core/events.c +++ b/src/core/events.c @@ -291,6 +291,10 @@ meta_display_handle_event (MetaDisplay *display, meta_display_a11y_zoom (display, FALSE); } + /* Don't let the zoom modifier's release trigger a + * modifier-only keybinding (e.g. Super opening the menu) */ + meta_keybindings_cancel_modifier_only (display); + bypass_wayland = bypass_clutter = TRUE; goto out; } diff --git a/src/core/keybindings-private.h b/src/core/keybindings-private.h index 0e3586869..c7dcaeaad 100644 --- a/src/core/keybindings-private.h +++ b/src/core/keybindings-private.h @@ -144,6 +144,7 @@ int meta_keybindings_get_mouse_zoom_modifiers (MetaDisplay *display); ClutterModifierType meta_display_get_window_grab_modifiers (MetaDisplay *display); uint meta_keybindings_get_ignored_modifier_mask (MetaDisplay *display); +void meta_keybindings_cancel_modifier_only (MetaDisplay *display); gboolean meta_prefs_add_keybinding (const char *name, GSettings *settings, diff --git a/src/core/keybindings.c b/src/core/keybindings.c index 4a8a9a495..4137ec66e 100644 --- a/src/core/keybindings.c +++ b/src/core/keybindings.c @@ -2474,6 +2474,7 @@ meta_keybindings_process_event (MetaDisplay *display, case CLUTTER_BUTTON_RELEASE: case CLUTTER_TOUCH_BEGIN: case CLUTTER_TOUCH_END: + case CLUTTER_SCROLL: modifier_key_only_pressed = FALSE; return FALSE; @@ -4207,6 +4208,12 @@ meta_keybindings_get_ignored_modifier_mask (MetaDisplay *display) return keys->ignored_modifier_mask; } +void +meta_keybindings_cancel_modifier_only (MetaDisplay *display) +{ + modifier_key_only_pressed = FALSE; +} + static void init_builtin_key_bindings (MetaDisplay *display) { From dc7ff5a3214b16452052499836845e16b1d1959b Mon Sep 17 00:00:00 2001 From: Alex Burger Date: Mon, 17 Aug 2026 20:00:54 -0400 Subject: [PATCH 2/2] keybindings: Grab the pointer during zoom scroll bursts on X11 The a11y mouse-wheel zoom relies on passive button 4/5 grabs, which only intercept the emulated legacy scroll button events. XInput2-aware clients (GTK3 apps, Chromium/Electron, Firefox with MOZ_USE_XINPUT2=1) scroll with the smooth-scroll XI_Motion valuator events instead, and those are delivered to the client before any passive button grab can activate. As a result, zooming also scrolled the window under the pointer. Take an active grab on the virtual core pointer when a zoom scroll is consumed and hold it for the duration of the scroll burst; it is released after a short idle timeout, on a button press, or as soon as the zoom modifier is released (tracked via XkbStateNotify). While the grab is held, the smooth-scroll motion events are routed to muffin and can no longer leak to the client. The grab is deliberately not held for the whole time the modifier is down: modifier+click interactions (Shift+click selection, Ctrl+click) must keep working, and a click swallowed by an active grab cannot be re-injected - the physical press has already registered in the master device's button state, so a synthetic press is discarded by the server as a duplicate. The only remaining leak is the first scroll event of a burst, which has been processed by the time the grab can engage. Wayland sessions are unaffected: muffin sees all events there and already consumes them before delivery to the client. Fixes #695 Ref: linuxmint/cinnamon#12587 Co-Authored-By: Claude Fable 5 --- src/backends/x11/meta-backend-x11.c | 15 ++++ src/core/events.c | 9 +++ src/core/keybindings-private.h | 12 ++++ src/core/keybindings.c | 104 ++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+) diff --git a/src/backends/x11/meta-backend-x11.c b/src/backends/x11/meta-backend-x11.c index d6819dfd6..4e8a0a73e 100644 --- a/src/backends/x11/meta-backend-x11.c +++ b/src/backends/x11/meta-backend-x11.c @@ -53,6 +53,7 @@ #include "clutter/x11/clutter-x11.h" #include "compositor/compositor-private.h" #include "core/display-private.h" +#include "core/keybindings-private.h" #include "meta/meta-cursor-tracker.h" #include "meta/util.h" @@ -425,6 +426,12 @@ handle_host_xevent (MetaBackend *backend, meta_backend_notify_keymap_layout_group_changed (backend, layout_group); } + if (display && + (xkb_ev->state.changed & (XkbModifierStateMask | + XkbModifierBaseMask))) + meta_display_process_zoom_modifier_state (display, + xkb_ev->state.mods, + xkb_ev->state.time); break; default: break; @@ -580,6 +587,14 @@ meta_backend_x11_post_init (MetaBackend *backend) meta_fatal ("X server doesn't have the XKB extension, version %d.%d or newer\n", XKB_X11_MIN_MAJOR_XKB_VERSION, XKB_X11_MIN_MINOR_XKB_VERSION); + /* Make sure modifier state changes are among the selected + * XkbStateNotify details; the zoom modifier pointer grab depends on + * them (see meta_display_process_zoom_modifier_state). Only the + * listed detail bits are affected, existing selections remain. */ + XkbSelectEventDetails (priv->xdisplay, XkbUseCoreKbd, XkbStateNotify, + XkbModifierStateMask | XkbModifierBaseMask, + XkbModifierStateMask | XkbModifierBaseMask); + META_BACKEND_CLASS (meta_backend_x11_parent_class)->post_init (backend); monitor_manager = meta_backend_get_monitor_manager (backend); diff --git a/src/core/events.c b/src/core/events.c index f6a3a54c5..d704e3806 100644 --- a/src/core/events.c +++ b/src/core/events.c @@ -295,11 +295,20 @@ meta_display_handle_event (MetaDisplay *display, * modifier-only keybinding (e.g. Super opening the menu) */ meta_keybindings_cancel_modifier_only (display); + /* On X11, hold the pointer for the duration of the scroll burst + * so smooth-scroll events can't leak to the client under the + * pointer. */ + meta_display_zoom_scroll_grab_notify (display, + clutter_event_get_time (event)); + bypass_wayland = bypass_clutter = TRUE; goto out; } } + if (event->type == CLUTTER_BUTTON_PRESS) + meta_display_zoom_grab_break (display, clutter_event_get_time (event)); + if (event->type != CLUTTER_DEVICE_ADDED && event->type != CLUTTER_DEVICE_REMOVED) { diff --git a/src/core/keybindings-private.h b/src/core/keybindings-private.h index c7dcaeaad..1dfbda162 100644 --- a/src/core/keybindings-private.h +++ b/src/core/keybindings-private.h @@ -126,6 +126,10 @@ typedef struct /* Alt+click button grabs */ ClutterModifierType window_grab_modifiers; ClutterModifierType mouse_zoom_modifiers; + + /* Active pointer grab held during zoom scroll bursts (X11) */ + gboolean zoom_pointer_grabbed; + guint zoom_grab_timeout_id; } MetaKeyBindingManager; void meta_display_init_keys (MetaDisplay *display); @@ -146,6 +150,14 @@ ClutterModifierType meta_display_get_window_grab_modifiers (MetaDisplay *display uint meta_keybindings_get_ignored_modifier_mask (MetaDisplay *display); void meta_keybindings_cancel_modifier_only (MetaDisplay *display); +void meta_display_process_zoom_modifier_state (MetaDisplay *display, + unsigned int mods, + guint32 timestamp); +void meta_display_zoom_scroll_grab_notify (MetaDisplay *display, + guint32 timestamp); +void meta_display_zoom_grab_break (MetaDisplay *display, + guint32 timestamp); + gboolean meta_prefs_add_keybinding (const char *name, GSettings *settings, const gchar **bindings, diff --git a/src/core/keybindings.c b/src/core/keybindings.c index 4137ec66e..9b8ab3981 100644 --- a/src/core/keybindings.c +++ b/src/core/keybindings.c @@ -1503,6 +1503,12 @@ meta_display_shutdown_keys (MetaDisplay *display) g_hash_table_destroy (keys->key_bindings_index); g_hash_table_destroy (keys->key_bindings); + if (keys->zoom_grab_timeout_id != 0) + { + g_source_remove (keys->zoom_grab_timeout_id); + keys->zoom_grab_timeout_id = 0; + } + clear_active_keyboard_layouts (keys); } @@ -4214,6 +4220,104 @@ meta_keybindings_cancel_modifier_only (MetaDisplay *display) modifier_key_only_pressed = FALSE; } +#define ZOOM_SCROLL_GRAB_TIMEOUT_MS 1000 + +/* While zoom scrolling is in progress on X11 we hold an active grab on + * the pointer. The passive button 4/5 grabs only intercept the emulated + * legacy scroll button events; XInput2-aware clients scroll with the + * smooth-scroll XI_Motion events, which are delivered before any passive + * button grab can activate. Only an active device grab keeps them from + * reaching the client under the pointer. + * + * The grab is only held for the duration of a scroll burst (released on + * a short idle timeout, on a button press, or when the zoom modifier is + * released) so that modifier+click interactions keep working. A click + * swallowed by the grab cannot be re-injected: the physical press has + * already registered in the master device's button state, so a synthetic + * press is discarded as a duplicate. + */ + +void +meta_display_zoom_grab_break (MetaDisplay *display, + guint32 timestamp) +{ + MetaKeyBindingManager *keys = &display->key_binding_manager; + + if (keys->zoom_grab_timeout_id != 0) + { + g_source_remove (keys->zoom_grab_timeout_id); + keys->zoom_grab_timeout_id = 0; + } + + if (!keys->zoom_pointer_grabbed) + return; + + /* If a compositor or window grab took over in the meantime, our + * grab was already replaced; ungrabbing would break theirs. */ + if (display->event_route == META_EVENT_ROUTE_NORMAL) + meta_backend_ungrab_device (keys->backend, + META_VIRTUAL_CORE_POINTER_ID, + timestamp); + keys->zoom_pointer_grabbed = FALSE; +} + +static gboolean +zoom_scroll_grab_timeout (gpointer data) +{ + MetaDisplay *display = data; + MetaKeyBindingManager *keys = &display->key_binding_manager; + + keys->zoom_grab_timeout_id = 0; + meta_display_zoom_grab_break (display, META_CURRENT_TIME); + return G_SOURCE_REMOVE; +} + +void +meta_display_zoom_scroll_grab_notify (MetaDisplay *display, + guint32 timestamp) +{ + MetaKeyBindingManager *keys = &display->key_binding_manager; + + if (meta_is_wayland_compositor ()) + return; + + if (!keys->zoom_pointer_grabbed && + display->event_route == META_EVENT_ROUTE_NORMAL && + display->grab_op == META_GRAB_OP_NONE) + { + keys->zoom_pointer_grabbed = + meta_backend_grab_device (keys->backend, + META_VIRTUAL_CORE_POINTER_ID, + timestamp); + } + + if (keys->zoom_pointer_grabbed) + { + if (keys->zoom_grab_timeout_id != 0) + g_source_remove (keys->zoom_grab_timeout_id); + keys->zoom_grab_timeout_id = g_timeout_add (ZOOM_SCROLL_GRAB_TIMEOUT_MS, + zoom_scroll_grab_timeout, + display); + } +} + +/* Called on XkbStateNotify: release the scroll grab as soon as the zoom + * modifier is no longer held. */ +void +meta_display_process_zoom_modifier_state (MetaDisplay *display, + unsigned int mods, + guint32 timestamp) +{ + MetaKeyBindingManager *keys = &display->key_binding_manager; + + if (!keys->zoom_pointer_grabbed) + return; + + if ((mods & ~keys->ignored_modifier_mask) != keys->mouse_zoom_modifiers) + meta_display_zoom_grab_break (display, timestamp); +} + + static void init_builtin_key_bindings (MetaDisplay *display) {