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

commit11821a7e3fbe1cfbfee046325462d6928334e381
parent0399ad41ba
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 16:58
fix(tray): open an X11 tray icon's menu under the icon

The bar passed tray clicks to Activate/ContextMenu in segment-local
coordinates, where SNI asks for screen ones, so the XEmbed bridge had
no idea where the icon was and placed every click at the top-right of
the X screen: Ubisoft Connect's right-click menu opened ~700 px from its
icon.

The bar now sends a screen point -- its segment's position from ccectl
windows --json plus the pointer's x, at the bar's bottom edge so a menu
hangs below the bar (tray_click_point). The bridge scales that point
into X pixels by Xft.dpi/96, moves the icon's container under it, and
reports it as the click's root position, which is where Wine opens the
menu. A host sending (0,0) keeps the old top-right placement.

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

 CLAUDE.md                      | 14 +++++--
 src/bin/cce-xembed-tray/sni.rs | 20 +++++-----
 src/bin/cce-xembed-tray/x11.rs | 83 +++++++++++++++++++++++++++++++++++++++---
 src/main.rs                    | 54 ++++++++++++++++++++++++++-
 4 files changed, 151 insertions(+), 20 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 763cabd..cdd888b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,7 +19,7 @@ cce-icons glyph textures), `src/listeners.rs` (status/switcher socket tasks).
 
 ```sh
 cargo build --release                 # standalone build (or `-p cce-status-interface` from the workspace root)
-cargo test                            # 54 tests: main.rs (contrast, parsers), config.rs, tray.rs, the tray bridge's x11.rs and title.rs
+cargo test                            # 56 tests: main.rs (contrast, parsers), config.rs, tray.rs, the tray bridge's x11.rs and title.rs
 make install                          # release build, then `ccebuild install --no-build cce-status-interface`
 ```
 
