feat(qt): system tray via ksni

Block B5 of the miniplayer/tray contract. The tray settings rows have
rendered and persisted since the Settings port and driven nothing; this is
what they drive.

src/tray_linux.rs (new) — the ksni StatusNotifierItem: the twelve icon
pixmaps, the variant ladder, the tooltip, the six-item menu, left/middle
click and scroll, and the qbz-tray-updater thread every mutation crosses.
Note the filename inversion it reproduces: tray-light-* holds the BLACK
glyph, so the "mono-light" setting loads tray-dark-*.png.

src/tray_qt.rs (new) — the portable half: init's three gates, the window
verbs, the 400 ms toggle debounce, the transport dispatchers and
in_gamescope(). toggle_window is mini-aware — while the mini is open a tray
click raises the MINI, because a mini-blind toggle would put the main window
beside a live mini on the first click.

src/tray_bridge.rs (new) — QbzTray: trayLive, closeToTray, and the four
signals the ksni thread reaches QML through. Rust cannot touch a
QQuickWindow, so every verb is a signal.

icons/ — the twelve PNGs, copied rather than include_bytes!'d across crates,
so this crate does not depend on a file inside the Slint binary crate.

src/playback_qt.rs — the three live-update pushes: set_track and clear_track
in both arms of refresh_now_playing (the publisher with 23 call sites, so a
QConnect or cast track change updates the tooltip too), and set_playing in
BOTH is_playing != was_playing blocks, each beside the existing discord_push.

src/settings_qt.rs — tray() widened to pub(crate) for the session-entry read;
the tray_close_to_tray snapshot default corrected from false to true, which
was cosmetic while nothing consumed it and becomes behavioural now; and the
tray-icon-theme select arm re-themes the running icon with no restart.

Cargo.toml + Cargo.lock — ksni 0.3 with default-features off. Never the
tokio feature: the blocking API panics inside tokio and the panic is
swallowed, which is why the two dedicated threads exist.

src/main.rs, build.rs, qml/Main.qml — the registrations, and tray_qt::init
in on_session_entered. That call site is also deliberately the process's
first settings_qt::tray(), because the store binds per user and a read from
the bridge's construction seed would latch an empty one at the login screen.

The Connections block that answers the four signals lands with B6, together
with the window verbs it calls. Until then the tray renders, its tooltip and
menu are live, and its clicks reach Rust and stop there.

cc
This commit is contained in:
vicrodh committed 2026-08-04 02:47:56 -06:00
1 parent 0d6e7d3498
commit 56d4387172
22 files changed
+1598 -4

No files matched your search

