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

commit8991c5dd3db7eb23b6d3ca0ab27847aefbc74afb
parenta48fcdd36b
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 17:13
feat(status): a clickaway topic for closing X11 popups

Xwayland only sees the pointer over its own surfaces, so an X11 popup --
a Wine tray app's menu opened through cce-xembed-tray -- never hears a
press on a Wayland window and stayed open until the user clicked one of
the app's own X windows.

handle_button now reports a press that lands on no X11 surface, while
some override-redirect window is showing, as a one-shot `press` line on
the status socket's new `clickaway` topic. The tray bridge subscribes
and closes the popup its click opened. The one-shot fan-out that the
shortcuts topic had inline is now push_one_shot, shared by both.

verify/clients' xembed-icon gains --popup WxH: a Windows-style tray
popup, bottom-aligned at the click and clamped to the screen top, that
reports moves and closes on a press outside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                             | 17 ++++++-
 src/server/cursor.rs                  | 34 +++++++++++++
 src/server/status_server.rs           | 96 ++++++++++++++++++++++++-----------
 verify/clients/src/bin/xembed_icon.rs | 55 +++++++++++++++++++-
 4 files changed, 171 insertions(+), 31 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cb8daf33..a5e5a772 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -269,7 +269,11 @@ own `target/`, invisible to ccebuild), built on demand by the drivers:
   advertises, sends SYSTEM_TRAY_REQUEST_DOCK, paints one solid colour and
   prints each milestone (docked, embedded, every button it receives).
   `--recolor SECS COLOR` and `--exit-after SECS` exercise the icon updating
-  and leaving. Needs `cce-shadow start --xwayland`; see
+  and leaving. `--popup WxH` makes a right-click open an override-redirect
+  popup the way a Windows tray app does — bottom-aligned at the click and
+  clamped to the screen top, i.e. over a top bar — which reports every move
+  and closes on a press outside it: the bridge's popup placement and the
+  `clickaway` topic, together. Needs `cce-shadow start --xwayland`; see
   `../cce-status-interface/CLAUDE.md` for the bridge it tests.
 
 - **`or-flip`** — an X11 window that maps override-redirect, then is
@@ -752,6 +756,17 @@ headless seat has no keyboard and Chromium crashes in
   change. This feeds the status bar (`cce-status-interface`). The main loop pushes
   updates through a `StatusSender` mpsc handle.
 
+  **`clickaway`** is a one-shot topic (like `dismiss` and `shortcuts`): a
+  `press` line for each button press that lands on NO X11 surface while some
+  override-redirect X window is showing (`handle_button`). Xwayland only sees
+  the pointer over its own surfaces, so an X11 popup — a Wine tray app's
+  menu above all — never hears a press on a Wayland window and stays open;
+  until 2026-09-26 only a click on one of the app's own X windows closed it.
+  The tray bridge (`cce-status-interface`'s `cce-xembed-tray`) subscribes and
+  closes the popup its forwarded click opened by addressing it a press just
+  outside itself. The bridge's hidden icon containers have no scene tree, so
+  they never count as showing.
+
   **What may start a transaction.** `dirty_windowing()` schedules a full
   manage/arrange/render pass, and on an idle desktop the answer to "why is the
   window manager busy" is always some call site that dirties on a routine
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 01e9a845..decf6eb5 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -1493,6 +1493,40 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
             }
         }
 
+        // Click-away for X11 popups. Xwayland only sees the pointer over
+        // its own surfaces, so a popup menu an X11 app opened (Wine's, most
+        // of all — a tray icon's menu opened through cce-xembed-tray) never
+        // hears a press on a Wayland window and stays open; only a click on
+        // one of the app's own X windows used to close it. So a press that
+        // lands on no X11 surface while some override-redirect window is
+        // showing is reported on the status socket's `clickaway` topic, and
+        // the bridge closes the popup it opened. The tray bridge's own
+        // containers have no scene tree, so they never count as showing.
+        {
+            let or_showing = (*server)
+                .wm
+                .override_redirects
+                .iter()
+                .any(|&or| !or.is_null() && !(*or).surface_tree.is_null());
+            if or_showing {
+                let on_x11 = match (*server).scene.at(lx, ly) {
+                    Some(result) => match result.data {
+                        SceneNodeDataVal::Window(window) => {
+                            matches!((*window).impl_type, crate::window::WindowImpl::Xwayland(_))
+                        }
+                        SceneNodeDataVal::OverrideRedirect(_) => true,
+                        _ => false,
+                    },
+                    None => false,
+                };
+                if !on_x11 {
+                    if let Some(ref sender) = (*server).wm.status_sender {
+                        sender.send_click_away();
+                    }
+                }
+            }
+        }
+
         // 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 fe020bfd..d7fff23f 100644
