git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

commitd2cc09be35d52e162d333f926cfec4a83b6dcf4f
parent11821a7e3f
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 17:13
fix(xembed-tray): open a tray menu below the bar, close it on a click away

Windows tray apps open their menu upward from the pointer and clamp it
to the screen top, so on a top bar Ubisoft Connect's menu covered its own
icon; and an X11 popup never hears a press on a Wayland window, so the
menu stayed open until a click on one of Ubisoft's own windows.

The bridge now arms a watch when it forwards a click: the next
override-redirect window to map within 1.5 s is that click's popup
(root SubstructureNotify). One reaching above the bar's bottom edge is
moved down to it. And on the compositor's new `clickaway` status topic
the bridge addresses the popup a press just outside itself, which the
app, holding the mouse capture, reads as a click outside its menu.

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

 CLAUDE.md                      |  14 +++++
 src/bin/cce-xembed-tray/sni.rs |  35 ++++++++++++
 src/bin/cce-xembed-tray/x11.rs | 125 +++++++++++++++++++++++++++++++++++++++--
 3 files changed, 170 insertions(+), 4 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cdd888b..564c944 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -59,6 +59,20 @@ selection and, per docked icon:
   (`tray_click_point`). It sent segment-local ones until 2026-09-26, and
   Ubisoft Connect's menu opened ~700 px from its icon. With no point (0,0)
   the container stays where it docked, at the top-right of the X screen.
+- **looks after the popup the click opens** (`PopupWatch`): the next
+  override-redirect window to map within 1.5 s of a forwarded click is that
+  click's popup. Windows tray apps open their menu UPWARD from the pointer
+  (the taskbar is at the bottom there) and clamp it to the screen top, so on
+  a top bar it lands over the icon — a popup reaching above the bar's bottom
+  edge is moved down to it. The app keeps working in the moved window, since
+  X reports pointer positions relative to the window. Wine's `_NET_WORKAREA`
+  does not help: Ubisoft Connect places its own menu, and ignored a work area
+  that excluded the bar. The popup is also closed on the compositor's
+  `clickaway` status topic (a press on no X11 surface): the bridge addresses
+  it a press just outside itself, which the app — holding the mouse capture
+  while its menu is up — reads as a click outside. Xwayland never delivers a
+  press on a Wayland window, so before this only a click on one of the app's
+  own X windows closed the menu.
 - **names it after its app** (`title.rs`), since icon windows are untitled
   and their WM_CLASS names the toolkit (`steam_proton` for every Proton
   program). A Wine icon is not even the app's window: Wine's tray lives in the
diff --git a/src/bin/cce-xembed-tray/sni.rs b/src/bin/cce-xembed-tray/sni.rs
index 448f178..f694df0 100644
--- a/src/bin/cce-xembed-tray/sni.rs
+++ b/src/bin/cce-xembed-tray/sni.rs
@@ -196,6 +196,39 @@ fn retry_title(x: Arc<XHandle>, icon: u32, fallback: String, events: WeakUnbound
     });
 }
 
+/// The compositor's status socket, as the bar finds it.
+fn status_socket_path() -> String {
+    let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
+    let primary = format!("/tmp/cce-status-interface-{display}.sock");
+    if std::path::Path::new(&primary).exists() {
+        primary
+    } else {
+        format!("/tmp/cce-status-{display}.sock")
+    }
+}
+
+/// Follow the compositor's `clickaway` topic — a press that landed on no
+/// X11 surface while an X11 popup was up — and close the popup a forwarded
+/// click opened (`XHandle::dismiss_popup`). Reconnects when the compositor
+/// goes away; a compositor without the topic simply never sends a line.
+fn follow_click_aways(x: Arc<XHandle>) {
+    tokio::spawn(async move {
+        use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+        loop {
+            if let Ok(mut stream) = tokio::net::UnixStream::connect(status_socket_path()).await {
+                if stream.write_all(b"clickaway\n").await.is_ok() {
+                    let mut lines = BufReader::new(stream).lines();
+                    while let Ok(Some(_press)) = lines.next_line().await {
+                        let x = x.clone();
+                        let _ = tokio::task::spawn_blocking(move || x.dismiss_popup()).await;
+                    }
+                }
+            }
+            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+        }
+    });
+}
+
 /// Mirror the X side's icons onto the bus until it stops (its channel
 /// closes) or the process is told to exit; then hand the icons back.
 /// `requeue` feeds the same channel, for the late title lookups. It is
