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

commit7db8c03224ca01598f81d42f4a7d30bac8821408
parenta564d54f1e
authorLucas Galante <[email protected]>
date2026-08-30 19:18
feat: Escape closes any open in-surface status menu

The keyboard twin of the click-away dismiss: plain Escape (no
modifiers), while any status segment is expanded, pushes the same
one-shot dismiss over the status socket ('-' — no exemption, every
open menu closes) and is consumed like a builtin binding, so the
focused window never sees the press or its release. With no menu open
the arm declines and Escape flows to the focused client unchanged; a
chorded Escape stays bindable.

The 'is any segment expanded' predicate moves from cursor.rs's
click-away into WindowManager::any_expanded_status_segment (with the
click-away's own-segment exemption as a parameter) so the two triggers
can never disagree about what counts as open.

Not verifiable in a shadow session: ccectl key injection deliberately
bypasses compositor keybindings, and virtual keyboards are advertised
but not wired (wlr_virtual_keyboard_manager_v1 is created with no
new_virtual_keyboard listener — a pre-existing gap). The shared
predicate is exercised through click-away in a shadow; the Escape arm
needs a real keypress on a post-relogin session.

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

 src/server/cursor.rs         | 18 +-----------------
 src/server/keyboard_group.rs | 22 ++++++++++++++++++++--
 src/server/window_manager.rs | 28 ++++++++++++++++++++++++++++
 3 files changed, 49 insertions(+), 19 deletions(-)

diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index ad951b7..96f7f75 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -1081,23 +1081,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                     }
                 }
             }
-            let bar_h = (*server).wm.layout.bar_height;
-            let any_other_expanded = (*server).wm.windows.iter().any(|&w| {
-                !w.is_null()
-                    && !(*w).closed
-                    && w != target_status
-                    && (*w).is_status_bar()
-                    && matches!((*w).state, crate::window::WindowState::Mapped)
-                    && {
-                        let bg = (*w).box_geom;
-                        let thickness = match (*w).status_edge {
-                            crate::policy::arrange::StatusEdge::Left
-                            | crate::policy::arrange::StatusEdge::Right => bg.width,
-                            _ => bg.height,
-                        };
-                        thickness > bar_h
-                    }
-            });
+            let any_other_expanded = (*server).wm.any_expanded_status_segment(target_status);
             if any_other_expanded {
                 let except = if target_status.is_null() {
                     "-".to_string()
diff --git a/src/server/keyboard_group.rs b/src/server/keyboard_group.rs
index 8cabb76..0accf92 100644
--- a/src/server/keyboard_group.rs
+++ b/src/server/keyboard_group.rs
@@ -276,7 +276,7 @@ impl KeyboardGroup {
     }
 }
 
-unsafe fn handle_builtin_binding(seat: *mut Seat, keysym: u32) -> bool {
+unsafe fn handle_builtin_binding(seat: *mut Seat, keysym: u32, modifiers: u32) -> bool {
     match keysym {
         ffi::XKB_KEY_XF86Switch_VT_1..=ffi::XKB_KEY_XF86Switch_VT_12 => {
             log::debug!("switch VT keysym received");
@@ -289,6 +289,24 @@ unsafe fn handle_builtin_binding(seat: *mut Seat, keysym: u32) -> bool {
             }
             true
         }
+        // Plain Escape closes any open in-surface status menu — the keyboard
+        // twin of the click-away dismiss in cursor.rs, consumed the same way
+        // a builtin is (the release is eaten with the press via the consumer
+        // map), so the focused window never sees it. Gated on a menu
+        // actually being open and on NO modifiers: a chorded Escape stays a
+        // bindable/forwardable key, and with nothing expanded this arm never
+        // fires at all.
+        ffi::XKB_KEY_Escape if modifiers == 0 => {
+            let server = (*seat).server;
+            if !(*server).wm.any_expanded_status_segment(std::ptr::null_mut()) {
+                return false;
+            }
+            log::debug!("Escape dismisses the open status menu");
+            if let Some(ref sender) = (*server).wm.status_sender {
+                sender.send_menu_dismiss("-");
+            }
+            true
+        }
         _ => false,
     }
 }
@@ -354,7 +372,7 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
             let syms = std::slice::from_raw_parts(syms_ptr, num_syms as usize);
             for &sym in syms {
                 log::debug!("  keysym={:#x}", sym);
-                if handle_builtin_binding(group.seat, sym) {
+                if handle_builtin_binding(group.seat, sym, modifiers) {
                     matched_builtin = true;
                     break;
                 }
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 75e5101..5d45165 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -2902,6 +2902,34 @@ impl WindowManager {
     }
 
 
+    /// Whether any mapped status segment is currently expanded past the bar
+    /// strip — i.e. an in-surface menu is open. `except` (null = none) exempts
+    /// one segment, for the click-away path where a press ON an expanded
+    /// segment must not dismiss that segment's own menu. Expanded is DEFINED
+    /// as thicker than the configured bar height, which is also how the
+    /// arrange pass recognizes an expanded segment. Shared by the click-away
+    /// dismiss (cursor.rs) and the Escape dismiss (keyboard_group.rs) so the
+    /// two triggers can never disagree about what counts as open.
+    pub unsafe fn any_expanded_status_segment(&self, except: *mut crate::window::Window) -> bool {
+        let bar_h = self.layout.bar_height;
+        self.windows.iter().any(|&w| {
+            !w.is_null()
+                && !(*w).closed
+                && w != except
+                && (*w).is_status_bar()
+                && matches!((*w).state, crate::window::WindowState::Mapped)
+                && {
+                    let bg = (*w).box_geom;
+                    let thickness = match (*w).status_edge {
+                        crate::policy::arrange::StatusEdge::Left
+                        | crate::policy::arrange::StatusEdge::Right => bg.width,
+                        _ => bg.height,
+                    };
+                    thickness > bar_h
+                }
+        })
+    }
+
     pub unsafe fn update_status(&self) {
         if let Some(ref sender) = self.status_sender {
             let update = crate::status_server::build_status_update(self);