--- a/src/server/status_server.rs
+++ b/src/server/status_server.rs
@@ -53,6 +53,13 @@ pub enum StatusMsg {
     /// `activated|deactivated <session> <id> <time_msec>`, for every
     /// `shortcuts` subscriber — in practice the one portal backend.
     Shortcut(String),
+    /// A button press that landed on no X11 surface while an X11
+    /// override-redirect window was showing: one line, `press`, for every
+    /// `clickaway` subscriber. An X11 popup only hears clicks on its own
+    /// client's X windows (Xwayland's pointer never reaches it over a
+    /// Wayland surface), so the tray bridge closes the popup it opened on
+    /// this cue — see `cce-status-interface`'s `cce-xembed-tray`.
+    ClickAway,
 }
 
 /// Subscription types that the status bar script can request.
@@ -71,6 +78,9 @@ enum Subscription {
     /// `shortcuts` — one-shot portal shortcut press/release lines only
     /// (see `StatusMsg::Shortcut`); never receives state pushes.
     Shortcuts,
+    /// `clickaway` — one-shot `press` lines only (see
+    /// `StatusMsg::ClickAway`); never receives state pushes.
+    ClickAway,
     /// `backdrop <app_id>` — what THIS segment is composited over, so it can
     /// adapt its own text contrast. Lines are `<luma> <spread>`, both 0-100.
     Backdrop(String),
@@ -93,6 +103,7 @@ impl Subscription {
             "adjust" => Subscription::Adjust,
             "dismiss" => Subscription::Dismiss,
             "shortcuts" => Subscription::Shortcuts,
+            "clickaway" => Subscription::ClickAway,
             _ => Subscription::Unknown,
         }
     }
@@ -146,6 +157,46 @@ impl StatusSender {
             wake_fd(&self.wake);
         }
     }
