Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat: push one-shot menu-dismiss on click-away from expanded status segments
New 'dismiss' status-socket topic (one-shot events only, never state
pushes; the mpsc channel now carries a StatusMsg enum). On pointer
press, if any status segment is expanded and the press did not land on
it, send the dismiss line to subscribers with the pressed segment's
app_id as the exemption payload ('-' when the press hit no status
segment) — bar segments close their in-surface menus on it.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/server/cursor.rs | 44 ++++++++++++++++++++++++++++
src/server/status_server.rs | 71 +++++++++++++++++++++++++++++++++++++++++----
2 files changed, 109 insertions(+), 6 deletions(-)
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 2b7358a..d441eeb 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -800,6 +800,50 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
return;
}
+ // Click-away-close for in-surface status menus: if any status
+ // segment is expanded (menu open) and this press did not land on it,
+ // push a one-shot dismiss over the status socket. The line carries
+ // the pressed segment's app_id so a press ON an expanded segment
+ // exempts that segment (it handles its own clicks) while still
+ // dismissing any other open menu.
+ {
+ let mut target_status: *mut crate::window::Window = std::ptr::null_mut();
+ if let Some(result) = (*server).scene.at(lx, ly) {
+ if let SceneNodeDataVal::Window(window) = result.data {
+ if (*window).is_status_bar() {
+ target_status = window;
+ }
+ }
+ }
+ 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
+ }
+ });
+ if any_other_expanded {
+ let except = if target_status.is_null() {
+ "-".to_string()
+ } else {
+ (*target_status).get_app_id_string().unwrap_or_else(|| "-".to_string())
+ };
+ if let Some(ref sender) = (*server).wm.status_sender {
+ sender.send_menu_dismiss(&except);
+ }
+ }
+ }
+
// Status-bar segments are dragged either in adjust-position mode or
// directly with super+left-drag (0x40 = WLR_MODIFIER_LOGO).
let super_held = {
diff --git a/src/server/status_server.rs b/src/server/status_server.rs
index 5b3792f..5df62b4 100644
--- a/src/server/status_server.rs
+++ b/src/server/status_server.rs
@@ -24,6 +24,17 @@ pub struct StatusUpdate {
pub modifiers_text: String,
}
+/// A message from the main loop to the server thread: either a new state
+/// snapshot for the state topics, or a one-shot menu-dismiss event.
+#[derive(Debug, Clone)]
+pub enum StatusMsg {
+ State(StatusUpdate),
+ /// Click-away-close for in-surface status menus: every `dismiss`
+ /// subscriber EXCEPT the segment whose app_id is carried here should
+ /// close its open menu (the exempt segment saw the press itself).
+ MenuDismiss { except_app_id: String },
+}
+
/// Subscription types that the status bar script can request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Subscription {
@@ -31,6 +42,8 @@ enum Subscription {
Layout,
Title,
Modifiers,
+ /// One-shot menu-dismiss events only — never receives state pushes.
+ Dismiss,
Unknown,
}
@@ -41,6 +54,7 @@ impl Subscription {
"layout" => Subscription::Layout,
"title" => Subscription::Title,
"modifiers" => Subscription::Modifiers,
+ "dismiss" => Subscription::Dismiss,
_ => Subscription::Unknown,
}
}
@@ -55,13 +69,19 @@ struct Client {
/// Handle to the status server for sending updates from the main loop.
#[derive(Debug, Clone)]
pub struct StatusSender {
- tx: mpsc::Sender<StatusUpdate>,
+ tx: mpsc::Sender<StatusMsg>,
}
impl StatusSender {
pub fn send(&self, update: StatusUpdate) {
// If the channel is full or the receiver is gone, just drop it.
- let _ = self.tx.send(update);
+ let _ = self.tx.send(StatusMsg::State(update));
+ }
+
+ /// Fire a one-shot menu-dismiss at every `dismiss` subscriber except the
+ /// segment with this app_id (pass "-" to exempt nobody).
+ pub fn send_menu_dismiss(&self, except_app_id: &str) {
+ let _ = self.tx.send(StatusMsg::MenuDismiss { except_app_id: except_app_id.to_string() });
}
}
@@ -75,7 +95,7 @@ pub fn get_status_socket_path(display_socket: Option<&str>) -> String {
/// Spawn the status server thread. Returns a StatusSender for the main loop.
pub fn spawn_status_server(display_socket: Option<String>) -> StatusSender {
- let (tx, rx) = mpsc::channel::<StatusUpdate>();
+ let (tx, rx) = mpsc::channel::<StatusMsg>();
std::thread::Builder::new()
.name("cce-status-server".into())
@@ -87,7 +107,7 @@ pub fn spawn_status_server(display_socket: Option<String>) -> StatusSender {
StatusSender { tx }
}
-fn status_server_main(rx: mpsc::Receiver<StatusUpdate>, display_socket: Option<String>) {
+fn status_server_main(rx: mpsc::Receiver<StatusMsg>, display_socket: Option<String>) {
let socket_path = get_status_socket_path(display_socket.as_deref());
// Remove stale socket
let _ = std::fs::remove_file(&socket_path);
@@ -147,13 +167,18 @@ fn status_server_main(rx: mpsc::Receiver<StatusUpdate>, display_socket: Option<S
}
// Process incoming updates from the main loop
+ let mut dismiss_events: Vec<String> = Vec::new();
loop {
match rx.try_recv() {
- Ok(update) => {
+ Ok(StatusMsg::State(update)) => {
latest = Some(update);
activity = true;
has_new_update = true;
}
+ Ok(StatusMsg::MenuDismiss { except_app_id }) => {
+ dismiss_events.push(except_app_id);
+ activity = true;
+ }
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
log::info!("[status] channel disconnected, exiting");
@@ -163,12 +188,46 @@ fn status_server_main(rx: mpsc::Receiver<StatusUpdate>, display_socket: Option<S
}
}
+ // One-shot dismiss lines go only to `dismiss` subscribers; the line
+ // payload is the exempt app_id.
+ if !dismiss_events.is_empty() {
+ let mut dead_clients = Vec::new();
+ for (i, client) in clients.iter_mut().enumerate() {
+ if client.subscription != Subscription::Dismiss {
+ continue;
+ }
+ for except in &dismiss_events {
+ match client
+ .stream
+ .write_all(except.as_bytes())
+ .and_then(|_| client.stream.write_all(b"\n"))
+ {
+ Ok(_) => {}
+ Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
+ Err(_) => {
+ dead_clients.push(i);
+ break;
+ }
+ }
+ }
+ }
+ dead_clients.dedup();
+ for i in dead_clients.into_iter().rev() {
+ clients.remove(i);
+ }
+ }
+
// If we got a new update, push it to all clients
if has_new_update {
if let Some(ref update) = latest {
let mut dead_clients = Vec::new();
for (i, client) in clients.iter_mut().enumerate() {
+ // Dismiss subscribers get one-shot events only, never
+ // state pushes.
+ if client.subscription == Subscription::Dismiss {
+ continue;
+ }
let msg = format_for_subscription(client.subscription, update);
match client
.stream
@@ -232,7 +291,7 @@ fn format_for_subscription(sub: Subscription, update: &StatusUpdate) -> String {
Subscription::Layout => update.layout_text.clone(),
Subscription::Title => update.title_text.clone(),
Subscription::Modifiers => update.modifiers_text.clone(),
- Subscription::Unknown => String::new(),
+ Subscription::Dismiss | Subscription::Unknown => String::new(),
}
}