@@ -214,6 +247,8 @@ pub async fn run(
     let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
         .map_err(|e| zbus::Error::Failure(e.to_string()))?;
 
+    follow_click_aways(x.clone());
+
     let mut entries: HashMap<u32, Entry> = HashMap::new();
     loop {
         tokio::select! {
diff --git a/src/bin/cce-xembed-tray/x11.rs b/src/bin/cce-xembed-tray/x11.rs
index 3fa71d9..909dfb3 100644
--- a/src/bin/cce-xembed-tray/x11.rs
+++ b/src/bin/cce-xembed-tray/x11.rs
@@ -6,7 +6,8 @@
 //! through an [`XHandle`]; `RustConnection` is thread-safe for that.
 
 use std::collections::HashMap;
-use std::sync::Arc;
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
 
 use tokio::sync::mpsc::UnboundedSender;
 use x11rb::connection::Connection;
@@ -83,11 +84,28 @@ struct Icon {
     last: Option<Vec<u8>>,
 }
 
+/// How long after a forwarded click a newly mapped override-redirect
+/// window counts as the popup that click opened.
+const POPUP_ARM: Duration = Duration::from_millis(1500);
+
+/// The popup an app opened in answer to a forwarded click, shared between
+/// the X thread (which sees it map and go) and the D-Bus side (which arms
+/// the watch on a click and closes the popup on a click-away).
+#[derive(Default)]
+struct PopupWatch {
+    /// When the last click was forwarded, and the root y (X pixels) the
+    /// popup should not reach above: the bar's bottom edge, when the host
+    /// gave a screen point.
+    armed: Option<(Instant, Option<i32>)>,
+    popup: Option<Window>,
+}
+
 /// The request-only view of the connection the D-Bus side holds.
 pub struct XHandle {
     conn: Arc<RustConnection>,
     root: Window,
     atoms: Atoms,
+    watch: Arc<Mutex<PopupWatch>>,
 }
 
 impl XHandle {
@@ -109,10 +127,16 @@ impl XHandle {
     /// instead. `None` (or the 0,0 a host with no idea sends) keeps the
     /// container where it docked, at the top-right of the X screen.
     pub fn click(&self, icon: Window, button: u8, at: ClickPoint, screen: Option<(i32, i32)>) -> Res<()> {
-        let at = match screen.filter(|&(x, y)| x > 0 || y > 0) {
+        let placed = screen.filter(|&(x, y)| x > 0 || y > 0);
+        let at = match placed {
             Some(point) => self.move_under(at, point, x11_scale(&self.conn, self.root)),
             None => at,
         };
+        // Whatever override-redirect window maps next is this click's
+        // popup: armed before the press goes out, so the map cannot win.
+        if let Ok(mut w) = self.watch.lock() {
+            w.armed = Some((Instant::now(), placed.map(|_| i32::from(at.root.1))));
+        }
         let mask = 1u16 << (7 + button.min(5));
         for (kind, state) in [(BUTTON_PRESS_EVENT, 0u16), (BUTTON_RELEASE_EVENT, mask)] {
             let ev = ButtonPressEvent {
@@ -155,6 +179,43 @@ impl XHandle {
         }
     }
 
+    /// Close the popup a forwarded click opened, if it is still up: press
+    /// and release at a point just outside it, addressed to the popup. The
+    /// app holds the mouse capture while its menu is open, so it reads that
+    /// as a click outside the menu and closes it — what a click on any
+    /// other window would do on Windows, and what one on a Wayland window
+    /// cannot, since Xwayland never delivers it (see the compositor's
+    /// `clickaway` status topic, which is what calls this).
+    pub fn dismiss_popup(&self) {
+        let Some(popup) = self.watch.lock().ok().and_then(|w| w.popup) else {
+            return;
+        };
+        let Some(geom) = self.conn.get_geometry(popup).ok().and_then(|c| c.reply().ok()) else {
+            return;
+        };
+        let (ex, ey) = (-8i16, -8i16);
+        for (kind, state) in [(BUTTON_PRESS_EVENT, 0u16), (BUTTON_RELEASE_EVENT, 1u16 << 8)] {
+            let ev = ButtonPressEvent {
+                response_type: kind,
+                detail: 1,
+                sequence: 0,
+                time: CURRENT_TIME,
+                root: self.root,
+                event: popup,
+                child: NONE,
+                root_x: geom.x.saturating_add(ex),
+                root_y: geom.y.saturating_add(ey),
+                event_x: ex,
+                event_y: ey,
+                state: KeyButMask::from(state),
+                same_screen: true,
+            };
+            let _ = self.conn.send_event(false, popup, EventMask::NO_EVENT, ev);
+        }
+        let _ = self.conn.flush();
+        log::info!("click-away: closing popup {popup:#x}");
+    }
+
     /// Hand every icon back on the way out: unmapped, on the root window,
     /// which the XEmbed client reads as "the tray is gone" and so goes
     /// looking for the next one (or its own fallback).
@@ -179,6 +240,7 @@ pub struct Tray {
     colormap: Colormap,
     icons: HashMap<Window, Icon>,
     tx: UnboundedSender<TrayEvent>,
+    watch: Arc<Mutex<PopupWatch>>,
 }
 
 impl Tray {
@@ -238,6 +300,13 @@ impl Tray {
             AtomEnum::VISUALID,
             &[visual],
         )?;
+        // Top-level map/unmap events, to catch the popup a forwarded click
+        // opens (`PopupWatch`). SubstructureNotify is shareable; only the
+        // redirect mask is the window manager's alone.
+        conn.change_window_attributes(
+            screen.root,
+            &ChangeWindowAttributesAux::new().event_mask(EventMask::SUBSTRUCTURE_NOTIFY),
+        )?;
         conn.flush()?;
         log::info!("tray visual {visual:#x} (depth {depth})");
 
@@ -253,11 +322,12 @@ impl Tray {
             colormap,
             icons: HashMap::new(),
             tx,
+            watch: Arc::new(Mutex::new(PopupWatch::default())),
         })
     }
 
     pub fn handle(&self) -> XHandle {
-        XHandle { conn: self.conn.clone(), root: self.root, atoms: self.atoms }
+        XHandle { conn: self.conn.clone(), root: self.root, atoms: self.atoms, watch: self.watch.clone() }
     }
 
     /// Take the tray selection and announce it. Another tray already
@@ -343,7 +413,12 @@ impl Tray {
                     log::info!("another tray took the selection; handing over");
                     return Ok(());
                 }
-                Event::DestroyNotify(e) => self.undock(e.window, true),
+                Event::DestroyNotify(e) => {
+                    self.popup_gone(e.window);
+                    self.undock(e.window, true)
+                }
+                Event::UnmapNotify(e) => self.popup_gone(e.window),
+                Event::MapNotify(e) if e.override_redirect => self.popup_mapped(e.window)?,
                 Event::ReparentNotify(e) => {
                     if self.icons.get(&e.window).is_some_and(|i| i.container != e.parent) {
                         self.undock(e.window, false);
@@ -383,6 +458,48 @@ impl Tray {
         }
     }
 
+    /// An override-redirect window mapped: if a forwarded click is still
+    /// armed, it is that click's popup. Remember it for the click-away, and
+    /// if it covers the bar, move it down to the bar's bottom edge. Windows
+    /// tray apps open their menu UPWARD from the pointer, the taskbar being
+    /// at the bottom there; with the pointer on a top bar there is no room
+    /// above, and the app clamps the menu to the screen top — over the very
+    /// icon that opened it. The app keeps working in the moved window:
+    /// X reports pointer positions relative to the window, which is what
+    /// the app hit-tests with.
+    fn popup_mapped(&mut self, window: Window) -> Res<()> {
+        if self.icons.values().any(|i| i.container == window) {
+            return Ok(());
+        }
+        let anchor = {
+            let Ok(mut w) = self.watch.lock() else { return Ok(()) };
+            match w.armed {
+                Some((at, anchor)) if at.elapsed() < POPUP_ARM => {
+                    w.popup = Some(window);
+                    anchor
+                }
+                _ => return Ok(()),
+            }
+        };
+        let geom = self.conn.get_geometry(window)?.reply()?;
+        log::info!("popup {window:#x} opened at {},{} {}x{}", geom.x, geom.y, geom.width, geom.height);
+        if let Some(bar_bottom) = anchor {
+            if i32::from(geom.y) < bar_bottom {
+                self.conn.configure_window(window, &ConfigureWindowAux::new().y(bar_bottom))?;
+                log::info!("popup {window:#x} moved below the bar to y={bar_bottom}");
+            }
+        }
+        Ok(())
+    }
+
+    fn popup_gone(&self, window: Window) {
+        if let Ok(mut w) = self.watch.lock() {
+            if w.popup == Some(window) {
+                w.popup = None;
+            }
+        }
+    }
+
     fn dock(&mut self, icon: Window) -> Res<()> {
         if icon == NONE || self.icons.contains_key(&icon) {
             return Ok(());