+
+    /// Report a press that missed every X11 surface (`StatusMsg::ClickAway`).
+    pub fn send_click_away(&self) {
+        if self.tx.send(StatusMsg::ClickAway).is_ok() {
+            wake_fd(&self.wake);
+        }
+    }
+}
+
+/// Write each of `lines` to every client subscribed to `topic`, dropping
+/// the clients whose socket has gone away. One-shot topics only: nothing is
+/// remembered for a client that subscribes later.
+fn push_one_shot(clients: &mut Vec<Client>, topic: &Subscription, lines: &[String]) {
+    if lines.is_empty() {
+        return;
+    }
+    let mut dead_clients = Vec::new();
+    for (i, client) in clients.iter_mut().enumerate() {
+        if &client.subscription != topic {
+            continue;
+        }
+        for line in lines {
+            match client
+                .stream
+                .write_all(line.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);
+    }
 }
 
 impl Drop for StatusSender {
@@ -327,6 +378,7 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
         // Process incoming updates from the main loop
         let mut dismiss_events: Vec<String> = Vec::new();
         let mut shortcut_events: Vec<String> = Vec::new();
+        let mut clickaway_events: Vec<String> = Vec::new();
         loop {
             match rx.try_recv() {
                 Ok(StatusMsg::State(update)) => {
@@ -339,6 +391,9 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
                 Ok(StatusMsg::Shortcut(line)) => {
                     shortcut_events.push(line);
                 }
+                Ok(StatusMsg::ClickAway) => {
+                    clickaway_events.push("press".to_string());
+                }
                 Err(mpsc::TryRecvError::Empty) => break,
                 Err(mpsc::TryRecvError::Disconnected) => {
                     log::info!("[status] channel disconnected, exiting");
@@ -377,33 +432,10 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
             }
         }
 
-        // Portal shortcut edges go only to `shortcuts` subscribers, in order.
-        if !shortcut_events.is_empty() {
-            let mut dead_clients = Vec::new();
-            for (i, client) in clients.iter_mut().enumerate() {
-                if client.subscription != Subscription::Shortcuts {
-                    continue;
-                }
-                for line in &shortcut_events {
-                    match client
-                        .stream
-                        .write_all(line.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);
-            }
-        }
+        // Portal shortcut edges go only to `shortcuts` subscribers, in
+        // order; click-aways only to `clickaway` subscribers.
+        push_one_shot(&mut clients, &Subscription::Shortcuts, &shortcut_events);
+        push_one_shot(&mut clients, &Subscription::ClickAway, &clickaway_events);
 
         // If we got a new update, push it to all clients
         if has_new_update {
@@ -413,7 +445,10 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
                 for (i, client) in clients.iter_mut().enumerate() {
                     // Dismiss and shortcuts subscribers get one-shot events
                     // only, never state pushes.
-                    if matches!(client.subscription, Subscription::Dismiss | Subscription::Shortcuts) {
+                    if matches!(
+                        client.subscription,
+                        Subscription::Dismiss | Subscription::Shortcuts | Subscription::ClickAway
+                    ) {
                         continue;
                     }
                     let msg = format_for_subscription(&client.subscription, update);
@@ -493,7 +528,10 @@ fn format_for_subscription(sub: &Subscription, update: &StatusUpdate) -> String
                 None => "unknown".to_string(),
             }
         }
-        Subscription::Dismiss | Subscription::Shortcuts | Subscription::Unknown => String::new(),
+        Subscription::Dismiss
+        | Subscription::Shortcuts
+        | Subscription::ClickAway
+        | Subscription::Unknown => String::new(),
     }
 }
 
diff --git a/verify/clients/src/bin/xembed_icon.rs b/verify/clients/src/bin/xembed_icon.rs
index 5ced4faf..6978b28c 100644
--- a/verify/clients/src/bin/xembed_icon.rs
+++ b/verify/clients/src/bin/xembed_icon.rs
@@ -12,11 +12,18 @@
 //   button N press|release x,y root rx,ry
 //   recolored 0xAARRGGBB
 //   undocked                      (reparented back to the root)
+//   popup at X,Y WxH              (--popup: opened, then after any move)
+//   popup dismissed by press at X,Y
 //
 // Args: --color 0xAARRGGBB (premultiplied pixel, default opaque red)
 //       --recolor SECS 0xAARRGGBB   repaint in a second colour later
 //       --exit-after SECS           destroy the window and exit
 //       --size N                    window size, default 32
+//       --popup WxH                 on a right-click, open an override-
+//                                   redirect popup the way a Windows tray
+//                                   app does: bottom-aligned at the click's
+//                                   root point and clamped to the screen
+//                                   top; closed by a press outside it
 
 use std::time::{Duration, Instant};
 
@@ -35,6 +42,7 @@ fn main() {
     let mut recolor: Option<(f64, u32)> = None;
     let mut exit_after: Option<f64> = None;
     let mut size: u16 = 32;
+    let mut popup_size: Option<(u16, u16)> = None;
     let mut args = std::env::args().skip(1);
     while let Some(a) = args.next() {
         match a.as_str() {
@@ -45,6 +53,11 @@ fn main() {
             }
             "--exit-after" => exit_after = Some(args.next().unwrap().parse().unwrap()),
             "--size" => size = args.next().unwrap().parse().unwrap(),
+            "--popup" => {
+                let v = args.next().unwrap();
+                let (w, h) = v.split_once('x').expect("WxH");
+                popup_size = Some((w.parse().unwrap(), h.parse().unwrap()));
+            }
             other => panic!("unknown arg {other}"),
         }
     }
@@ -129,6 +142,7 @@ fn main() {
     conn.send_event(false, owner, EventMask::NO_EVENT, dock).unwrap();
     conn.flush().unwrap();
 
+    let mut popup: Option<(Window, u16, u16)> = None;
     let start = Instant::now();
     let paint = |c: u32| {
         conn.change_gc(gc, &ChangeGCAux::new().foreground(c)).unwrap();
@@ -166,8 +180,47 @@ fn main() {
                         println!("embedded");
                     }
                 }
+                Event::ButtonPress(e) if popup.is_some_and(|(p, _, _)| p == e.event) => {
+                    let (p, w, h) = popup.unwrap();
+                    let inside = e.event_x >= 0 && e.event_y >= 0 && e.event_x < w as i16 && e.event_y < h as i16;
+                    if !inside {
+                        conn.destroy_window(p).unwrap();
+                        conn.flush().unwrap();
+                        popup = None;
+                        println!("popup dismissed by press at {},{}", e.event_x, e.event_y);
+                    }
+                }
+                Event::ConfigureNotify(e) if popup.is_some_and(|(p, _, _)| p == e.window) => {
+                    println!("popup at {},{} {}x{}", e.x, e.y, e.width, e.height);
+                }
                 Event::ButtonPress(e) => {
-                    println!("button {} press {},{} root {},{}", e.detail, e.event_x, e.event_y, e.root_x, e.root_y)
+                    println!("button {} press {},{} root {},{}", e.detail, e.event_x, e.event_y, e.root_x, e.root_y);
+                    if let (3, Some((w, h)), None) = (e.detail, popup_size, popup) {
+                        let x = (e.root_x - w as i16 / 2).max(0);
+                        let y = (e.root_y - h as i16).max(0);
+                        let p = conn.generate_id().unwrap();
+                        conn.create_window(
+                            screen.root_depth,
+                            p,
+                            screen.root,
+                            x,
+                            y,
+                            w,
+                            h,
+                            0,
+                            WindowClass::INPUT_OUTPUT,
+                            screen.root_visual,
+                            &CreateWindowAux::new()
+                                .background_pixel(screen.white_pixel)
+                                .override_redirect(1)
+                                .event_mask(EventMask::BUTTON_PRESS | EventMask::STRUCTURE_NOTIFY),
+                        )
+                        .unwrap();
+                        conn.map_window(p).unwrap();
+                        conn.flush().unwrap();
+                        popup = Some((p, w, h));
+                        println!("popup at {x},{y} {w}x{h}");
+                    }
                 }
                 Event::ButtonRelease(e) => {
                     println!("button {} release {},{} root {},{}", e.detail, e.event_x, e.event_y, e.root_x, e.root_y)