status bar
git clone https://git.lucas.co/cce-status-interface.git
fix(xembed-tray): don't take a tray icon's tooltip for the click's popup
A forwarded click makes Wine's explorer.exe, the prefix's tray host, show
the icon's tooltip, and Wine gives it the same window type (DIALOG) and
Win32 styles as the app's menu. The popup watch took whichever
override-redirect window mapped last within 1.5 s of the click, so a
tooltip mapping over an open menu replaced it: the click-away then closed
the tooltip and left the menu up.
A window owned by a Wine plumbing process (a WINEPREFIX process that
is_wine_program does not count as an app) or typed
_NET_WM_WINDOW_TYPE_TOOLTIP is now skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 12 ++++++++++
src/bin/cce-xembed-tray/title.rs | 8 +++++++
src/bin/cce-xembed-tray/x11.rs | 49 ++++++++++++++++++++++++++++++++--------
3 files changed, 60 insertions(+), 9 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index b62c09b..55c6541 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -73,6 +73,18 @@ selection and, per docked icon:
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.
+ **Tooltips are not the popup** (`is_tooltip`). A forwarded click makes
+ Wine's `explorer.exe` — the prefix's tray host — show the icon's tooltip,
+ and Wine gives it the same window type (`DIALOG`) and Win32 styles as the
+ app's menu; only its owner tells them apart. So a window from a Wine
+ plumbing process (`title::is_wine_plumbing`: a `WINEPREFIX` process that
+ `is_wine_program` does not count as an app) is skipped, as is one typed
+ `_NET_WM_WINDOW_TYPE_TOOLTIP`. Until 2026-09-28 the watch took whichever
+ mapped LAST, so a tooltip mapping over an open menu replaced it and the
+ click-away closed the tooltip, leaving the menu up. A headless shadow never
+ shows the tooltip (Wine's tooltip checks the real X pointer); reproduce it
+ by mapping explorer's `DIALOG` window override-redirect yourself while a
+ test app's menu is open.
- **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/title.rs b/src/bin/cce-xembed-tray/title.rs
index c5b4fc9..798a614 100644
--- a/src/bin/cce-xembed-tray/title.rs
+++ b/src/bin/cce-xembed-tray/title.rs
@@ -143,6 +143,14 @@ pub fn is_wine_program(argv0: &str) -> bool {
drive_path && !a[1..].starts_with(":\\windows\\") && !a.contains("\\xalia\\")
}
+/// A process of Wine's own — run under a `WINEPREFIX`, but not a program
+/// `is_wine_program` counts as an app. `explorer.exe`, which hosts every
+/// tray icon in the prefix, is the one that matters: its windows are the
+/// icons and their tooltips, never an app's menu.
+pub fn is_wine_plumbing(pid: u32) -> bool {
+ environ_var(pid, "WINEPREFIX").is_some() && argv0(pid).is_some_and(|a| !is_wine_program(&a))
+}
+
/// A process's name for the fallback: a Windows exe's file stem, or the
/// kernel's `comm` for anything else.
fn process_name(pid: u32) -> Option<String> {
diff --git a/src/bin/cce-xembed-tray/x11.rs b/src/bin/cce-xembed-tray/x11.rs
index 909dfb3..5130bef 100644
--- a/src/bin/cce-xembed-tray/x11.rs
+++ b/src/bin/cce-xembed-tray/x11.rs
@@ -36,6 +36,8 @@ x11rb::atom_manager! {
_XEMBED_INFO,
_NET_WM_NAME,
_NET_WM_PID,
+ _NET_WM_WINDOW_TYPE,
+ _NET_WM_WINDOW_TYPE_TOOLTIP,
UTF8_STRING,
_CCE_XEMBED_TRAY_TIME,
}
@@ -471,16 +473,18 @@ impl Tray {
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 armed = self.watch.lock().ok().and_then(|w| w.armed);
+ let anchor = match armed {
+ Some((at, anchor)) if at.elapsed() < POPUP_ARM => anchor,
+ _ => return Ok(()),
};
+ if self.is_tooltip(window) {
+ log::debug!("{window:#x} mapped after a click, but is a tooltip; not the popup");
+ return Ok(());
+ }
+ if let Ok(mut w) = self.watch.lock() {
+ w.popup = Some(window);
+ }
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 {
@@ -492,6 +496,33 @@ impl Tray {
Ok(())
}
+ /// Whether a window that mapped while a click was armed is a tooltip,
+ /// not the click's popup. A forwarded click makes Wine's `explorer.exe`
+ /// — which hosts the prefix's tray icons — show the icon's tooltip, and
+ /// nothing tells it from the app's menu but whose it is: Wine gives the
+ /// two the same window type (DIALOG) and the same Win32 styles. Taken
+ /// for the popup, the tooltip stole the click-away (the menu stayed
+ /// open) and, mapping after the menu, replaced it in the watch. A
+ /// toolkit that declares `_NET_WM_WINDOW_TYPE_TOOLTIP` is taken at its
+ /// word.
+ fn is_tooltip(&self, window: Window) -> bool {
+ let typed = self
+ .conn
+ .get_property(false, window, self.atoms._NET_WM_WINDOW_TYPE, AtomEnum::ATOM, 0, 16)
+ .ok()
+ .and_then(|c| c.reply().ok())
+ .is_some_and(|r| r.value32().is_some_and(|mut v| v.any(|a| a == self.atoms._NET_WM_WINDOW_TYPE_TOOLTIP)));
+ if typed {
+ return true;
+ }
+ self.conn
+ .get_property(false, window, self.atoms._NET_WM_PID, AtomEnum::CARDINAL, 0, 1)
+ .ok()
+ .and_then(|c| c.reply().ok())
+ .and_then(|r| r.value32().and_then(|mut v| v.next()))
+ .is_some_and(crate::title::is_wine_plumbing)
+ }
+
fn popup_gone(&self, window: Window) {
if let Ok(mut w) = self.watch.lock() {
if w.popup == Some(window) {