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