git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit3b8729236eb1cf448ee5184e90d58b23dccdc505
parent9f749ad0fe
authorLucas Galante <[email protected]>
date2026-07-15 14:53
fix(cursor): always pair a forwarded press with its release

The release path consumed ANY button release while a seat op was
active (and the overview block could drop one too), without checking
whether that press had been forwarded to a client — leaving the client
with an orphaned press. With trackpoint + touchpad both emitting
BTN_LEFT, an interleaved tap and border-drag reliably orphaned the
tap's press, wedging cce-ui's event router (observed live as the
data-editor File dropdown going dead).

The cursor now records which presses were forwarded
(notified_pressed); the paired release is delivered at the top of the
release path before any compositor-side consumption, and only then do
op/binding/overview branches run. The old conditional notify in the
normal branch is gone (it would double-send).

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/server/cursor.rs | 31 +++++++++++++++++++++----------
 1 file changed, 21 insertions(+), 10 deletions(-)

diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 58ef4c2..3a2c519 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -3,7 +3,7 @@ use crate::seat::{Seat, Focus};
 use crate::server::{WlListener, wl_listener_remove, wl_signal_add, WlList};
 use crate::scene_node_data::SceneNodeDataVal;
 use crate::drag_icon::DragIcon;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 
 pub struct Cursor {
     pub seat: *mut Seat,
@@ -24,6 +24,11 @@ pub struct Cursor {
 
     pub touch_points: HashMap<i32, (f64, f64)>,
     pub pressed: HashMap<u32, Option<*mut crate::pointer_binding::PointerBinding>>,
+    /// Buttons whose PRESS was forwarded to the focused client. The paired
+    /// release must reach the client no matter what the compositor is doing
+    /// by then (seat op, overview, …) — an orphaned press wedges client-side
+    /// input state (widget routers keep a grab armed forever).
+    pub notified_pressed: HashSet<u32>,
 
     pub touch_down_listener: ffi::wl_listener,
     pub touch_motion_listener: ffi::wl_listener,
@@ -79,6 +84,7 @@ impl Default for Cursor {
 
             touch_points: HashMap::new(),
             pressed: HashMap::new(),
+            notified_pressed: HashSet::new(),
 
             touch_down_listener: unsafe { std::mem::zeroed() },
             touch_motion_listener: unsafe { std::mem::zeroed() },
@@ -1162,6 +1168,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 (*event).button,
                 (*event).state,
             );
+            cursor.notified_pressed.insert((*event).button);
         }
 
         // If pressed, update focus to window under cursor
@@ -1200,6 +1207,17 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
         }
     } else {
         assert_eq!((*event).state, ffi::wl_pointer_button_state_WL_POINTER_BUTTON_STATE_RELEASED);
+        // Pair the release for the client BEFORE any compositor-side
+        // consumption below: if the press was forwarded, the release always
+        // is too, or the client is left with an orphaned press.
+        if cursor.notified_pressed.remove(&(*event).button) {
+            ffi::wlr_seat_pointer_notify_button(
+                seat.wlr_seat,
+                (*event).time_msec,
+                (*event).button,
+                (*event).state,
+            );
+        }
         if seat.op.is_some() {
             let cursor_x = (*cursor.wlr_cursor).x;
             let cursor_y = (*cursor.wlr_cursor).y;
@@ -1461,15 +1479,8 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 return;
             }
 
-            if !should_block_button {
-                ffi::wlr_seat_pointer_notify_button(
-                    seat.wlr_seat,
-                    (*event).time_msec,
-                    (*event).button,
-                    (*event).state,
-                );
-            }
-
+            // The client-facing release (when the press was forwarded) is
+            // already paired at the top of the release path.
             if cursor.pressed.is_empty() && seat.op.is_some() {
                 seat.op_release = true;
                 (*(*seat).server).wm.dirty_windowing();