Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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);