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