@@ -50,9 +50,15 @@ selection and, per docked icon:
 - **forwards clicks** as `SendEvent` button presses to the icon: `Activate` is
   button 1, `ContextMenu` button 3 (the app draws its own menu, which is why
   there is deliberately no `Menu` property — its presence makes the bar fetch a
-  D-Bus menu instead), `Scroll` buttons 4-7. The root position in the event is
-  where the container sits, along the top-right of the X screen, so an app that
-  opens its menu at the cursor opens it near the tray.
+  D-Bus menu instead), `Scroll` buttons 4-7. An app opens its menu at the
+  root position the event reports, so the click is placed at the host's
+  Activate/ContextMenu point — scaled into X pixels by `Xft.dpi`/96 — and the
+  container is moved under it first. That point is only right because the
+  bar sends SCREEN coordinates, as SNI asks: its segment's position from
+  `ccectl windows --json` plus the pointer's x, at the bar's bottom edge
+  (`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.
 - **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 d60bbc5..448f178 100644
--- a/src/bin/cce-xembed-tray/sni.rs
+++ b/src/bin/cce-xembed-tray/sni.rs
@@ -26,8 +26,10 @@ struct Item {
 }
 
 impl Item {
-    fn click(&self, button: u8) {
-        if let Err(e) = self.x.click(self.icon, button, self.at) {
+    /// `screen`: the host's click point, when it gave one (see
+    /// `XHandle::click`).
+    fn click(&self, button: u8, screen: Option<(i32, i32)>) {
+        if let Err(e) = self.x.click(self.icon, button, self.at, screen) {
             log::warn!("click on {:#x} failed: {e}", self.icon);
         }
     }
@@ -83,16 +85,16 @@ impl Item {
         false
     }
 
-    fn activate(&self, _x: i32, _y: i32) {
-        self.click(1);
+    fn activate(&self, x: i32, y: i32) {
+        self.click(1, Some((x, y)));
     }
 
-    fn secondary_activate(&self, _x: i32, _y: i32) {
-        self.click(2);
+    fn secondary_activate(&self, x: i32, y: i32) {
+        self.click(2, Some((x, y)));
     }
 
-    fn context_menu(&self, _x: i32, _y: i32) {
-        self.click(3);
+    fn context_menu(&self, x: i32, y: i32) {
+        self.click(3, Some((x, y)));
     }
 
     fn scroll(&self, delta: i32, orientation: &str) {
@@ -103,7 +105,7 @@ impl Item {
             (true, true) => 7,
             (true, false) => 6,
         };
-        self.click(button);
+        self.click(button, None);
     }
 
     #[zbus(signal)]
diff --git a/src/bin/cce-xembed-tray/x11.rs b/src/bin/cce-xembed-tray/x11.rs
index f3c0ba6..3fa71d9 100644
--- a/src/bin/cce-xembed-tray/x11.rs
+++ b/src/bin/cce-xembed-tray/x11.rs
@@ -63,12 +63,16 @@ pub enum TrayEvent {
     Undocked { icon: Window },
 }
 
-/// Where a forwarded click lands: in root coordinates (what the app reads
-/// back as the cursor position, so where its menu opens) and in the icon's.
+/// Where a forwarded click lands when the host gives no screen point: in
+/// root coordinates (what the app reads back as the cursor position, so
+/// where its menu opens) and in the icon's. Also the icon's container and
+/// size, which a click at a host-given point moves (`XHandle::click`).
 #[derive(Clone, Copy, Debug)]
 pub struct ClickPoint {
     pub root: (i16, i16),
     pub local: (i16, i16),
+    pub container: Window,
+    pub size: u16,
 }
 
 struct Icon {
@@ -95,7 +99,20 @@ impl XHandle {
     /// Press and release `button` on `icon`. Sent with an empty event mask,
     /// which delivers it to the client that created the window — the app
     /// that docked the icon — whatever it selected.
-    pub fn click(&self, icon: Window, button: u8, at: ClickPoint) -> Res<()> {
+    ///
+    /// `screen` is the host's click point in screen (logical) coordinates,
+    /// as SNI's Activate/ContextMenu carry it; cce's bar sends the pointer's
+    /// x at the bar's bottom edge. An app opens its menu at the root
+    /// position the event reports, so that point — scaled into X's pixels —
+    /// is where the click is placed, and the container is moved under it
+    /// first so the icon's real position agrees for an app that asks X
+    /// 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) {
+            Some(point) => self.move_under(at, point, x11_scale(&self.conn, self.root)),
+            None => at,
+        };
         let mask = 1u16 << (7 + button.min(5));
         for (kind, state) in [(BUTTON_PRESS_EVENT, 0u16), (BUTTON_RELEASE_EVENT, mask)] {
             let ev = ButtonPressEvent {
@@ -119,6 +136,25 @@ impl XHandle {
         Ok(())
     }
 
+    /// Put `at`'s container so the icon's bottom-centre is the screen point
+    /// (in X pixels), and return the click point to match.
+    fn move_under(&self, at: ClickPoint, (sx, sy): (i32, i32), scale: f64) -> ClickPoint {
+        let (rx, ry) = to_x11_point(sx, sy, scale);
+        let size = i32::from(at.size);
+        let half = size / 2;
+        let cx = (rx - half).max(0);
+        let cy = (ry - (size - 1)).max(0);
+        let _ = self
+            .conn
+            .configure_window(at.container, &ConfigureWindowAux::new().x(cx).y(cy));
+        let local = ((rx - cx).clamp(0, size - 1), (ry - cy).clamp(0, size - 1));
+        ClickPoint {
+            root: (clamp_i16(cx + local.0), clamp_i16(cy + local.1)),
+            local: (clamp_i16(local.0), clamp_i16(local.1)),
+            ..at
+        }
+    }
+
     /// 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).
@@ -431,7 +467,7 @@ impl Tray {
 
         let title = crate::title::resolve(&self.conn, self.root, &self.atoms, icon);
         let half = (size / 2) as i16;
-        let at = ClickPoint { root: (x + half, half), local: (half, half) };
+        let at = ClickPoint { root: (x + half, half), local: (half, half), container, size };
         let image = self.capture_changed(icon);
         log::info!("docked {icon:#x} ({:?}, {size}px, depth {}) in slot {slot}", title.text, geom.depth);
         let _ = self.tx.send(TrayEvent::Docked {
@@ -504,6 +540,35 @@ impl Tray {
     }
 }
 
+/// How many X pixels one logical pixel is: cce writes `Xft.dpi` into the
+/// root window's resources for its X11 scale (192 at scale 2), so it is
+/// read back from there. 1 when absent.
+fn x11_scale(conn: &RustConnection, root: Window) -> f64 {
+    let dpi = conn
+        .get_property(false, root, AtomEnum::RESOURCE_MANAGER, AtomEnum::STRING, 0, 1 << 16)
+        .ok()
+        .and_then(|c| c.reply().ok())
+        .and_then(|r| xft_dpi(&String::from_utf8_lossy(&r.value)));
+    dpi.map_or(1.0, |d| d / 96.0).max(1.0)
+}
+
+/// `Xft.dpi` from an X resource database string.
+pub fn xft_dpi(resources: &str) -> Option<f64> {
+    resources.lines().find_map(|l| {
+        let (k, v) = l.split_once(':')?;
+        (k.trim() == "Xft.dpi").then(|| v.trim().parse().ok()).flatten()
+    })
+}
+
+/// A logical screen point in X root pixels.
+pub fn to_x11_point(x: i32, y: i32, scale: f64) -> (i32, i32) {
+    ((f64::from(x) * scale).round() as i32, (f64::from(y) * scale).round() as i32)
+}
+
+fn clamp_i16(v: i32) -> i16 {
+    v.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
+}
+
 /// Convert a 32-bits-per-pixel ZPixmap to SNI's ARGB32: network byte
 /// order, straight alpha. X's ARGB visuals are premultiplied; a depth-24
 /// image has no alpha and is opaque. `None` for any other layout.
@@ -535,7 +600,15 @@ pub fn to_sni_argb(data: &[u8], depth: u8, lsb_first: bool) -> Option<Vec<u8>> {
 
 #[cfg(test)]
 mod tests {
-    use super::to_sni_argb;
+    use super::{to_sni_argb, to_x11_point, xft_dpi};
+
+    #[test]
+    fn scale_comes_from_xft_dpi() {
+        assert_eq!(xft_dpi("Xcursor.size:\t48\nXft.dpi:\t192\n"), Some(192.0));
+        assert_eq!(xft_dpi("Xcursor.size: 48\n"), None);
+        assert_eq!(to_x11_point(1167, 27, 2.0), (2334, 54));
+        assert_eq!(to_x11_point(1167, 27, 1.0), (1167, 27));
+    }
 
     #[test]
     fn premultiplied_argb_becomes_straight_network_order() {
diff --git a/src/main.rs b/src/main.rs
index e88968e..9ada056 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1354,6 +1354,40 @@ pub(crate) fn query_adjust_position_mode() -> Option<bool> {
     }
 }
 
+/// Where this segment sits on screen: (x, y, height) in logical px, from
+/// `ccectl windows --json`. `None` when the compositor does not list it.
+pub(crate) fn segment_screen_rect(app_id: &str) -> Option<(i32, i32, i32)> {
+    let out = std::process::Command::new(get_ccectl_cmd())
+        .args(["windows", "--json"])
+        .output()
+        .ok()?;
+    segment_rect_in(&String::from_utf8_lossy(&out.stdout), app_id)
+}
+
+/// `segment_screen_rect`'s parse, split out to test.
+pub(crate) fn segment_rect_in(windows_json: &str, app_id: &str) -> Option<(i32, i32, i32)> {
+    windows_json.lines().find_map(|line| {
+        let v: serde_json::Value = serde_json::from_str(line).ok()?;
+        if v.get("app_id")?.as_str()? != app_id {
+            return None;
+        }
+        let n = |k: &str| v.get(k).and_then(|x| x.as_i64()).map(|x| x as i32);
+        Some((n("x")?, n("y")?, n("h")?))
+    })
+}
+
+/// The SNI `Activate`/`ContextMenu` point for a click at segment-local
+/// (`cx`, `cy`): SCREEN coordinates, as the spec asks, with the y at the
+/// segment's bottom edge so a menu the app opens there hangs below the bar
+/// instead of over it. Without the segment's position (compositor not
+/// answering) the local point is all there is.
+pub(crate) fn tray_click_point(segment: Option<(i32, i32, i32)>, cx: i32, cy: i32) -> (i32, i32) {
+    match segment {
+        Some((x, y, h)) => (x + cx, y + h),
+        None => (cx, cy),
+    }
+}
+
 /// One window from `ccectl windows` output: (id, app_id, title, focused).
 pub(crate) type CcectlWindow = (String, String, String, bool);
 
@@ -2158,10 +2192,13 @@ impl cce_ui::engine::Application for StatusApp {
                     MouseButton::Right => 273,
                     _ => 0,
                 };
-                let cx_i = cx as i32;
-                let cy_i = cy as i32;
+                let local = (cx as i32, cy as i32);
+                let segment_app_id = self.get_app_id();
                 let thread_sender = self.sender.clone();
                 std::thread::spawn(move || {
+                    // Off the UI thread: this asks the compositor.
+                    let (cx_i, cy_i) =
+                        tray_click_point(segment_screen_rect(&segment_app_id), local.0, local.1);
                     let rt = tokio::runtime::Builder::new_current_thread()
                         .enable_all()
                         .build()
@@ -2552,6 +2589,19 @@ fn main() {
 mod tests {
     use super::*;
 
+    #[test]
+    fn tray_clicks_report_screen_points_below_the_bar() {
+        let listing = concat!(
+            r#"{"app_id":"cce-status-interface-right-clock","x":1569,"y":0,"w":339,"h":27}"#, "\n",
+            r#"{"app_id":"cce-status-interface-right-tray","x":1127,"y":0,"w":116,"h":27}"#, "\n",
+        );
+        let seg = segment_rect_in(listing, "cce-status-interface-right-tray");
+        assert_eq!(seg, Some((1127, 0, 27)));
+        assert_eq!(tray_click_point(seg, 40, 12), (1167, 27));
+        assert_eq!(segment_rect_in(listing, "cce-status-interface-left-window"), None);
+        assert_eq!(tray_click_point(None, 40, 12), (40, 12));
+    }
+
     // ------------------------------------------------------------------
     // Adaptive contrast: the bar cannot see its own backdrop, so these pin
     // down what it does with the compositor's measurement of it.