+19
View File
@@ -105,5 +105,24 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
# xdg-portal path is the Wayland-correct one and needs no extra system libs.
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] }
# Linux system tray via StatusNotifierItem (src/tray_linux.rs). Linux only —
# owner ruling K2: `crates/qbz/src/tray/macos.rs` exists because Slint's winit
# backend already bundles `muda`; under Qt that premise is gone, so macOS would
# be a NEW implementation, not a port. Windows has no tray in either tree.
#
# The comment below is copied verbatim from the reference
# (crates/qbz/Cargo.toml:139-143) because the feature set is not negotiable:
#
# default-features = false: ksni's default is "tokio", which flips the shared
# zbus 5 executor to tokio graph-wide (breaks accesskit/atspi plain-thread
# connections — see the rfd comment above). We only use the blocking API on
# worker threads; async-io self-drives the connection from any context.
#
# ksni 0.3.4 is already in the workspace lock (pulled by crates/qbz), so this
# adds no new lock entry. Feature unification is per-crate-graph, not
# per-target: ANY dependency that turns on zbus/tokio poisons the whole binary.
[target.'cfg(target_os = "linux")'.dependencies]
ksni = { version = "0.3", default-features = false, features = ["blocking", "async-io"] }
[build-dependencies]
cxx-qt-build = "0.7"
+1 -1
View File
@@ -51,7 +51,7 @@ fn main() {
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array.
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs"],
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+6
View File
@@ -196,6 +196,12 @@ ApplicationWindow {
// SILENT no-op — the mini queue document would never reach QML and
// nothing would be logged on either side.
QbzMini.boot()
// Same reason, and it bites harder here: the tray's clicks reach QML
// ONLY through this bridge's signals, so an unbooted QbzTray is a tray
// whose every click does nothing, with zero evidence in the log
// (contract §15 trap 27). The handlers for those signals arrive with
// B6, together with the window verbs they call.
QbzTray.boot()
// Seed the maximized latch ONCE, imperatively — see its declaration for
// why it must not be a binding on the bridge property.
+30
View File
@@ -84,6 +84,10 @@ mod kiosk_nav_bridge;
// singleton — the port of the Slint MiniPlayerState global. Its controller
// half is `mini_qt` below, which is a PLAIN module.
mod mini_bridge;
// System tray (the same contract, block B5): the QbzTray singleton — the
// window verbs' Qt-thread hop. Its controllers are `tray_qt` (portable) and
// `tray_linux` (the ksni item), both PLAIN modules below.
mod tray_bridge;
mod kiosk_nav_qt;
// The kiosk profile itself (the same contract, §8): env/pref resolution, the
// live Kiosk <-> Desktop toggle, and the boot decisions that follow from it.
@@ -229,6 +233,14 @@ mod qconnect_qt;
// Plain module — it declares no #[cxx_qt::bridge], so it must NOT appear in
// build.rs's rust_files.
mod mini_qt;
// Tray controllers (the same contract, block B5, §5). `tray_qt` is portable
// and holds the gates, the debounce and the transport verbs; `tray_linux` is
// the ksni StatusNotifierItem and carries no inner cfg, so the gate is on the
// mod line (the shape of `crates/qbz/src/tray/mod.rs:26-27`). Owner ruling K2
// keeps macOS and Windows out — neither has a tray in either frontend.
mod tray_qt;
#[cfg(target_os = "linux")]
mod tray_linux;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
@@ -422,6 +434,24 @@ fn on_session_entered() {
// ListenBrainz opt-ins and starts the scrobble-queue flush watcher.
// Strictly opt-in — every one of them is inert until the user connects it.
integrations_qt::start(&app());
// System tray (Linux only, ksni — owner ruling K2), the port of
// `init_shell_for_user`'s `tray::init` call (`crates/qbz/src/main.rs:257-263`).
// Suppressed under gamescope by the same predicate the reference uses
// (`:262`): an extra mapped surface can steal the compositor's
// focused-window pick on the Deck. This function is MULTI-ENTRY (login,
// session restore, "Start offline"), and `init`'s three gates are what make
// that safe — the one-shot is checked AFTER the enabled gate, so a disabled
// first call does not burn it and a later recovery login can still arm the
// tray. THIS is also the first `settings_qt::tray()` call in the process,
// by design: it runs after the per-user store binds, where the bridge's
// construction-time seed would have latched an empty one.
{
let tray = settings_qt::tray().get_settings().unwrap_or_default();
tray_qt::init(
tray.tray_icon_theme.clone(),
tray.enable_tray && !tray_qt::in_gamescope(),
);
}
// Phase 7: the sidebar playlist tree.
load_sidebar_once();
// Refresh the favourite-id cache from the network. The disk seed already
+46 -1
View File
@@ -1739,6 +1739,16 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>
b.as_mut().set_np_track_id(QString::from(""));
});
crate::lyrics_qt::publish_idle();
// Tray tooltip: the no-track arm. 1:1 with the reference, where
// `clear_track()` (crates/qbz/src/playback.rs:1918) and `set_track(…)`
// (`:2160`) are the two arms of ONE publisher,
// `refresh_now_playing_meta` (`:1895`) — this function is its Qt
// analogue, and BOTH pushes live here. Silent no-op with no tray.
// Clearing also drops `is_playing`, so the middle-click hint cannot go
// stale.
if let Some(t) = crate::tray_qt::handle() {
t.clear_track();
}
return;
};
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
@@ -1754,6 +1764,22 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>
track.artist,
track.duration_secs,
);
// Tray tooltip: the has-track arm (§5.6). Edge-triggered — this publisher
// runs on a track change, never per tick (§7-M12).
//
// The three strings are exactly the reference's
// (crates/qbz/src/playback.rs:2163-2165): the VERSIONED title, the artist,
// and the CLEAN album — `track.album`, NOT the release-variant
// `album_display` built below, which `playback.rs:1937` / `:1941-1948`
// deliberately keeps as a separate value.
//
// It goes in THIS function and not in the poll loop's de-duped track edge:
// `refresh_now_playing` has 23 call sites across nine modules, so binding
// the tooltip to the poll loop would leave it stale on every QConnect
// handoff, cast event and click-to-play (§15 trap 34).
if let Some(t) = crate::tray_qt::handle() {
t.set_track(title.clone(), track.artist.clone(), track.album.clone());
}
let (tier, label) = quality_badge(&track);
let album_id = track.album_id.clone().unwrap_or_default();
// Album with its release variant appended ("Octavarium (2009 Remaster)"),
@@ -2395,8 +2421,19 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
// returns to the top early and must NOT run
// `seen_position = position` (the reset above is the
// point). Dropping the Discord push here would leave the
// presence saying "playing" after the timer stopped us.
// presence saying "playing" after the timer stopped us. The
// tray tooltip's middle-click hint has the identical
// problem — it would keep saying "Middle-click to pause"
// after the stop-after timer fired — so its push is a
// sibling here, exactly as in the reference, where tray
// (playback.rs:5719-5722) and Discord (`:5734`) share one
// `if`. THIS IS ONE OF TWO `is_playing != was_playing`
// BLOCKS; a push in only one is the silent half-port
// (§5.6, AC-27).
if is_playing != was_playing {
if let Some(t) = crate::tray_qt::handle() {
t.set_playing(is_playing);
}
crate::integrations_qt::discord_push(&runtime);
}
was_playing = is_playing;
@@ -2420,7 +2457,15 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
}
seen_position = position;
// Reflect play/pause into the tray tooltip on the TRANSITION only,
// so the "Middle-click to pause/play" hint stays correct without
// spamming the updater channel every tick (§7-M12). The second of
// the two `is_playing != was_playing` blocks — see the sibling in
// the stop-after arm above.
if is_playing != was_playing {
if let Some(t) = crate::tray_qt::handle() {
t.set_playing(is_playing);
}
crate::integrations_qt::discord_push(&runtime);
}
was_playing = is_playing;
+32 -2
View File
@@ -967,7 +967,18 @@ pub fn save_pref(key: &str, value: serde_json::Value) {
// file the Slint tray_settings.rs glue writes).
static TRAY: OnceLock<qbz_app::settings::tray::TraySettingsState> = OnceLock::new();
fn tray() -> &'static qbz_app::settings::tray::TraySettingsState {
/// `pub(crate)` for the tray port (contract A-36): `on_session_entered` reads
/// `enable_tray` + `tray_icon_theme` here to build `tray_qt::init`'s two
/// arguments, and `tray_bridge`'s seed reads `close_to_tray`. Every caller was
/// inside this module until then. **No signature change, no `pub`, no
/// re-export** — and duplicating the accessor instead would be the
/// third-private-copy this block exists to prevent.
///
/// Binds ONCE per process and does not rebind on a user switch — a deliberate,
/// labelled divergence (§13-D20, ticket T-1): `USER_DIR` is itself a
/// whole-shell `OnceLock` that also feeds the pinned store and discover prefs,
/// so a tray-only rebind would fix one of three and hide the other two.
pub(crate) fn tray() -> &'static qbz_app::settings::tray::TraySettingsState {
TRAY.get_or_init(|| {
let state = qbz_app::settings::tray::TraySettingsState::new_empty();
if let Some(dir) = crate::sidebar_qt::user_dir() {
@@ -1640,10 +1651,20 @@ pub async fn publish_snapshot() {
gpu_powers: gpu_power_choice().0,
gpu_power_index: gpu_power_choice().1,
tray_enable: tray().get_settings().map(|t| t.enable_tray).unwrap_or(true),
// S1: the shared default is `close_to_tray: true`
// (`qbz-app/src/settings/tray.rs:61`), and the Slint glue reads it
// with `unwrap_or_default()` on the struct
// (`crates/qbz/src/tray_settings.rs:54-56`). This line used to fall
// back to `false`, which was cosmetic while nothing consumed it and
// becomes BEHAVIOURAL with the tray: with no store bound, the
// user's very first close would quit instead of hiding. The
// adjacent `tray_enable` read above already uses `.unwrap_or(true)`
// against `tray.rs:59` — the disagreement between two sibling reads
// of one store was the smell.
tray_close_to_tray: tray()
.get_settings()
.map(|t| t.close_to_tray)
.unwrap_or(false),
.unwrap_or(true),
tray_icon_themes: TRAY_ICON_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
@@ -2173,6 +2194,15 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
if let Err(e) = tray().set_tray_icon_theme(v) {
log::error!("[qbz-qt] persist tray icon theme failed: {e}");
}
// Re-theme the RUNNING tray icon live, no restart — the fourth of
// §5.6's live-update rows (reference: `crates/qbz/src/main.rs:11258-11264`).
// The push re-decodes the pixmaps on the updater thread and emits
// the SNI `NewIcon` signal, so panels re-fetch in place. A silent
// no-op when no tray is live, which is the whole point of routing
// through `handle()`.
if let Some(t) = crate::tray_qt::handle() {
t.set_icon_theme(v.to_string());
}
}
"auto-theme-source" => {
const SOURCES: &[&str] = &["system", "wallpaper", "image"];
+171
View File
@@ -0,0 +1,171 @@
//! QbzTray — the tray's QML seam (contract rows A-21, A-21b, A-22).
//!
//! Same mechanics as `mini_bridge.rs` / `immersive_bridge.rs`:
//! `OnceLock<CxxQtThread>`, a `ui()` that is a silent no-op before `boot()`,
//! and `impl Default` for construction-seeded values.
//!
//! ## Why this bridge exists at all
//!
//! Rust cannot reach a `QQuickWindow` (contract §2.3), so the tray cannot show,
//! hide, raise or close a window by itself. The hop is:
//!
//! ```text
//! ksni thread -> tray_qt::<verb>() -> tray_bridge::ui(..) -> #[qsignal]
//! -> Connections in Main.qml -> the window function
//! ```
//!
//! That path is well-trodden, not a first: eleven `#[qsignal]`s across nine
//! bridges already use it, the nearest analogue being
//! `hotkeys_bridge.rs:137-138`'s `focus_search_requested`, consumed at
//! `qml/shell/AppShell.qml:104-107` as
//! `Connections { target: QbzHotkeys; function onFocusSearchRequested() … }`.
//! `#[auto_cxx_name]` is what turns `window_show_requested` into QML's
//! `onWindowShowRequested`.
//!
//! ## Both properties need WRITERS — this is the silent-failure row (A-21b)
//!
//! cxx-qt derives `Default`, so a `bool` `#[qproperty]` nobody writes is
//! `false` FOREVER. `Main.qml`'s close choreography is
//! `QbzTray.trayLive && QbzTray.closeToTray`; with either operand unwritten the
//! expression is permanently false, **every close quits, and the failure looks
//! exactly like "close-to-tray was not implemented"** (§15 trap 31). The three
//! writers, none optional:
//!
//! - **(a) the seed below**, at construction;
//! - **(b) `tray_qt::init`'s thread pushes `tray_live`** on BOTH its `Ok` and
//! its `Err` arm — the two arms the reference sets `TRAY` in
//! (`crates/qbz/src/tray/mod.rs:151-158`) — and it re-pushes `close_to_tray`
//! from the per-user store once a session is entered (the seed below runs at
//! the login screen, where that store is not bound yet);
//! - **(c) the `"tray-close-to-tray"` Settings write arm** pushes
//! `close_to_tray` beside its persist, so the toggle changes the very next
//! close with no restart.
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
#[cxx_qt::bridge]
pub mod qbz_tray {
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// Whether a tray icon is actually live in a panel. FALSE when the tray
// is disabled by the user, suppressed under gamescope, or failed to
// initialise (an icon-decode Err aborts creation). Half of the
// close-to-tray predicate: **tray off => close QUITS**, because gating
// on the setting alone would strand the process with no window and no
// way back on a desktop with no SNI host (§5.7, §15 trap 22).
#[qproperty(bool, tray_live)]
// The user's "Close to tray" setting, mirrored for QML. The shared
// default is `true` (`qbz-app/src/settings/tray.rs:61`).
#[qproperty(bool, close_to_tray)]
type QbzTray = super::QbzTrayRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
/// singleton). **Without it every `tray_bridge::ui()` is a SILENT
/// no-op** — the bridge never errors, the signals simply never arrive,
/// so the tray's clicks would do nothing at all (§15 trap 27).
#[qinvokable]
fn boot(self: Pin<&mut QbzTray>);
/// QML reports the main window's REAL visibility back after every
/// change. **D18 — there is ONE visibility owner:** `hideToTray()` and
/// `showFromTray()` are the only functions that call this, and they are
/// the only places the main window is hidden or shown, including from
/// the miniplayer's enter/exit. That is what keeps the tray toggle
/// honest (§5.8.1, AC-24).
#[qinvokable]
fn set_window_shown(self: Pin<&mut QbzTray>, shown: bool);
/// Show + raise + focus the main window -> `Main.qml`'s
/// `showFromTray()`.
#[qsignal]
fn window_show_requested(self: Pin<&mut QbzTray>);
/// Hide the main window to the tray -> `Main.qml`'s `hideToTray()`.
#[qsignal]
fn window_hide_requested(self: Pin<&mut QbzTray>);
/// Raise the MINIPLAYER window (it is open; the mini IS the app's
/// window right now). Emitted by `tray_qt::present()`, which the tray's
/// left-click degenerates to while the mini is up — §5.8.2 / §13-D21.
#[qsignal]
fn mini_present_requested(self: Pin<&mut QbzTray>);
/// Quit the app -> `Main.qml`'s
/// `persistWindowGeometryOnExit(); Qt.quit()`.
#[qsignal]
fn quit_requested(self: Pin<&mut QbzTray>);
}
impl cxx_qt::Threading for QbzTray {}
}
use qbz_tray::QbzTray;
/// Rust side of the tray bridge (plain storage, phase-1 pattern).
pub struct QbzTrayRust {
tray_live: bool,
close_to_tray: bool,
}
impl Default for QbzTrayRust {
fn default() -> Self {
Self {
// The tray is initialised at session entry, so at construction
// (Main.qml's Component.onCompleted, i.e. the login screen) this is
// normally false; `tray_qt::init` pushes the real value from both
// arms of its init thread — A-21b(b).
tray_live: crate::tray_qt::handle().is_some(),
// The literal shared default (`qbz-app/src/settings/tray.rs:61` —
// `close_to_tray: true`), matching what the Settings document
// reports after the S1 fix. Seeding `false` here would be a second
// copy of that bug and would make the user's very first close QUIT
// instead of hide (AC-29).
//
// **It is a literal on purpose: this must NOT read the store.** A
// `#[qml_singleton]` is constructed on first access, and
// `QbzTray.boot()` sits in `Main.qml`'s `Component.onCompleted` —
// which runs at the LOGIN screen, where `sidebar_qt::user_dir()` is
// still `None`. `settings_qt::tray()` is a `OnceLock::get_or_init`
// whose closure is the only place `init_at` is ever called, so a
// read from here would latch an UNBOUND store for the life of the
// process: every later `get_settings()` returns "No active session",
// and Settings' whole SYSTEM TRAY group — which works today —
// silently stops persisting. `tray_qt::init` pushes the user's real
// value at session entry, after the store binds and long before any
// close can happen.
close_to_tray: true,
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (immersive_bridge.rs / mini_bridge.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzTray>> = OnceLock::new();
/// Queue a tray-bridge mutation onto the Qt event loop (no-op before boot
/// registers the thread). **Every tray verb reaches QML through here** — the
/// ksni item runs on its own thread and must never touch a QObject directly.
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzTray>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
impl qbz_tray::QbzTray {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] tray Qt thread already registered");
}
}
/// D18's report-back. Flag only — it never touches a window.
pub fn set_window_shown(self: Pin<&mut Self>, shown: bool) {
crate::tray_qt::set_window_shown(shown);
}
}
+620
View File
@@ -0,0 +1,620 @@
//! Linux system tray via `ksni` (StatusNotifierItem) — the ksni half.
//!
//! Port of `crates/qbz/src/tray/linux.rs` (485 L) per the 2026-08-03
//! miniplayer/tray contract (row A-20, §5.2-§5.6). The icon decoding, theme
//! resolution, tooltip composition and the updater-thread pattern are
//! byte-for-byte equivalent; the ONLY behavioural difference is that tray
//! actions call `tray_qt`'s free verbs, which emit `QbzTray` signals, because
//! Rust cannot reach a `QQuickWindow` (§2.3, §5.8).
//!
//! **Mounted `#[cfg(target_os = "linux")]` from `main.rs`** (owner ruling K2 —
//! macOS and Windows are out; §13-D6).
//!
//! ## Why `QSystemTrayIcon` and `Qt.labs.platform.SystemTrayIcon` are not here
//!
//! The process is a `QGuiApplication` (`src/main.rs`), cxx-qt-lib 0.7.3 ships
//! no Widgets bindings at all, and `Qt.labs.platform`'s item has **no scroll
//! signal**, ONE flat `tooltip` string where this tray composes a 3-4 line
//! block, ONE `icon.source` where this tray publishes four pixmaps, and it
//! links QtWidgets — under `QGuiApplication` it silently does not appear
//! (contract §3.3). A hybrid is impossible: the SNI menu is part of the same
//! D-Bus object and `ksni` owns `fn menu()`.
use std::sync::{
mpsc::{self, Sender},
Arc, Mutex,
};
use image::GenericImageView;
use ksni::{blocking::TrayMethods, menu::StandardItem, Icon, MenuItem, Orientation, ToolTip, Tray};
use crate::tray_qt::NowPlaying;
// Multiple pixmap sizes per StatusNotifierItem spec — panels pick the best
// match for their bar height (22 = base, 32/44/64 = HiDPI). THE PANEL CHOOSES;
// QBZ publishes all four and does not pick.
//
// Legacy filename note (shared with the Tauri assets): `tray-light-*` holds
// the BLACK glyph (for LIGHT panels) and `tray-dark-*` holds the WHITE glyph.
// The constants use glyph-colour names so the mapping is explicit.
//
// The trap is measured, not merely comment-asserted: `tray-light-22`'s
// opaque-pixel mean RGB is 0.0 and `tray-dark-22`'s is 255.0 — UT-5 asserts on
// exactly that, so no future edit can quietly invert it (§15 trap 18).
//
// The twelve PNGs are COPIED into `crates/qbz-qt/icons/` rather than
// `include_bytes!`d across crates (§13-D17): a Qt-crate -> Slint-binary-crate
// file dependency breaks the day that crate is pruned. They are NOT in
// `qml/assets/` — `build.rs` sweeps that tree into the qrc and these bytes are
// read by Rust, not by QML.
const TRAY_ICON_MONO_BLACK_22: &[u8] = include_bytes!("../icons/tray-light-22.png");
const TRAY_ICON_MONO_BLACK_32: &[u8] = include_bytes!("../icons/tray-light-32.png");
const TRAY_ICON_MONO_BLACK_44: &[u8] = include_bytes!("../icons/tray-light-44.png");
const TRAY_ICON_MONO_BLACK_64: &[u8] = include_bytes!("../icons/tray-light-64.png");
const TRAY_ICON_MONO_WHITE_22: &[u8] = include_bytes!("../icons/tray-dark-22.png");
const TRAY_ICON_MONO_WHITE_32: &[u8] = include_bytes!("../icons/tray-dark-32.png");
const TRAY_ICON_MONO_WHITE_44: &[u8] = include_bytes!("../icons/tray-dark-44.png");
const TRAY_ICON_MONO_WHITE_64: &[u8] = include_bytes!("../icons/tray-dark-64.png");
const TRAY_ICON_COLOR_22: &[u8] = include_bytes!("../icons/tray-color-22.png");
const TRAY_ICON_COLOR_32: &[u8] = include_bytes!("../icons/tray-color-32.png");
const TRAY_ICON_COLOR_44: &[u8] = include_bytes!("../icons/tray-color-44.png");
const TRAY_ICON_COLOR_64: &[u8] = include_bytes!("../icons/tray-color-64.png");
fn is_flatpak() -> bool {
std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists()
}
/// Detect whether the system prefers a dark color scheme. Tries (in order):
/// GNOME `color-scheme`, GTK `prefer-dark-theme`, KDE `ColorScheme`. Defaults
/// to `false` (light) when nothing matches.
///
/// **The three steps do NOT have the same early-return shape, and that is
/// deliberate** (`crates/qbz/src/tray/linux.rs:46-81`):
///
/// - step 1 can only ever return `true` — a `"default"` / `"prefer-light"`
/// answer falls through;
/// - steps 2 and 3 return on the first KEY MATCH, **falsey value included**, so
/// `gtk-application-prefer-dark-theme=0` returns `false` and never consults
/// gtk-3.0 or kdeglobals. Only the key's ABSENCE falls through.
///
/// **One-shot** — it runs inside `decode_tray_icons`, i.e. at init and on an
/// explicit `SetIconTheme`; there is no live listener for a system light/dark
/// switch. REPRODUCE-1:1 (§12-P10, ticket T-8).
fn prefer_dark_tray() -> bool {
if let Ok(out) = std::process::Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output()
{
if String::from_utf8_lossy(&out.stdout).contains("prefer-dark") {
return true;
}
}
if let Some(config) = dirs::config_dir() {
for variant in ["gtk-4.0", "gtk-3.0"] {
let path = config.join(variant).join("settings.ini");
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
if let Some(rest) = line
.trim()
.strip_prefix("gtk-application-prefer-dark-theme")
{
let v = rest.trim_start_matches(['=', ' ']);
return v.starts_with('1') || v.starts_with("true");
}
}
}
}
if let Ok(content) = std::fs::read_to_string(config.join("kdeglobals")) {
for line in content.lines() {
if let Some(rest) = line.trim().strip_prefix("ColorScheme") {
return rest
.trim_start_matches(['=', ' '])
.to_lowercase()
.contains("dark");
}
}
}
}
false
}
/// Convert an embedded RGBA PNG to the ARGB32 big-endian layout ksni expects.
fn decode_pixmap(bytes: &[u8]) -> Result<Icon, String> {
let img = image::load_from_memory(bytes).map_err(|e| format!("decode tray icon: {e}"))?;
let (width, height) = img.dimensions();
let mut data = img.into_rgba8().into_vec();
// ksni spec: ARGB32 with A, R, G, B order per pixel. `image` gives us
// RGBA; rotate_right(1) moves the alpha byte from the last slot to the
// first. It is a byte permutation, not a colour conversion: no
// premultiplication and no further endian swap.
for pixel in data.chunks_exact_mut(4) {
pixel.rotate_right(1);
}
Ok(Icon {
width: width as i32,
height: height as i32,
data,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum IconVariant {
/// Black glyph — for light panels.
MonoBlack,
/// White glyph — for dark panels (Plasma dark, GNOME top bar).
MonoWhite,
/// Full color vinyl.
Color,
}
/// Resolve which icon variant to load. `theme_override`:
/// - "auto" (or unrecognised) — system color-scheme detection
/// - "mono-light" — white (light-coloured) glyph
/// - "mono-dark" — black (dark-coloured) glyph
/// - "color" — full vinyl logo
///
/// **The SECOND inversion, independent of the filenames:** the setting string
/// names the GLYPH's lightness, where the filename names the PANEL. So
/// `"mono-light"` -> `MonoWhite` -> `tray-dark-*.png`. Any port that re-derives
/// a path from the setting string gets this backwards and the icon is invisible
/// on half the desktops (§5.2, §15 trap 18).
fn resolve_variant(theme_override: Option<&str>) -> IconVariant {
match theme_override {
Some("mono-light") => IconVariant::MonoWhite,
Some("mono-dark") => IconVariant::MonoBlack,
Some("color") => IconVariant::Color,
_ => {
if prefer_dark_tray() {
IconVariant::MonoWhite
} else {
IconVariant::MonoBlack
}
}
}
}
/// The four PNG byte sets for a variant, ascending (22/32/44/64) — the order
/// panels see. Split out of `decode_tray_icons` so UT-5 can assert on the BYTES
/// rather than on a filename string, which is what makes the §5.2 filename trap
/// untestable-by-accident.
fn icon_sources(variant: IconVariant) -> [&'static [u8]; 4] {
match variant {
IconVariant::MonoBlack => [
TRAY_ICON_MONO_BLACK_22,
TRAY_ICON_MONO_BLACK_32,
TRAY_ICON_MONO_BLACK_44,
TRAY_ICON_MONO_BLACK_64,
],
IconVariant::MonoWhite => [
TRAY_ICON_MONO_WHITE_22,
TRAY_ICON_MONO_WHITE_32,
TRAY_ICON_MONO_WHITE_44,
TRAY_ICON_MONO_WHITE_64,
],
IconVariant::Color => [
TRAY_ICON_COLOR_22,
TRAY_ICON_COLOR_32,
TRAY_ICON_COLOR_44,
TRAY_ICON_COLOR_64,
],
}
}
/// Decode pixmaps (22/32/44/64) for the resolved variant. `collect()` into a
/// `Result` short-circuits on the first decode error, and that error aborts
/// tray creation entirely (§5.6).
fn decode_tray_icons(theme_override: Option<&str>) -> Result<Vec<Icon>, String> {
icon_sources(resolve_variant(theme_override))
.iter()
.map(|b| decode_pixmap(b))
.collect()
}
/// The ksni item itself.
///
/// It carries no runtime, no window handle and no tokio handle — where the
/// reference needs all three (`tray/linux.rs:164-171`), the Qt verbs are free
/// functions that reach `crate::app()` / `crate::spawn` themselves and emit
/// signals for anything window-shaped. Named `QbzTrayItem` so it cannot be
/// confused with the `QbzTray` QObject in `tray_bridge.rs`.
struct QbzTrayItem {
icons: Vec<Icon>,
now_playing: Option<NowPlaying>,
is_playing: bool,
}
impl Tray for QbzTrayItem {
fn id(&self) -> String {
"com.blitzfc.qbz".into()
}
fn title(&self) -> String {
// Untranslated on purpose — 1:1 with `tray/linux.rs:184-186`.
"QBZ".into()
}
fn icon_name(&self) -> String {
// Intentionally empty: SNI panels (KDE Plasma especially) prefer
// IconName over IconPixmap when both are present, and resolving the
// app id against the icon theme picks up the full colour app icon
// instead of our themed monochrome glyph (issue #362). An empty name
// forces panels to render IconPixmap directly. DO NOT "improve" this
// by returning the app id — that IS the regression (§15 trap 19).
String::new()
}
fn icon_pixmap(&self) -> Vec<Icon> {
self.icons.clone()
}
fn tool_tip(&self) -> ToolTip {
// The composition lives in `tray_qt` so it is unit-testable without a
// D-Bus connection (UT-4).
let (title, description) =
crate::tray_qt::compose_tooltip(self.now_playing.as_ref(), self.is_playing);
ToolTip {
title,
description,
icon_name: String::new(),
icon_pixmap: vec![],
}
}
/// Primary click (left) — toggle main window visibility. Mini-aware: while
/// the miniplayer is open this raises the MINI (§5.8.2, §13-D21).
fn activate(&mut self, _x: i32, _y: i32) {
log::info!("[tray] primary activate (left click)");
crate::tray_qt::toggle_window();
}
/// Secondary click (middle) — play/pause.
fn secondary_activate(&mut self, _x: i32, _y: i32) {
log::info!("[tray] secondary activate (middle click) -> play/pause");
crate::tray_qt::dispatch_play_pause();
}
/// Mouse wheel — adjust volume in 5%-per-notch steps. Panels report ±120
/// per notch; touch-pad scrolls produce smaller deltas, so we normalise by
/// 120 and fall back to `signum()`. Horizontal scroll is ignored.
///
/// The 5 % step itself lives in `dispatch_volume_delta`, not here — same
/// split as the reference.
fn scroll(&mut self, delta: i32, orientation: Orientation) {
if !matches!(orientation, Orientation::Vertical) || delta == 0 {
return;
}
let mut ticks = delta / 120;
if ticks == 0 {
ticks = delta.signum();
}
log::debug!("[tray] scroll delta={} ticks={}", delta, ticks);
// Positive ticks = wheel-up = volume up.
crate::tray_qt::dispatch_volume_delta(ticks);
}
/// Six items, in this order: Play/Pause · Next · Previous · separator ·
/// Show/Hide Window · separator · Quit QBZ. **Every item is unconditionally
/// enabled and unconditionally visible** — there is no enable or visibility
/// rule anywhere in the reference's builder, and there are no radio,
/// checkbox or submenu items (§5.3). Note Next comes BEFORE Previous.
///
/// `ksni` calls `menu()` on every update, so the labels re-translate on the
/// next tray update after a language change — but nothing fires an explicit
/// refresh on the language edge. REPRODUCE-1:1 (§12-P14, ticket T-10).
fn menu(&self) -> Vec<MenuItem<Self>> {
vec![
StandardItem {
label: qbz_i18n::t("Play/Pause"),
activate: Box::new(|_this: &mut Self| crate::tray_qt::dispatch_play_pause()),
..Default::default()
}
.into(),
StandardItem {
label: qbz_i18n::t("Next Track"),
activate: Box::new(|_this: &mut Self| crate::tray_qt::dispatch_next()),
..Default::default()
}
.into(),
StandardItem {
label: qbz_i18n::t("Previous Track"),
activate: Box::new(|_this: &mut Self| crate::tray_qt::dispatch_previous()),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: qbz_i18n::t("Show/Hide Window"),
activate: Box::new(|_this: &mut Self| crate::tray_qt::toggle_window()),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: qbz_i18n::t("Quit QBZ"),
activate: Box::new(|_this: &mut Self| crate::tray_qt::quit()),
..Default::default()
}
.into(),
]
}
}
/// Updates dispatched from the rest of the app into the ksni worker.
///
/// We cannot call `ksni::blocking::Handle::update` directly from a tokio task —
/// `ksni` 0.3 (`feature = "blocking"`) wraps every update in
/// `Runtime::block_on` (`ksni-0.3.4/src/blocking.rs:206-208`), which panics
/// inside an existing tokio runtime (the playback poll loop runs on one). **The
/// panic is swallowed, so updates appear to succeed but never apply.** We
/// serialise updates over a `std::sync::mpsc` channel applied from a dedicated
/// `std::thread` outside any tokio context. §15 trap 25 — this is structural,
/// not stylistic.
enum TrayUpdate {
SetTrack {
title: String,
artist: String,
album: String,
},
ClearTrack,
SetPlaying(bool),
/// Re-decode pixmaps for the requested theme override and push them live
/// (`NewIcon` SNI signal — panels re-fetch without restart).
SetIconTheme(String),
}
/// Cross-thread handle to the live ksni tray. Cloneable; mutators forward to
/// the worker thread, safe from any async context. When the tray failed to
/// start the inner sender stays `None` and every mutator is a no-op.
#[derive(Clone)]
pub(crate) struct LinuxTrayHandle {
sender: Arc<Mutex<Option<Sender<TrayUpdate>>>>,
}
impl LinuxTrayHandle {
fn empty() -> Self {
Self {
sender: Arc::new(Mutex::new(None)),
}
}
/// Own `handle` on a dedicated `std::thread` and drain the channel from
/// there. The loop ends when every sender has dropped.
///
/// `Handle::update` returns `Option<R>` (`None` once the service is gone),
/// which is `#[must_use]`; every call below discards it with `let _ =`
/// because a dead service is exactly the no-op the tray wants.
fn install(&self, handle: ksni::blocking::Handle<QbzTrayItem>) {
let (tx, rx) = mpsc::channel::<TrayUpdate>();
std::thread::Builder::new()
.name("qbz-tray-updater".into())
.spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
TrayUpdate::SetTrack {
title,
artist,
album,
} => {
log::debug!(
"[tray] tooltip update -> {} / {} / {}",
title,
artist,
album
);
let _ = handle.update(move |tray| {
tray.now_playing = Some(NowPlaying {
title,
artist,
album,
});
});
}
TrayUpdate::ClearTrack => {
log::debug!("[tray] tooltip cleared");
let _ = handle.update(|tray| {
// Clearing the track also drops the play state,
// so the middle-click hint cannot go stale.
tray.now_playing = None;
tray.is_playing = false;
});
}
TrayUpdate::SetPlaying(is_playing) => {
let _ = handle.update(move |tray| {
tray.is_playing = is_playing;
});
}
TrayUpdate::SetIconTheme(theme) => {
log::info!("[tray] icon theme override -> {}", theme);
// Decoded HERE, on the updater thread, off the ksni
// internals. A decode failure logs and leaves the
// OLD icons in place rather than clearing them.
match decode_tray_icons(Some(&theme)) {
Ok(new_icons) => {
let _ = handle.update(move |tray| {
tray.icons = new_icons;
});
}
Err(e) => {
log::error!(
"[tray] failed to decode icons for theme '{}': {}",
theme,
e
);
}
}
}
}
}
log::debug!("[tray] updater thread exiting");
})
.expect("spawn tray updater thread");
if let Ok(mut guard) = self.sender.lock() {
*guard = Some(tx);
}
}
fn send(&self, msg: TrayUpdate) {
let guard = match self.sender.lock() {
Ok(g) => g,
Err(_) => return,
};
if let Some(tx) = guard.as_ref() {
let _ = tx.send(msg);
}
}
pub(crate) fn set_track(&self, title: String, artist: String, album: String) {
self.send(TrayUpdate::SetTrack {
title,
artist,
album,
});
}
pub(crate) fn clear_track(&self) {
self.send(TrayUpdate::ClearTrack);
}
pub(crate) fn set_playing(&self, is_playing: bool) {
self.send(TrayUpdate::SetPlaying(is_playing));
}
pub(crate) fn set_icon_theme(&self, theme: String) {
self.send(TrayUpdate::SetIconTheme(theme));
}
}
/// Initialize the Linux ksni tray service. Spawns a background thread that owns
/// the SNI service and returns a cloneable handle for live tooltip / theme
/// updates. `theme_override`: "auto"/"mono-light"/"mono-dark"/"color".
///
/// **Called ONLY from the `qbz-tray-init` thread** (`tray_qt::init`): `spawn()`
/// below is the blocking API and panics inside a tokio runtime.
pub(crate) fn init(theme_override: &str) -> Result<LinuxTrayHandle, Box<dyn std::error::Error>> {
log::info!(
"Initializing ksni tray (Linux, SNI primary-activate enabled, theme={:?})",
theme_override
);
// An icon-decode Err aborts tray creation entirely and propagates to the
// init thread's Err arm, which leaves `tray_qt::handle()` at None and
// pushes `tray_live = false` — so close-to-tray quits instead of hiding
// (AC-32).
let icons = decode_tray_icons(Some(theme_override))?;
let tray = QbzTrayItem {
icons,
now_playing: None,
is_playing: false,
};
// Flatpak requires disabling the well-known DBus name because the sandbox
// cannot own arbitrary bus names (§15 trap 21).
let ksni_handle = if is_flatpak() {
log::info!("[tray] Flatpak detected — spawning ksni without DBus well-known name");
tray.disable_dbus_name(true).spawn()?
} else {
tray.spawn()?
};
let live = LinuxTrayHandle::empty();
live.install(ksni_handle);
log::info!("ksni tray initialized");
Ok(live)
}
#[cfg(test)]
mod tests {
use super::*;
/// Mean RGB over the OPAQUE pixels of a decoded pixmap. The data is ARGB32
/// after `decode_pixmap`, so byte 0 of each pixel is alpha and bytes 1..4
/// are R, G, B.
fn opaque_mean_rgb(bytes: &[u8]) -> f64 {
let icon = decode_pixmap(bytes).expect("decode");
let mut sum = 0u64;
let mut count = 0u64;
for px in icon.data.chunks_exact(4) {
if px[0] == 0 {
continue;
}
sum += px[1] as u64 + px[2] as u64 + px[3] as u64;
count += 3;
}
assert!(count > 0, "no opaque pixels");
sum as f64 / count as f64
}
/// UT-5 — variant resolution and THE FILENAME INVERSION (AC-21).
///
/// Asserted on the BYTES, never on a filename string: `"mono-light"` must
/// end up loading a WHITE glyph, whatever the file is called. Getting this
/// backwards makes the icon invisible on half the desktops, and a
/// name-based assertion would happily pass while it was wrong.
#[test]
fn tray_icon_variant_resolution() {
// "mono-light" -> MonoWhite -> tray-dark-*.png -> a WHITE glyph.
assert_eq!(resolve_variant(Some("mono-light")), IconVariant::MonoWhite);
let white = icon_sources(IconVariant::MonoWhite);
assert_eq!(white[0], TRAY_ICON_MONO_WHITE_22);
assert!(
(opaque_mean_rgb(white[0]) - 255.0).abs() < 1.0,
"mono-light must render a WHITE glyph (mean {})",
opaque_mean_rgb(white[0])
);
// "mono-dark" -> MonoBlack -> tray-light-*.png -> a BLACK glyph.
assert_eq!(resolve_variant(Some("mono-dark")), IconVariant::MonoBlack);
let black = icon_sources(IconVariant::MonoBlack);
assert_eq!(black[0], TRAY_ICON_MONO_BLACK_22);
assert!(
opaque_mean_rgb(black[0]) < 1.0,
"mono-dark must render a BLACK glyph (mean {})",
opaque_mean_rgb(black[0])
);
// The two mono sets are genuinely different byte sets, in all 4 sizes.
for i in 0..4 {
assert_ne!(white[i], black[i], "size index {i}");
}
// "color" -> the vinyl set, which is neither of the monos.
assert_eq!(resolve_variant(Some("color")), IconVariant::Color);
let color = icon_sources(IconVariant::Color);
assert_eq!(color[0], TRAY_ICON_COLOR_22);
let color_mean = opaque_mean_rgb(color[0]);
assert!(
color_mean > 1.0 && color_mean < 254.0,
"color is not a monochrome glyph (mean {color_mean})"
);
// All four sizes are published, ascending, and each decodes square at
// its nominal size (AC-22).
for (bytes, expected) in color.iter().zip([22, 32, 44, 64]) {
let icon = decode_pixmap(bytes).expect("decode");
assert_eq!(icon.width, expected);
assert_eq!(icon.height, expected);
assert_eq!(icon.data.len() as i32, expected * expected * 4);
}
// "auto", an unknown string and None all take the prefer_dark_tray
// branch — the same branch, not three different ones.
//
// Asserted WITHOUT re-deriving the expected value from
// `prefer_dark_tray()`: computing the expectation with the same
// expression the code under test uses makes the test tautological —
// inverting that arm would flip both sides and it would still pass.
// Two properties are checked instead, and both survive an inversion:
// the three spellings agree with each other, and the result is one of
// the two MONO variants (never Color, which only an explicit "color"
// may produce).
let auto = resolve_variant(Some("auto"));
assert_eq!(resolve_variant(Some("banana")), auto);
assert_eq!(resolve_variant(None), auto);
assert!(
matches!(auto, IconVariant::MonoWhite | IconVariant::MonoBlack),
"the auto ladder must resolve to a mono glyph, got {auto:?}"
);
assert_ne!(resolve_variant(Some("color")), auto);
}
}
+672
View File
@@ -0,0 +1,672 @@
//! System tray — the platform-agnostic façade.
//!
//! Port of `crates/qbz/src/tray/mod.rs` (391 L) per the 2026-08-03
//! miniplayer/tray contract (rows A-19, A-23, A-24; §5.1, §5.6, §5.8).
//! The ksni half lives in `tray_linux.rs` (A-20); the QObject QML talks to is
//! `tray_bridge.rs` (A-21).
//!
//! **Linux only (owner ruling K2).** `tray/macos.rs` exists in the Slint tree
//! only because Slint's winit backend already bundles `muda`; under Qt that
//! premise is gone, so macOS would be a NEW implementation, not a port
//! (contract §13-D6). Windows has no tray in either tree.
//!
//! ## Three things about this module are not style choices
//!
//! 1. **Nothing here touches a window.** Rust cannot reach a `QQuickWindow`
//! (§2.3), so every window verb emits a `#[qsignal]` on `QbzTray` and QML's
//! `Connections` in `Main.qml` answers it — the same hop
//! `hotkeys_bridge.rs:137-138` → `qml/shell/AppShell.qml:104-107` already
//! uses for `focus_search_requested`.
//! 2. **Nothing here calls `ksni`.** `ksni`'s blocking API wraps every call in
//! `Runtime::block_on`, which panics inside a tokio runtime **and the panic
//! is swallowed, so updates appear to succeed and never apply**
//! (`ksni-0.3.4/src/blocking.rs:206-208`; the reference states it twice,
//! `tray/mod.rs:141-150` and `tray/linux.rs:320-327`). `init` runs the ksni
//! setup on a dedicated `qbz-tray-init` thread and every mutation crosses an
//! `mpsc` into a second `qbz-tray-updater` thread. §15 traps 24-25.
//! 3. **The tray does zero work per tick** (§7-M12). Every update below is
//! edge-triggered by its publisher; there is no tray poll and no tray timer.
//!
//! On non-Linux targets the whole surface is inert, so the per-item
//! `allow(dead_code)` noise is collapsed into one module attribute rather than
//! sprinkled over a dozen items.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
// ---------------------------------------------------------------------------
// Tooltip model + composition (§5.4)
// ---------------------------------------------------------------------------
/// Now-playing info shown in the tooltip. Cleared when no track is loaded.
/// 1:1 with `crates/qbz/src/tray/linux.rs:157-162`; it lives HERE rather than
/// beside the ksni item so the composition is unit-testable without a D-Bus
/// connection (UT-4, contract §11).
#[derive(Clone, Debug, Default)]
pub(crate) struct NowPlaying {
pub title: String,
pub artist: String,
pub album: String,
}
/// Compose the SNI tooltip's `(title, description)` pair.
///
/// Verbatim port of `QbzTray::tool_tip`'s body
/// (`crates/qbz/src/tray/linux.rs:201-228`), including every omission rule:
///
/// - no track → title `"QBZ"` (hard-coded, NOT translated) and the single
/// description msgid `"Music Player\nNothing playing"` (the `\n` is inside
/// the msgid);
/// - track loaded → title = the track title, falling back to the literal
/// `"QBZ"` when empty, and the description is these lines joined with `\n`:
/// 1. `by {artist}` — **only when the artist is non-empty**,
/// 2. the album — **only when non-empty**,
/// 3. the middle-click hint, keyed on `is_playing`,
/// 4. the scroll hint (always).
///
/// All five msgids already exist in all seven translated catalogs (§6.1); this
/// port adds none.
pub(crate) fn compose_tooltip(np: Option<&NowPlaying>, is_playing: bool) -> (String, String) {
match np {
Some(np) => {
let header = if np.title.is_empty() {
"QBZ".to_string()
} else {
np.title.clone()
};
let mut lines: Vec<String> = Vec::with_capacity(4);
if !np.artist.is_empty() {
lines.push(qbz_i18n::t_args("by {}", &[np.artist.as_str()]));
}
if !np.album.is_empty() {
lines.push(np.album.clone());
}
lines.push(if is_playing {
qbz_i18n::t("Middle-click to pause")
} else {
qbz_i18n::t("Middle-click to play")
});
lines.push(qbz_i18n::t("Scroll to adjust volume"));
(header, lines.join("\n"))
}
None => (
"QBZ".to_string(),
qbz_i18n::t("Music Player\nNothing playing"),
),
}
}
// ---------------------------------------------------------------------------
// The live handle (A-19)
// ---------------------------------------------------------------------------
/// Cross-thread handle to the live tray. Cloneable; mutators forward to the
/// platform backend (ksni on Linux) and are no-ops when the tray is disabled or
/// on a platform without a live-update path.
/// 1:1 with `crates/qbz/src/tray/mod.rs:52-102`, minus the macOS arm (K2).
#[derive(Clone, Default)]
pub(crate) struct TrayHandle {
#[cfg(target_os = "linux")]
linux: Option<crate::tray_linux::LinuxTrayHandle>,
}
impl TrayHandle {
pub(crate) fn set_track(&self, title: String, artist: String, album: String) {
#[cfg(target_os = "linux")]
if let Some(h) = &self.linux {
h.set_track(title, artist, album);
return;
}
#[cfg(not(target_os = "linux"))]
let _ = (title, artist, album);
}
pub(crate) fn clear_track(&self) {
#[cfg(target_os = "linux")]
if let Some(h) = &self.linux {
h.clear_track();
}
}
pub(crate) fn set_playing(&self, is_playing: bool) {
#[cfg(target_os = "linux")]
if let Some(h) = &self.linux {
h.set_playing(is_playing);
return;
}
#[cfg(not(target_os = "linux"))]
let _ = is_playing;
}
pub(crate) fn set_icon_theme(&self, theme: String) {
#[cfg(target_os = "linux")]
if let Some(h) = &self.linux {
h.set_icon_theme(theme);
return;
}
#[cfg(not(target_os = "linux"))]
let _ = theme;
}
}
/// Process-global tray handle, set once by `init`. `None` until the tray is
/// created — **or forever, if it was disabled, suppressed, or failed to
/// initialise** (an icon-decode `Err` aborts creation, §5.6). That `None` is
/// exactly what makes the close-to-tray predicate honest: tray off ⇒ close
/// QUITS (§5.7, AC-32).
static TRAY: OnceLock<TrayHandle> = OnceLock::new();
/// The live tray handle, if the tray was initialized. Callers (the now-playing
/// publisher, the poll loop, Settings) use this to push tooltip / theme
/// updates, and every one of them is a silent no-op when it returns `None`.
/// 1:1 with `tray/mod.rs:110-112`.
pub(crate) fn handle() -> Option<&'static TrayHandle> {
TRAY.get()
}
/// The close-to-tray predicate, ported verbatim from the reference's
/// `tray_settings::get().close_to_tray && tray::handle().is_some()`
/// (`crates/qbz/src/main.rs:23253`, WM arm `:23276`). QML evaluates the same
/// `&&` as `QbzTray.trayLive && QbzTray.closeToTray` (§5.7), so this function
/// has no production caller — it is the Rust statement of the rule and what
/// UT-8 / AC-32 assert on. **Gating on the setting alone would strand the
/// process with no window on a desktop with no SNI host** (§15 trap 22).
#[allow(dead_code)]
pub(crate) fn should_hide_on_close(close_to_tray: bool) -> bool {
close_to_tray && handle().is_some()
}
// ---------------------------------------------------------------------------
// Init — the THREE gates, in order (§5.6, §15 trap 20)
// ---------------------------------------------------------------------------
/// The synchronous one-shot that closes the race the `OnceLock` cannot: on
/// Linux `TRAY` is set *asynchronously* inside the init thread, so a second
/// shell entry arriving before that thread finishes would pass gate 2 and spawn
/// a DUPLICATE ksni tray. The Qt port has the same re-entry
/// (`QbzSession.recoveryLogin`, `src/session_bridge.rs:72`, and
/// `on_session_entered` is explicitly MULTI-ENTRY).
static INIT_STARTED: AtomicBool = AtomicBool::new(false);
/// The three gates, extracted so their ORDER is unit-testable without spawning
/// a D-Bus service (UT-6, AC-23).
///
/// Returns `true` when `init` may proceed. The order is load-bearing:
///
/// 1. `enabled` — the user setting (and gamescope suppression) — logs and stops;
/// 2. the `OnceLock` — already initialised;
/// 3. the one-shot — **checked LAST on purpose, so a disabled first call does
/// NOT burn it** and a later enabled call can still initialise
/// (`tray/mod.rs:131-135`). Reintroducing the obvious "check the cheap flag
/// first" ordering is the bug §15 trap 20 names.
fn init_gates(enabled: bool, tray_present: bool, started: &AtomicBool) -> bool {
if !enabled {
log::info!("[tray] disabled by user setting");
return false;
}
if tray_present {
return false;
}
if started.swap(true, Ordering::SeqCst) {
return false;
}
true
}
/// Initialize the system tray, gated by the user's `enable_tray` setting.
/// `theme_override` is the persisted `tray_icon_theme`
/// ("auto"/"mono-light"/"mono-dark"/"color"). No-op when disabled or already
/// initialized.
///
/// **Signature note (A-19).** The reference takes FIVE parameters
/// (`tray/mod.rs:117-123`); the Qt shape drops three: `runtime` and `handle`
/// come from `crate::app()` / `crate::spawn` (both driven by process-global
/// `OnceLock`s, so they are reachable from the non-tokio ksni thread), and
/// `weak` has no analogue at all — the hop to the window is a `#[qsignal]`
/// (§5.8).
///
/// **There is NO teardown and logout does not touch the tray** — `TRAY` is a
/// never-cleared `OnceLock`, `INIT_STARTED` a one-shot, and `ksni`'s
/// `shutdown()` is never called. That is the reference's behaviour, reproduced
/// 1:1 (§12-P12): after logout the icon stays in the panel and its menu still
/// drives playback. **Do not add a teardown.**
pub(crate) fn init(theme_override: String, enabled: bool) {
if !init_gates(enabled, TRAY.get().is_some(), &INIT_STARTED) {
return;
}
// Re-seed the bridge's `close_to_tray` from the per-user store now that a
// session is entered.
//
// A-21b(a) seeds it at CONSTRUCTION, but a QML singleton is constructed at
// `Main.qml`'s `Component.onCompleted` — i.e. at the LOGIN SCREEN, where
// `sidebar_qt::user_dir()` is not bound yet, so the store answers `Err` and
// the seed is structurally the `unwrap_or(true)` default rather than the
// user's value. Reading it here (on the caller's thread, NOT on the ksni
// thread) is what makes the seed the user's own setting before the first
// close can happen. The Settings write arm keeps it live afterwards
// (A-21b(c)).
push_close_to_tray_seed();
#[cfg(target_os = "linux")]
{
// ksni's blocking `spawn()` calls `Runtime::block_on` internally, which
// panics inside an existing tokio runtime — and `init` is called from
// `on_session_entered`, which runs ON the tokio runtime. Run the ksni
// setup on a dedicated std::thread, outside any tokio context. The ksni
// service + updater thread persist independently; this thread exits.
std::thread::Builder::new()
.name("qbz-tray-init".into())
.spawn(move || match crate::tray_linux::init(&theme_override) {
Ok(linux_handle) => {
let _ = TRAY.set(TrayHandle {
linux: Some(linux_handle),
});
// A-21b(b): the `tray_live` push lives in the SAME two arms
// the reference sets `TRAY` in (`tray/mod.rs:151-158`).
// Without it the property keeps cxx-qt's derived `false`
// and `trayLive && closeToTray` is permanently false —
// close-to-tray never fires and the failure looks exactly
// like "not implemented" (§15 trap 31).
push_tray_live(true);
}
Err(e) => {
log::error!("[tray] Linux tray init failed: {e}");
// An icon-decode Err aborts tray creation entirely and
// `TRAY` stays None, so close-to-tray must quit instead of
// hiding. AC-32 is unprovable without this arm.
push_tray_live(false);
}
})
.expect("spawn tray init thread");
}
#[cfg(not(target_os = "linux"))]
{
let _ = theme_override;
log::info!("[tray] no tray backend on this platform");
}
}
/// True when running under gamescope (Steam Deck Game Mode). Stateless, and a
/// verbatim port of `crates/qbz/src/main.rs:8333-8336` — the raw
/// `GAMESCOPE_WAYLAND_DISPLAY` env is inherited by a re-spawn, and
/// `QBZ_GAMESCOPE` is the marker `apply_gamescope_quirks` sets at boot.
///
/// ONE predicate serves both halves of the contract: the tray's `enabled`
/// argument (`crates/qbz/src/main.rs:262`,
/// `tray.enable_tray && !in_gamescope()`) and the miniplayer's two entry points
/// (§4.10.1). An extra mapped window or tray surface can steal gamescope's
/// focused-window pick.
pub(crate) fn in_gamescope() -> bool {
std::env::var_os("QBZ_GAMESCOPE").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
}
/// Push `QbzTray.tray_live`. Separate from the closure so both init arms read
/// identically and neither can be edited without the other being visible.
fn push_tray_live(live: bool) {
crate::tray_bridge::ui(move |mut t| {
t.as_mut().set_tray_live(live);
});
}
/// Push `QbzTray.close_to_tray` from the per-user store, applying the SAME
/// `.unwrap_or(true)` the Settings document uses after the S1 fix — the shared
/// default is `close_to_tray: true` (`qbz-app/src/settings/tray.rs:61`), and a
/// second copy of the old `unwrap_or(false)` bug here would make the user's
/// very first close quit instead of hide (AC-29).
fn push_close_to_tray_seed() {
let close_to_tray = crate::settings_qt::tray()
.get_settings()
.map(|t| t.close_to_tray)
.unwrap_or(true);
crate::tray_bridge::ui(move |mut t| {
t.as_mut().set_close_to_tray(close_to_tray);
});
}
// ---------------------------------------------------------------------------
// Window verbs (A-23, §5.8)
//
// Rust owns no window. Each verb emits the matching `QbzTray` signal and
// `Main.qml`'s `Connections` calls the window function (A-25/A-26, B6).
// ---------------------------------------------------------------------------
/// Whether the main window is currently shown.
///
/// **D18 — there is ONE visibility owner.** Every path that changes the main
/// window's visibility goes through `hideToTray()` / `showFromTray()`, and both
/// report back through `QbzTray.setWindowShown(bool)` → `set_window_shown`
/// below. The reference has a real desync here: the miniplayer's `enter()` /
/// `exit()` bypass `tray::hide_window` / `show_window`, so this flag goes
/// stale and two tray clicks can raise the MAIN window beside a live mini
/// (§5.8.1). The Qt port defines the invariant instead of inheriting the hole.
static WINDOW_SHOWN: AtomicBool = AtomicBool::new(true);
/// Last tray-toggle timestamp, for the double-click debounce (`toggle_window`).
static LAST_TOGGLE: Mutex<Option<Instant>> = Mutex::new(None);
/// Ignore a 2nd tray activation within this window. A tray double-click
/// otherwise fires two `Activate` in milliseconds; in the reference each toggle
/// destroys/recreates the winit surface AND the wgpu shader underlay and a
/// render in flight then use-after-frees a `TextureView` (wgpu-core panic).
/// **Qt's renderer differs, so the crash may not reproduce — but "one toggle
/// per double-click" is the user-visible semantics and it is part of 1:1**
/// (§5.8, AC-26).
const TOGGLE_DEBOUNCE_MS: u128 = 400;
/// The debounce decision, extracted so it is unit-testable without sleeping
/// (UT-7). `last` is read-modify-written: **the timestamp is stored only when
/// the click PASSES**, so a rejected click does not extend the window — a burst
/// lets one through every 400 ms rather than never — and **the first click
/// always passes** (`last` starts `None`).
fn debounce_pass(last: &mut Option<Instant>, now: Instant) -> bool {
if let Some(prev) = *last {
if now.duration_since(prev).as_millis() < TOGGLE_DEBOUNCE_MS {
return false;
}
}
*last = Some(now);
true
}
/// Toggle the main window: hide if shown, else show + focus — **unless the
/// miniplayer is open, in which case this degenerates to `present()` and
/// raises the MINI** (D21, §5.8.2).
///
/// The mini-awareness is not a nicety: with D18's honest `WINDOW_SHOWN`, a
/// mini-blind toggle would show the main window beside a live mini on the FIRST
/// click — the "second instance" symptom of #559/#618, reached one click sooner
/// than the reference's own bug. While the mini is up, the mini IS the app's
/// window; exiting it is not the tray's job (the capsule's expand button,
/// `Shift+M` and `Escape` all do that).
pub(crate) fn toggle_window() {
{
// Poison-tolerant on purpose (`tray/mod.rs:196`): a panic elsewhere
// while holding this lock must not wedge the toggle permanently. The
// guard is dropped by this bare block before anything else runs.
let now = Instant::now();
let mut last = LAST_TOGGLE.lock().unwrap_or_else(|p| p.into_inner());
// `&mut *last`: `last` is the guard; the state is the `Option<Instant>`
// behind it.
if !debounce_pass(&mut *last, now) {
return;
}
}
if crate::mini_bridge::is_open() {
present();
return;
}
if WINDOW_SHOWN.load(Ordering::Relaxed) {
hide_window();
} else {
show_window();
}
}
/// Show the main window and focus it → `Main.qml`'s `showFromTray()`.
pub(crate) fn show_window() {
// The flag moves HERE, synchronously, exactly as the reference does
// (`tray/mod.rs:212-213` sets it before the queued window call). QML's
// `setWindowShown` is the RECONCILER, not the only writer: the hop through
// `ui()` is queued, so a second tray click that arrives after the 400 ms
// debounce but before the Qt loop drains would otherwise read the stale
// value and queue a SECOND hide — the window hides once and the user's
// second click does nothing, one click out of phase. D18 requires one
// visibility OWNER, not one writer of the flag.
WINDOW_SHOWN.store(true, Ordering::Relaxed);
crate::tray_bridge::ui(|mut t| {
t.as_mut().window_show_requested();
});
}
/// Hide the main window to the tray → `Main.qml`'s `hideToTray()`.
///
/// The reference clears the dynamic-background shader texture BEFORE the
/// surface dies (`tray/mod.rs:258-265`). That step is Slint/wgpu-specific — the
/// texture wraps a wgpu texture from that surface's instance — and has no Qt
/// analogue (§12-P13). The SHAPE still transfers (drop GPU resources tied to a
/// surface before destroying it); nothing in the Qt path holds one.
pub(crate) fn hide_window() {
// See `show_window` — the optimistic store is the reference's shape
// (`tray/mod.rs:255`) and is what keeps rapid clicks in phase.
WINDOW_SHOWN.store(false, Ordering::Relaxed);
crate::tray_bridge::ui(|mut t| {
t.as_mut().window_hide_requested();
});
}
/// Raise whichever window is CURRENT: the miniplayer when it is open, else the
/// main window. `show_window` would force the MAIN window up next to a live
/// mini — visually a "second instance", #559/#618 (`tray/mod.rs:236-241`).
///
/// Its caller in this diff is `toggle_window` (§2.4). Owner ruling K3 keeps
/// MPRIS out of scope, and this verb is deliberately left in the shape MPRIS
/// `Raise` and the single-instance `Present()` will call **unchanged** when
/// they land — one decision serving all three tray entry points.
///
/// Unlike the reference this needs no event-loop hop: `mini_bridge::is_open()`
/// reads an `AtomicBool` mirror (`src/mini_bridge.rs:140-144`), where Slint's
/// `miniplayer::is_open` reads `thread_local` state.
pub(crate) fn present() {
if crate::mini_bridge::is_open() {
crate::tray_bridge::ui(|mut t| {
t.as_mut().mini_present_requested();
});
} else {
show_window();
}
}
/// Sync the shown-state flag without touching any window — the QML side reports
/// the REAL visibility back through `QbzTray.setWindowShown(bool)`, which is
/// what keeps the toggle honest (D18). 1:1 with `tray/mod.rs:281-283`.
pub(crate) fn set_window_shown(shown: bool) {
WINDOW_SHOWN.store(shown, Ordering::Relaxed);
}
/// Quit the whole app from a tray action (any thread) → `Main.qml`'s
/// `persistWindowGeometryOnExit(); Qt.quit()`.
///
/// The reference flushes the session first (`session_persist::save_on_exit()`,
/// `tray/mod.rs:300`); the Qt port has no `session_persist` module, so the
/// geometry flush is the whole of what is owed (§5.7).
pub(crate) fn quit() {
log::info!("[tray] quit requested");
crate::tray_bridge::ui(|mut t| {
t.as_mut().quit_requested();
});
}
// ---------------------------------------------------------------------------
// Player-action dispatch (A-24, §5.5)
//
// In Slint the remote-first ladder is open-coded in each dispatcher
// (`tray/mod.rs:311-372`). In Qt it lives INSIDE the verb, so these are thin.
//
// §13-D19, and it is a divergence IN THE PORT'S FAVOUR: the Qt verbs ride the
// now-playing bar's FULL `cast -> QConnect -> local` ladder
// (`src/playback_qt.rs:1384-1409` tries `cast_qt::service().toggle_play_if_cast()`
// FIRST), where the Slint tray's dispatchers are cast-blind. While a Cast
// session is active the Qt tray drives the cast device. **Do NOT "fix" this
// back to cast-blind.**
// ---------------------------------------------------------------------------
pub(crate) fn dispatch_play_pause() {
crate::transport_toggle_play();
}
pub(crate) fn dispatch_next() {
crate::transport_next();
}
pub(crate) fn dispatch_previous() {
crate::transport_previous();
}
/// Step the local volume by `ticks` notches of 5 % (positive = up).
///
/// **Local-only, REPRODUCE-1:1** (§12-P11): the reference's own header says
/// *"Local-only for now (remote-renderer volume forwarding is a later
/// refinement)"* (`tray/mod.rs:374-378`). Linux-only, because scroll-to-volume
/// is a StatusNotifierItem feature the macOS/Windows trays do not expose.
///
/// The 5 % step lives HERE, not in the ksni `scroll` handler — same split as the
/// reference. The current level is read through
/// `runtime.core().player().get_playback_event().volume` (an `f32` in `0..1`;
/// worked precedent `src/playback_qt.rs:1671`), and the read happens on the
/// tokio runtime rather than on the ksni thread, exactly as the reference does
/// it inside `handle.spawn`.
#[cfg(target_os = "linux")]
pub(crate) fn dispatch_volume_delta(ticks: i32) {
let runtime = crate::app();
crate::spawn(async move {
let current = runtime.core().player().get_playback_event().volume;
let next = (current + ticks as f32 * 0.05).clamp(0.0, 1.0);
// `transport_set_volume` updates the local model first (instant UI),
// then the engine (`src/main.rs:1667-1672`).
crate::transport_set_volume(next);
});
}
#[cfg(test)]
mod tests {
use super::*;
/// UT-4 — the tooltip's four description lines, in order, with the
/// empty-artist and empty-album omissions, the `is_playing` branch and the
/// `"QBZ"` title fallback (AC-19).
///
/// The msgids resolve to themselves under the test binary's untranslated
/// locale, which is what makes the assertions readable AND is the `en`
/// behaviour (§6.1: the 42 msgids resolve 7/7 in the translated catalogs
/// and 0/7 in `en`).
#[test]
fn tray_tooltip_composition() {
// No track: hard-coded title, one description msgid.
let (title, desc) = compose_tooltip(None, false);
assert_eq!(title, "QBZ");
assert_eq!(desc, "Music Player\nNothing playing");
// Full track, playing: title, "by {artist}", album, pause hint, scroll.
let np = NowPlaying {
title: "Pull Me Under".to_string(),
artist: "Dream Theater".to_string(),
album: "Images and Words".to_string(),
};
let (title, desc) = compose_tooltip(Some(&np), true);
assert_eq!(title, "Pull Me Under");
let lines: Vec<&str> = desc.split('\n').collect();
assert_eq!(lines.len(), 4);
assert!(lines[0].contains("Dream Theater"), "line 0: {}", lines[0]);
assert_eq!(lines[1], "Images and Words");
assert_eq!(lines[2], "Middle-click to pause");
assert_eq!(lines[3], "Scroll to adjust volume");
// Paused flips ONLY line 3 of the block.
let (_, desc) = compose_tooltip(Some(&np), false);
let lines: Vec<&str> = desc.split('\n').collect();
assert_eq!(lines[2], "Middle-click to play");
// Empty artist is OMITTED, not rendered as "by ".
let np = NowPlaying {
title: "Untitled".to_string(),
artist: String::new(),
album: "Bootleg".to_string(),
};
let (title, desc) = compose_tooltip(Some(&np), true);
assert_eq!(title, "Untitled");
let lines: Vec<&str> = desc.split('\n').collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "Bootleg");
// Empty album is OMITTED too, and an empty TITLE falls back to "QBZ".
let np = NowPlaying {
title: String::new(),
artist: "Nobody".to_string(),
album: String::new(),
};
let (title, desc) = compose_tooltip(Some(&np), false);
assert_eq!(title, "QBZ");
let lines: Vec<&str> = desc.split('\n').collect();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("Nobody"));
assert_eq!(lines[1], "Middle-click to play");
assert_eq!(lines[2], "Scroll to adjust volume");
// Everything empty: the two hints survive on their own.
let (title, desc) = compose_tooltip(Some(&NowPlaying::default()), false);
assert_eq!(title, "QBZ");
assert_eq!(desc.split('\n').count(), 2);
}
/// UT-6 — the gate ORDER (AC-23). The one-shot must survive a disabled
/// first call, or a user who launches with the tray off can never get one
/// in that process.
#[test]
fn tray_init_gate_order() {
let started = AtomicBool::new(false);
// 1. Disabled first call: refused, and it does NOT burn the one-shot.
assert!(!init_gates(false, false, &started));
assert!(!started.load(Ordering::SeqCst));
// 2. A later ENABLED call proceeds — and now the one-shot is burnt.
assert!(init_gates(true, false, &started));
assert!(started.load(Ordering::SeqCst));
// 3. A third call is refused by the one-shot even though `TRAY` is not
// set yet — that is the whole point: on Linux `TRAY` is filled
// asynchronously, so the OnceLock check alone leaves a race window.
assert!(!init_gates(true, false, &started));
// The OnceLock gate refuses on its own once the tray exists.
let already = AtomicBool::new(false);
assert!(!init_gates(true, true, &already));
assert!(!already.load(Ordering::SeqCst));
}
/// UT-7 — the 400 ms toggle debounce (AC-26): the first click always
/// passes, a second 100 ms later is swallowed, and a second 500 ms later
/// passes.
#[test]
fn tray_toggle_debounce() {
use std::time::Duration;
let t0 = Instant::now();
let mut last: Option<Instant> = None;
// First click always passes.
assert!(debounce_pass(&mut last, t0));
// 100 ms later: swallowed — a double-click is ONE toggle.
assert!(!debounce_pass(&mut last, t0 + Duration::from_millis(100)));
// A rejected click does NOT extend the window: 401 ms after the click
// that PASSED is enough, even though a click landed at 100 ms.
assert!(debounce_pass(&mut last, t0 + Duration::from_millis(401)));
// 500 ms apart produce two effects.
let mut last: Option<Instant> = None;
assert!(debounce_pass(&mut last, t0));
assert!(debounce_pass(&mut last, t0 + Duration::from_millis(500)));
// Exactly at the boundary the click passes (`<` is the reject test).
let mut last: Option<Instant> = None;
assert!(debounce_pass(&mut last, t0));
assert!(debounce_pass(&mut last, t0 + Duration::from_millis(400)));
}
/// UT-8 — with no tray, close QUITS regardless of the setting (AC-32).
/// The unit test runs in a process where `init` never ran, so `handle()` is
/// `None` — which is also the state an icon-decode `Err` leaves behind.
#[test]
fn tray_absent_makes_close_quit() {
assert!(handle().is_none());
assert!(!should_hide_on_close(true));
assert!(!should_hide_on_close(false));
}
}