status bar
git clone https://git.lucas.co/cce-status-interface.git
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(());