Skip to main content

muri/
lib.rs

1//! # muri — Menu Utilities for Rust Interfaces
2//!
3//! `muri` is a cross-platform, fully-styleable **tray-icon + popup-menu** system
4//! for Rust: a custom-drawn replacement for the `muda` + `tray-icon` pairing.
5//! Unlike native menus (which delegate pixels to AppKit / Win32 USER / GTK and
6//! can't be restyled), muri draws **one consistent custom appearance on every
7//! OS**, enabling true alignment, arbitrary colors/fonts, embedded logos, and
8//! **flush right-aligned values with no reserved chevron column**.
9//!
10//! muri owns the whole stack: the **tray icon**, the **styled popup**, and the
11//! **anchoring** (where the popup appears relative to the icon). A consuming app
12//! drives it with a small builder API:
13//!
14//! ```no_run
15//! use muri::{Tray, Menu, Row, Icon};
16//!
17//! # fn demo(icon_png: &[u8]) {
18//! let menu = Menu::new()
19//!     .row(Row::new("open").label("Open"))
20//!     .separator()
21//!     .row(Row::new("quit").label("Quit"));
22//!
23//! let _tray = Tray::new(Icon::from_png(icon_png))
24//!     .tooltip("My App")
25//!     .menu(menu)
26//!     .on_click(|id| println!("clicked {}", id.as_str()));
27//! // let _m = muri::MainThreadMarker::new().unwrap(); // call this on the real main thread
28//! // _tray.run(_m); // installs the tray icon and enters the platform event loop
29//! # }
30//! ```
31//!
32//! ## Native API: the one obvious way
33//!
34//! The native surface deliberately has **one canonical call per task**, with
35//! alternatives kept only as clearly-labeled thin sugar or full-control escape
36//! hatches (issue #62). When in doubt, reach for the canonical path:
37//!
38//! | Task | Canonical native call | Escape hatch |
39//! |------|-----------------------|--------------|
40//! | Build a menu | [`Menu::new`] + [`Menu::row`] / [`separator`](Menu::separator) / [`section_header`](Menu::section_header) / [`submenu`](Menu::submenu) / [`content`](Menu::content) | [`Menu::item`] with a hand-built [`Item`] |
41//! | An interactive row | [`Row::new(id)`](Row::new) | — |
42//! | A header / label / info row | [`Row::label_only(text)`](Row::label_only) | [`Row::default`] + segments |
43//! | Row text | [`Row::label`] / [`Row::label_value`] | [`Row::segments`] (hand-built [`Segment`]s) |
44//! | Bold a row's label | [`Row::bold`] | a whole-label [`StyleRun`] with [`Weight::Bold`] |
45//! | Color a row's value | [`Row::value_color`] | per-substring [`StyleRun`]s |
46//! | An icon | [`Icon::from_png`] / [`Icon::from_rgba`] / [`Icon::from_svg`] | — |
47//! | Choose the look | [`MenuOptions`] (carrying a [`ThemeSource`]) | [`Tray::theme`] / [`TrayHandle::set_theme`] (derived conveniences that set the `MenuOptions` theme) |
48//!
49//! Per-run [`StyleRun`] styling (via [`Segment::run`]/[`Segment::runs`]) is the
50//! **one** styling system; [`Row::bold`]/[`Row::value_color`] are ergonomic
51//! front doors onto it and render identically to hand-built runs.
52//! [`MenuOptions`] is the single source of truth for "which look".
53//!
54//! ## Status
55//!
56//! **0.9.0 testing release — all three backends implemented.** **macOS** draws
57//! a real styled popup (`NSStatusItem` + non-activating `NSPanel`), with
58//! flyout submenus, keyboard navigation ([`keynav`]), and an accessibility
59//! tree ([`a11y`]) exposed to VoiceOver via AccessKit (`a11y` feature).
60//! **Windows** anchors a `WS_EX_NOACTIVATE` layered popup and exposes UIA via
61//! `accesskit_windows`. **Linux** installs an SNI/AppIndicator native menu and
62//! supports pointer-anchored popups via X11 override-redirect. [`ContextMenu::open_at`]
63//! / [`Popup::anchored_to`] / [`TrayHandle::open`] work on all three
64//! platforms; on-device verification (real hardware, real screen readers) is
65//! what this testing release is for. See the README for the honest platform
66//! matrix.
67//!
68//! ## Crate layout
69//!
70//! - [`menu`] — the declarative `Segment` → `Row` → `Item` → `Menu` tree,
71//!   identifiers, events, and icons (pure data model).
72//! - [`style`] / [`theme`] — visual primitives ([`Color`], [`Font`]) and the
73//!   [`Theme`] surface with pure semantic-color resolution.
74//! - [`layout`] — pure `Flex`/`Align` width resolution (the flush-right layout).
75//! - [`flyout`] — pure flyout-submenu placement (right/left flip, clamp) and
76//!   hover-stack transitions.
77//! - [`geometry`] — logical points/sizes/rects and [`Insets`]/[`Edge`].
78//! - [`render`] — the [`SceneDrawer`](render::SceneDrawer) interface shared by
79//!   the one CPU-raster backend on every OS.
80//! - [`platform`] — the single per-OS [`Platform`] seam
81//!   (tray anchor, popup event loop, environment) selected once.
82//! - [`error`] — [`Error`] / [`Unsupported`].
83//!
84//! ## Rendering stack
85//!
86//! Text is shaped/rasterized with `fontdb` + `harfrust` + `swash`; everything
87//! else is composited by muri's own CPU [`Framebuffer`](render::Framebuffer)
88//! blitter — a tiny binary with no GPU warm-up. Windowing is native per-OS:
89//! macOS uses a non-activating `NSPanel`/`CALayer`; Windows a
90//! `WS_EX_NOACTIVATE` layered window; Linux an SNI tray plus an X11
91//! override-redirect window for [`ContextMenu::open_at`].
92//!
93//! ## Platform support (honest matrix)
94//!
95//! Legend: ✅ working (automated-tested / live) · 🔬 code-complete, on-device
96//! verification pending (this is what the 0.9.0 testing release is for) ·
97//! ❌ not offered (by design).
98//!
99//! | OS      | Tray icon | Styled anchored popup | Context menu (`open_at`) | Screen reader |
100//! |---------|-----------|-----------------------|---------------------------|---------------|
101//! | macOS   | 🔬 `NSStatusItem` | 🔬 non-activating `NSPanel`, N-level flyouts, mouse + keyboard nav | 🔬 `open_at` + `Popup` (shared `PopupSession`) | 🔬 VoiceOver (per-window AccessKit adapters wired) |
102//! | Windows | 🔬 `Shell_NotifyIcon` | 🔬 `WS_EX_NOACTIVATE` layered popup, `WH_MOUSE_LL` dismiss | 🔬 `open_at` + `Popup` (reuses the layered popup) | 🔬 NVDA + Narrator (UIA via `accesskit_windows`) |
103//! | Linux   | 🔬 SNI/AppIndicator native menu | ❌ tray-anchored (by design — see below); use pointer `ContextMenu` | 🔬 X11 override-redirect `open_at` (Wayland: `Unsupported::ClientPositioning`) | 🔬 Orca (AT-SPI via the native menu) |
104//!
105//! The 🔬 cells are muri code that *builds* and is clippy-clean on its target
106//! in CI but hasn't been exercised on real hardware yet — verifying them, and
107//! the four screen readers, is exactly what the 0.9.0 testing release is for;
108//! each flips to ✅ as it's confirmed on the road to 1.0. The Linux
109//! tray-anchored styled popup stays ❌ permanently.
110//!
111//! **Linux caveat:** the SNI/AppIndicator tray *host* owns the icon in its own
112//! process, so the app never gets the icon's rect or click coordinate; Wayland
113//! also forbids a client from positioning its own toplevel. A tray-anchored
114//! styled popup is therefore architecturally impossible there. [`Tray::run`]
115//! still installs a native SNI/AppIndicator menu, but reports
116//! [`Error::Unsupported`]`(`[`Unsupported::TrayAnchor`]`)` for the anchor rect
117//! — use that native menu or a pointer-anchored [`ContextMenu`] instead. See
118//! [`Unsupported`].
119
120// The OS backends require `unsafe` (NSStatusItem/objc2, NSPanel/CALayer,
121// layered HWND, X11 override-redirect); the portable scene drawer and data
122// model do not. `unsafe` is denied crate-wide and re-allowed only inside the
123// per-OS platform modules, which already localize it without a target_os gate.
124#![deny(unsafe_code)]
125#![deny(missing_docs)]
126
127pub mod a11y;
128pub mod anchor;
129// The muda-compatibility facade (spec 02/60), behind the `muda-compat` feature.
130#[cfg(feature = "muda-compat")]
131pub mod compat;
132pub mod error;
133// The process-global `MenuEvent` channel (spec 03 §3). Part of the native crate
134// surface (always compiled); the muda-compat facade re-exports it.
135pub mod event;
136pub mod flyout;
137pub mod geometry;
138pub mod keynav;
139pub mod layout;
140pub mod menu;
141pub mod platform;
142pub mod render;
143pub mod style;
144pub mod theme;
145
146pub use a11y::{announcement, build_tree, focused_id, locate, AxId, AxNode, AxRole, AxTree};
147pub use anchor::place_popup;
148pub use error::{Error, Result, Unsupported};
149pub use event::MenuEventReceiver;
150pub use flyout::{next_flyout, place_flyout, FlyoutPlacement, FlyoutSide, HoverTarget};
151pub use geometry::{Edge, Insets, LogicalPoint, LogicalRect, LogicalSize};
152pub use keynav::{handle_key, FlyoutFocus, MenuFocus, NavAction, NavKey};
153pub use menu::{
154    Align, Axis, ClickHandler, Content, Flex, Icon, Item, Menu, MenuEvent, MenuId, Row, Segment,
155    Stack, StyleRun, TextContent,
156};
157pub use platform::{Appearance, Platform, PlatformEvent};
158pub use render::{render_menu_to_png, render_menu_to_rgba};
159pub use style::{Color, Font, FontFamily, Rgba, Weight};
160pub use theme::{
161    GutterPolicy, MenuOptions, OsFamily, Preset, Theme, ThemeMode, ThemeSource,
162    TrailingGutterPolicy,
163};
164
165use std::marker::PhantomData;
166use std::sync::atomic::{AtomicU64, Ordering};
167
168// =============================================================================
169// MainThreadMarker
170// =============================================================================
171
172/// A zero-cost, `!Send + !Sync` proof that the calling code is running on the
173/// thread that obtained it — required by [`Tray::run`] and [`Tray::spawn`]
174/// because installing an `NSStatusItem` is only safe from AppKit's main thread
175/// on macOS (issue #46); Windows/Linux take the same proof for one uniform
176/// contract across backends.
177///
178/// `PhantomData<*const ()>` makes this `!Send + !Sync` for free, so a marker
179/// obtained on one thread can't be smuggled to another via a channel or
180/// closure.
181///
182/// ```compile_fail
183/// fn is_send<T: Send>() {}
184/// is_send::<muri::MainThreadMarker>(); // fails: MainThreadMarker is !Send
185/// ```
186#[derive(Clone, Copy, Debug)]
187pub struct MainThreadMarker(PhantomData<*const ()>);
188
189impl MainThreadMarker {
190    /// Obtain a proof that the caller is on the main thread.
191    ///
192    /// muri has no portable, safe way to verify this without OS-specific FFI
193    /// (and the crate root denies `unsafe_code`), so this is intentionally
194    /// **not** a runtime check — it always returns `Some`; the guarantee is
195    /// purely compile-time (see [`MainThreadMarker`]'s type doc), so the caller
196    /// must actually call this from the real main thread. The per-OS backend
197    /// (e.g. macOS's `objc2::MainThreadMarker`) still performs its own runtime
198    /// check before touching AppKit, catching a caller that gets this wrong.
199    #[allow(clippy::unnecessary_wraps)]
200    pub fn new() -> Option<Self> {
201        Some(MainThreadMarker(PhantomData))
202    }
203}
204
205// =============================================================================
206// SurfaceId
207// =============================================================================
208
209/// A process-global, monotonically increasing identifier for one *surface*
210/// instance — a [`Tray`], [`ContextMenu`], or [`Popup`] — so a [`MenuEvent`]
211/// consumer can tell which surface an activation came from (issue #51).
212///
213/// Assigned once, in the surface's constructor, from a process-wide
214/// [`AtomicU64`] counter; every live surface has a distinct id, and ids are
215/// issued in creation order.
216#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
217pub struct SurfaceId(u64);
218
219impl SurfaceId {
220    /// Issue the next process-global id.
221    pub(crate) fn next() -> Self {
222        static NEXT: AtomicU64 = AtomicU64::new(1);
223        SurfaceId(NEXT.fetch_add(1, Ordering::Relaxed))
224    }
225}
226
227// =============================================================================
228// Tray
229// =============================================================================
230
231/// A live tray icon with an attached styled menu.
232///
233/// Construct with [`Tray::new`], configure via the builder methods, then call
234/// [`Tray::run`] to install the icon and enter the platform event loop. Content
235/// can be swapped at runtime with [`Tray::set_menu`] (or from another thread via
236/// a [`TrayHandle`]) — usagio rebuilds its menu on a ~0.75s tick.
237///
238/// ## Anchoring, per OS
239///
240/// - **macOS:** anchored to the `NSStatusItem` button; AppKit computes the
241///   on-screen rect and which display the menu bar is on.
242/// - **Windows:** anchored via `Shell_NotifyIconGetRect`, positioning a
243///   `WS_EX_NOACTIVATE` layered window toward screen center.
244/// - **Linux:** [`Tray::run`] installs an SNI/AppIndicator **native** menu (it
245///   does not block on anchoring); a styled *tray-anchored* popup is not offered
246///   (the anchor rect is unavailable — the backend reports
247///   [`Error::Unsupported`]`(`[`Unsupported::TrayAnchor`]`)`). Use that native
248///   menu or a pointer-anchored [`ContextMenu`].
249///
250/// ## Shutdown
251///
252/// Dropping a [`Tray`] itself does **not** post `TrayCommand::Shutdown` — it's
253/// normally consumed by [`Tray::run`]/[`Tray::spawn`] first. What auto-shuts-down
254/// the tray is dropping the **last** outstanding [`TrayHandle`] from a given
255/// [`Tray::handle`] call: its `Drop` (issue #47) posts `Shutdown` exactly once,
256/// matching `tray-icon`'s drop-removes contract with no explicit
257/// [`TrayHandle::shutdown`] call needed.
258pub struct Tray {
259    icon: Icon,
260    menu: Menu,
261    tooltip: Option<String>,
262    /// Optional text shown *beside/instead of* the icon in the status item —
263    /// the macOS menu-bar title (e.g. a live "45%"). Rendered on the
264    /// `NSStatusItem` button; on Windows/Linux the notification area has no text
265    /// label, so it is retained but not drawn.
266    title: Option<String>,
267    options: MenuOptions,
268    on_click: Option<ClickHandler>,
269    /// Commands posted by a [`TrayHandle`] from any thread, drained on the
270    /// platform run loop. Shared with every handle handed out via
271    /// [`Tray::handle`]; the backend installs the wake mechanism in
272    /// [`Tray::run`] so posts made before `run` simply buffer here.
273    commands: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
274    /// The main-thread wake, installed by the backend once its run loop is
275    /// live. Posting a command calls this (if present) to schedule a drain.
276    waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
277    /// This surface's process-global identity (issue #51). Exposed via
278    /// [`Tray::surface_id`].
279    surface_id: SurfaceId,
280}
281
282/// A thread-safe wake callback the backend installs to poke its native run
283/// loop when a [`TrayHandle`] posts a command.
284type WakeFn = Box<dyn Fn() + Send + Sync + 'static>;
285
286/// A live command from a [`TrayHandle`] to a running [`Tray`], applied on the
287/// platform's UI thread. Deliberately OS-neutral (carries only data model
288/// types) so the same vocabulary drives every backend.
289///
290/// Every variant's payload is consumed by all three backends' `apply_command`
291/// (macOS, Windows, Linux — see `src/platform/{mac,windows,linux}.rs`).
292#[derive(Debug)]
293pub(crate) enum TrayCommand {
294    /// Replace the menu content and repaint/refresh any open popup.
295    SetMenu(Menu),
296    /// Replace the tray icon.
297    SetIcon(Icon),
298    /// Replace the tooltip / accessible name.
299    SetTooltip(Option<String>),
300    /// Replace the status-item text title (macOS menu-bar text).
301    SetTitle(Option<String>),
302    /// Show or hide the tray status item.
303    SetVisible(bool),
304    /// Programmatically open the popup anchored to the tray icon.
305    Open,
306    /// Dismiss the popup if shown.
307    Close,
308    /// Stop the tray: remove the OS status item and end the backend's run loop
309    /// (and background thread, for a spawned tray). Posted by compat facade
310    /// `Drop`, explicit [`TrayHandle::shutdown`], and automatically by
311    /// [`TrayHandle`]'s own `Drop` when the last handle in a family goes out of
312    /// scope (issue #47), matching `tray-icon`'s drop-removes contract.
313    Shutdown,
314    /// Swap the live theme source. Applied on the backend's UI thread: the next
315    /// popup open uses it, and any currently-open popup is repainted with it —
316    /// so a consumer can offer an in-menu "Preview theme" switcher (issue #45).
317    /// On Linux the persistent tray is a native `dbusmenu` the host renders, so
318    /// this only affects muri's own styled context-menu popups, not the SNI tray.
319    SetTheme(ThemeSource),
320    /// Swap the live [`MenuOptions`] wholesale (theme + width bounds + gutter
321    /// policy). Applied exactly like [`SetTheme`](TrayCommand::SetTheme) (#45).
322    SetOptions(MenuOptions),
323    /// Best-effort cross-thread request for the tray icon's current on-screen
324    /// anchor rectangle: the backend replies on its UI thread with the live rect
325    /// (or `None` when unavailable / unsupported, e.g. the Linux SNI tray, whose
326    /// host never exposes icon geometry) (issue #48).
327    QueryAnchorRect(std::sync::mpsc::Sender<Option<LogicalRect>>),
328}
329
330/// A cheap, `Clone + Send` remote control for a running [`Tray`].
331///
332/// [`Tray::run`] consumes the tray, so `TrayHandle` closes the gap: obtain one
333/// with [`Tray::handle`] *before* `run`, move it to any thread, and post
334/// commands the backend applies on its UI thread. Commands posted before the
335/// run loop is live simply buffer and apply once it starts.
336#[derive(Clone)]
337pub struct TrayHandle {
338    queue: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
339    waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
340    /// Shared across every clone of *this handle family* — fresh per
341    /// [`Tray::handle`] call, shared via `.clone()`. Its [`Drop`] posts
342    /// `TrayCommand::Shutdown` exactly once, when the last clone releases the
343    /// final `Arc` (issue #47) — deliberately its own `Arc`, independent of
344    /// `queue`/`waker`, so the backend's own reference never factors in.
345    ///
346    /// Held purely for its `Drop` guard; never read directly, hence
347    /// `allow(dead_code)` — cloning it is what shares the family.
348    #[allow(dead_code)]
349    family: std::sync::Arc<HandleFamily>,
350}
351
352/// The shared drop-guard for a [`TrayHandle`] family. Posting `Shutdown` from
353/// *this* type's `Drop` (rather than from `TrayHandle::drop` gated on
354/// `Arc::strong_count == 1`) makes the "last clone gone" signal race-free: the
355/// `Arc` runtime guarantees `HandleFamily::drop` runs exactly once, when the
356/// final clone is released, even if two final clones on different threads drop
357/// concurrently. A `strong_count == 1` check in `TrayHandle::drop` could let both
358/// such drops read `count > 1` (each still counts itself, and neither field
359/// decrement has happened yet) and neither post — leaking the tray + its thread.
360struct HandleFamily {
361    queue: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
362    waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
363}
364
365impl Drop for HandleFamily {
366    fn drop(&mut self) {
367        if let Ok(mut q) = self.queue.lock() {
368            q.push(TrayCommand::Shutdown);
369        }
370        if let Ok(waker) = self.waker.lock() {
371            if let Some(wake) = waker.as_ref() {
372                wake();
373            }
374        }
375    }
376}
377
378impl std::fmt::Debug for TrayHandle {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        f.debug_struct("TrayHandle").finish_non_exhaustive()
381    }
382}
383
384impl TrayHandle {
385    /// Test-only: drain and return the commands posted so far, so a unit test can
386    /// assert that a setter posted the *right* `TrayCommand` with the right
387    /// payload (the facade setters go through this path but the OS backend that
388    /// would otherwise consume it is not installed headlessly).
389    #[cfg(test)]
390    pub(crate) fn take_posted(&self) -> Vec<TrayCommand> {
391        self.queue
392            .lock()
393            .map(|mut q| std::mem::take(&mut *q))
394            .unwrap_or_default()
395    }
396
397    fn post(&self, command: TrayCommand) {
398        if let Ok(mut q) = self.queue.lock() {
399            q.push(command);
400        }
401        if let Ok(waker) = self.waker.lock() {
402            if let Some(wake) = waker.as_ref() {
403                wake();
404            }
405        }
406    }
407
408    /// Replace the menu shown on the next open (and repaint + refresh the
409    /// accessibility tree live if the popup is already open).
410    pub fn set_menu(&self, menu: Menu) {
411        self.post(TrayCommand::SetMenu(menu));
412    }
413
414    /// Replace the tray icon.
415    pub fn set_icon(&self, icon: Icon) {
416        self.post(TrayCommand::SetIcon(icon));
417    }
418
419    /// Replace the tooltip / accessible name.
420    pub fn set_tooltip(&self, tooltip: Option<impl Into<String>>) {
421        self.post(TrayCommand::SetTooltip(tooltip.map(Into::into)));
422    }
423
424    /// Replace the status-item text title (macOS menu-bar text, e.g. a live
425    /// "45%"). A no-op on the drawn item on Windows/Linux.
426    pub fn set_title(&self, title: Option<impl Into<String>>) {
427        self.post(TrayCommand::SetTitle(title.map(Into::into)));
428    }
429
430    /// Show or hide the tray status item.
431    pub fn set_visible(&self, visible: bool) {
432        self.post(TrayCommand::SetVisible(visible));
433    }
434
435    /// Programmatically open the popup anchored to the tray icon.
436    pub fn open(&self) {
437        self.post(TrayCommand::Open);
438    }
439
440    /// Dismiss the popup if shown.
441    pub fn close(&self) {
442        self.post(TrayCommand::Close);
443    }
444
445    /// Stop the tray: remove the OS status item and end its backend run loop (and
446    /// background thread, for a spawned tray). Best-effort and asynchronous — the
447    /// removal is applied on the backend's UI thread. Used by the compat facade
448    /// to remove the icon when its `TrayIcon` is dropped, matching `tray-icon`.
449    pub fn shutdown(&self) {
450        self.post(TrayCommand::Shutdown);
451    }
452
453    /// Swap the live theme source (issue #45). Applied asynchronously on the
454    /// backend's UI thread: the next popup open uses it, and any currently-open
455    /// popup is repainted — so a consumer can wire an in-menu "Preview theme"
456    /// submenu. See `TrayCommand::SetTheme` for the Linux SNI-tray caveat.
457    pub fn set_theme(&self, theme: ThemeSource) {
458        self.post(TrayCommand::SetTheme(theme));
459    }
460
461    /// Swap the live [`MenuOptions`] wholesale — theme, width bounds, gutter
462    /// policy (issue #45). Applied like [`set_theme`](TrayHandle::set_theme).
463    pub fn set_options(&self, options: MenuOptions) {
464        self.post(TrayCommand::SetOptions(options));
465    }
466
467    /// Best-effort cross-thread query for the tray icon's current on-screen
468    /// rectangle (issue #48). The native, main-thread counterpart is
469    /// [`Tray::anchor_rect`].
470    ///
471    /// Posts a `TrayCommand::QueryAnchorRect` and waits briefly for the
472    /// backend to reply on its UI thread. Returns `None` if the run loop is not
473    /// yet live, the query times out, or the platform can't report geometry
474    /// (the Linux SNI tray never can — its host owns the icon).
475    pub fn anchor_rect(&self) -> Option<LogicalRect> {
476        let (tx, rx) = std::sync::mpsc::channel();
477        self.post(TrayCommand::QueryAnchorRect(tx));
478        rx.recv_timeout(std::time::Duration::from_millis(200))
479            .ok()
480            .flatten()
481    }
482}
483
484impl Tray {
485    /// Create a tray with the given status-bar icon.
486    pub fn new(icon: Icon) -> Self {
487        Tray {
488            icon,
489            menu: Menu::new(),
490            tooltip: None,
491            title: None,
492            options: MenuOptions::default(),
493            on_click: None,
494            commands: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
495            waker: std::sync::Arc::new(std::sync::Mutex::new(None)),
496            surface_id: SurfaceId::next(),
497        }
498    }
499
500    /// This tray's process-global [`SurfaceId`] (issue #51), assigned once in
501    /// [`Tray::new`]. Lets a [`MenuEvent`] consumer correlate an activation
502    /// back to the surface it came from.
503    pub fn surface_id(&self) -> SurfaceId {
504        self.surface_id
505    }
506
507    /// A cheap, `Clone + Send` [`TrayHandle`] that can drive this tray from any
508    /// thread once [`Tray::run`] is live (posts made earlier buffer). Obtain it
509    /// before `run` consumes the tray.
510    ///
511    /// Each call starts a fresh, independently-tracked handle *family*: dropping
512    /// every handle/clone in one `handle()` call's family auto-shuts-down the
513    /// tray (issue #47). Calling `handle()` again creates a separate family —
514    /// prefer calling it once and fanning out with `.clone()`.
515    pub fn handle(&self) -> TrayHandle {
516        TrayHandle {
517            queue: std::sync::Arc::clone(&self.commands),
518            waker: std::sync::Arc::clone(&self.waker),
519            family: std::sync::Arc::new(HandleFamily {
520                queue: std::sync::Arc::clone(&self.commands),
521                waker: std::sync::Arc::clone(&self.waker),
522            }),
523        }
524    }
525
526    /// The tray icon's current on-screen rectangle in logical coordinates
527    /// (issue #48) — the native, main-thread counterpart of
528    /// [`TrayHandle::anchor_rect`].
529    ///
530    /// **Honest limitation:** [`Tray::run`]/[`Tray::spawn`] consume the `Tray`,
531    /// so there's no `&self` left once a live icon exists. This queries a
532    /// **fresh** [`platform::current()`] instance that never had `install_tray`
533    /// called, so on macOS/Windows it reliably returns `Err` until the engine
534    /// can query a running backend's live state. Provided for API symmetry with
535    /// [`TrayHandle::anchor_rect`]; Linux's answer ([`Unsupported::TrayAnchor`])
536    /// doesn't depend on installation state.
537    pub fn anchor_rect(&self) -> Result<LogicalRect> {
538        platform::current().tray_anchor_rect()
539    }
540
541    /// Attach the menu shown when the icon is clicked.
542    pub fn menu(mut self, menu: Menu) -> Self {
543        self.menu = menu;
544        self
545    }
546
547    /// Set the tray icon tooltip / accessible name.
548    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
549        self.tooltip = Some(text.into());
550        self
551    }
552
553    /// Set the status-item text title — the macOS menu-bar text shown beside (or
554    /// instead of) the icon, e.g. a live "45%". Rendered on the `NSStatusItem`
555    /// button; on Windows/Linux the notification area has no text label, so this
556    /// is retained but not drawn.
557    pub fn title(mut self, text: impl Into<String>) -> Self {
558        self.title = Some(text.into());
559        self
560    }
561
562    /// Set popup options (width bounds, theme source). [`MenuOptions`] is the
563    /// canonical, single source of truth for a tray's look and layout (issue
564    /// #62); [`theme`](Tray::theme) below is a derived convenience over it.
565    pub fn options(mut self, options: MenuOptions) -> Self {
566        self.options = options;
567        self
568    }
569
570    /// Convenience: set just the theme source. A thin wrapper over
571    /// [`options`](Tray::options) — it sets the [`MenuOptions::theme`] field,
572    /// which is the canonical "which look" source of truth (issue #62).
573    pub fn theme(mut self, theme: ThemeSource) -> Self {
574        self.options.theme = theme;
575        self
576    }
577
578    /// Register a click handler invoked with the activated row's [`MenuId`].
579    pub fn on_click(mut self, handler: impl Fn(&MenuId) + Send + 'static) -> Self {
580        self.on_click = Some(Box::new(handler));
581        self
582    }
583
584    /// Replace the menu content at runtime (cheap; re-rendered on next open).
585    pub fn set_menu(&mut self, menu: Menu) {
586        self.menu = menu;
587    }
588
589    /// Replace the status-bar icon at runtime (re-published on next backend
590    /// update; on Linux this re-registers the SNI `icon_pixmap`).
591    pub fn set_icon(&mut self, icon: Icon) {
592        self.icon = icon;
593    }
594
595    /// Replace the status-item text title at runtime (macOS menu-bar text).
596    pub fn set_title(&mut self, title: Option<String>) {
597        self.title = title;
598    }
599
600    /// The status-item text title, if set.
601    pub fn title_text(&self) -> Option<&str> {
602        self.title.as_deref()
603    }
604
605    /// The tooltip, if set.
606    pub fn tooltip_text(&self) -> Option<&str> {
607        self.tooltip.as_deref()
608    }
609
610    /// Borrow the current menu.
611    pub fn current_menu(&self) -> &Menu {
612        &self.menu
613    }
614
615    /// Borrow the icon.
616    pub fn icon(&self) -> &Icon {
617        &self.icon
618    }
619
620    /// Borrow the options.
621    pub fn menu_options(&self) -> &MenuOptions {
622        &self.options
623    }
624
625    /// Build the [`AxTree`] the platform screen reader walks over the current
626    /// menu. The backend rebuilds this whenever the menu is (re)opened or swapped
627    /// and feeds it to the platform accessibility API (via AccessKit).
628    pub fn accessibility_tree(&self) -> AxTree {
629        a11y::build_tree(&self.menu)
630    }
631
632    /// Dispatch a click to the registered handler, if any. Used by the backend
633    /// when a row is activated; exposed so the data flow is testable without a
634    /// live event loop.
635    ///
636    /// Order (spec 03 §3): the per-surface `on_click` closure runs first
637    /// (synchronously), then the same activation is projected onto the global
638    /// [`MenuEvent`] channel and any `set_event_handler`. An inert
639    /// [`MenuId::none`] fires neither.
640    pub fn dispatch(&self, id: &MenuId) {
641        if id.is_none() {
642            return;
643        }
644        if let Some(handler) = &self.on_click {
645            handler(id);
646        }
647        event::emit(id.clone(), self.surface_id);
648    }
649
650    /// Install the tray icon and run the platform event loop, dispatching row
651    /// activations to the registered handler and the global [`MenuEvent`]
652    /// channel. Consumes the [`Tray`] and blocks for its lifetime; obtain a
653    /// [`TrayHandle`] with [`Tray::handle`] *before* calling this to drive it
654    /// from any thread.
655    ///
656    /// Per-OS backend, selected once in [`platform::current`]: macOS runs the
657    /// native `NSApplication` loop, Windows the Win32 message pump, Linux its
658    /// SNI/AppIndicator worker loop. Takes a [`MainThreadMarker`] (issue #46)
659    /// since installing the `NSStatusItem` is only safe on AppKit's main thread.
660    pub fn run(self, _m: MainThreadMarker) -> Result<()> {
661        // One seam: the per-OS backend selected once in `platform::current()`.
662        platform::current().run_tray(self)
663    }
664
665    /// Install the tray icon and begin driving it **without blocking**, returning
666    /// a [`TrayHandle`] to mutate it from any thread. The non-blocking counterpart
667    /// to [`Tray::run`], for hosts that own their own event loop (e.g. the
668    /// `tray-icon` compat facade).
669    ///
670    /// On Windows/Linux the UI pump runs on a dedicated background thread. On
671    /// macOS this is **best-effort**: `spawn` must be called from the main thread
672    /// and relies on the host's existing `NSApplication` loop (see
673    /// [`Platform::spawn_tray`]). Takes a [`MainThreadMarker`] (issue #46) for the
674    /// same reason as [`Tray::run`].
675    pub fn spawn(self, _m: MainThreadMarker) -> Result<TrayHandle> {
676        let handle = self.handle();
677        platform::current().spawn_tray(self)?;
678        Ok(handle)
679    }
680}
681
682// =============================================================================
683// ContextMenu
684// =============================================================================
685
686/// A free-standing styled menu shown at an explicit screen point. Unlike a
687/// tray-anchored popup, this works anywhere a pointer coordinate is available —
688/// including Linux/Wayland via `xdg_positioner` relative to the caller's own
689/// surface — so it is muri's portable styled-menu primitive.
690pub struct ContextMenu {
691    menu: Menu,
692    options: MenuOptions,
693    on_click: Option<ClickHandler>,
694    /// This surface's process-global identity (issue #51).
695    surface_id: SurfaceId,
696}
697
698impl ContextMenu {
699    /// Create a context menu from a [`Menu`].
700    pub fn new(menu: Menu) -> Self {
701        ContextMenu {
702            menu,
703            options: MenuOptions::default(),
704            on_click: None,
705            surface_id: SurfaceId::next(),
706        }
707    }
708
709    /// This surface's process-global [`SurfaceId`] (issue #51), assigned once
710    /// in [`ContextMenu::new`].
711    pub fn surface_id(&self) -> SurfaceId {
712        self.surface_id
713    }
714
715    /// Set popup options.
716    pub fn options(mut self, options: MenuOptions) -> Self {
717        self.options = options;
718        self
719    }
720
721    /// Register a click handler.
722    pub fn on_click(mut self, handler: impl Fn(&MenuId) + Send + 'static) -> Self {
723        self.on_click = Some(Box::new(handler));
724        self
725    }
726
727    /// Borrow the menu.
728    pub fn menu(&self) -> &Menu {
729        &self.menu
730    }
731
732    /// Borrow the options.
733    pub fn menu_options(&self) -> &MenuOptions {
734        &self.options
735    }
736
737    /// Build the [`AxTree`] the platform screen reader walks over this menu.
738    pub fn accessibility_tree(&self) -> AxTree {
739        a11y::build_tree(&self.menu)
740    }
741
742    /// Dispatch a click to the registered handler, if any. Fires the `on_click`
743    /// closure first, then projects the activation onto the global
744    /// [`MenuEvent`] channel (spec 03 §3); an inert [`MenuId::none`] fires
745    /// neither.
746    pub fn dispatch(&self, id: &MenuId) {
747        if id.is_none() {
748            return;
749        }
750        if let Some(handler) = &self.on_click {
751            handler(id);
752        }
753        event::emit(id.clone(), self.surface_id);
754    }
755
756    /// Show the menu at the given screen point, growing from `edge`, and block
757    /// until it is dismissed.
758    ///
759    /// The point is treated as a zero-size anchor rectangle and funnelled through
760    /// the shared `PopupSession` (the same `place_popup` + scene drawer + dismiss
761    /// machinery the tray uses — spec 20 §3). Row activation is dispatched to the
762    /// [`on_click`](ContextMenu::on_click) handler. On platforms whose styled
763    /// popup loop is not implemented yet this returns [`Error::Platform`].
764    pub fn open_at(&self, point: LogicalPoint, edge: Edge) -> Result<()> {
765        let anchor = LogicalRect::new(point, LogicalSize::new(0.0, 0.0));
766        let handler = |id: &MenuId| self.dispatch(id);
767        platform::current().open_popup_session(
768            self.menu.clone(),
769            self.options.clone(),
770            &handler,
771            anchor,
772            edge,
773        )
774    }
775}
776
777// =============================================================================
778// Popup / Dropdown
779// =============================================================================
780
781/// A styled dropdown popup anchored to an **arbitrary caller rectangle** (e.g. a
782/// toolbar button), rather than the tray icon or a bare point. It reuses the exact
783/// `place_popup` math the tray uses; [`Tray`], [`ContextMenu`], and `Popup` differ
784/// only in how the anchor rectangle is obtained, and all funnel into one shared
785/// `PopupSession` (spec 01 §5.3, spec 20 §3).
786pub struct Popup {
787    menu: Menu,
788    options: MenuOptions,
789    on_click: Option<ClickHandler>,
790    /// This surface's process-global identity (issue #51).
791    surface_id: SurfaceId,
792}
793
794impl Popup {
795    /// Create a dropdown popup from a [`Menu`].
796    pub fn new(menu: Menu) -> Self {
797        Popup {
798            menu,
799            options: MenuOptions::default(),
800            on_click: None,
801            surface_id: SurfaceId::next(),
802        }
803    }
804
805    /// This surface's process-global [`SurfaceId`] (issue #51), assigned once
806    /// in [`Popup::new`].
807    pub fn surface_id(&self) -> SurfaceId {
808        self.surface_id
809    }
810
811    /// Set popup options (width bounds, theme source).
812    pub fn options(mut self, options: MenuOptions) -> Self {
813        self.options = options;
814        self
815    }
816
817    /// Register a click handler invoked with the activated row's [`MenuId`].
818    pub fn on_click(mut self, handler: impl Fn(&MenuId) + Send + 'static) -> Self {
819        self.on_click = Some(Box::new(handler));
820        self
821    }
822
823    /// Borrow the menu.
824    pub fn menu(&self) -> &Menu {
825        &self.menu
826    }
827
828    /// Borrow the options.
829    pub fn menu_options(&self) -> &MenuOptions {
830        &self.options
831    }
832
833    /// Build the [`AxTree`] the platform screen reader walks over this menu.
834    pub fn accessibility_tree(&self) -> AxTree {
835        a11y::build_tree(&self.menu)
836    }
837
838    /// Dispatch a click to the registered handler, if any. Fires the `on_click`
839    /// closure first, then projects the activation onto the global
840    /// [`MenuEvent`] channel (spec 03 §3) — so a `Popup` is a first-class event
841    /// source alongside [`Tray`] and [`ContextMenu`], as
842    /// [`MenuEvent::receiver`](crate::event) promises. An inert
843    /// [`MenuId::none`] fires neither.
844    pub fn dispatch(&self, id: &MenuId) {
845        if id.is_none() {
846            return;
847        }
848        if let Some(handler) = &self.on_click {
849            handler(id);
850        }
851        event::emit(id.clone(), self.surface_id);
852    }
853
854    /// Show the popup anchored to `anchor` (a caller rectangle in screen logical
855    /// coordinates), growing from `edge`, and block until it is dismissed.
856    ///
857    /// This is the tray's session anchored to an arbitrary rect instead of the
858    /// tray icon (spec 20 §3). On platforms whose styled popup loop is not
859    /// implemented yet this returns [`Error::Platform`].
860    pub fn anchored_to(&self, anchor: LogicalRect, edge: Edge) -> Result<()> {
861        let handler = |id: &MenuId| self.dispatch(id);
862        platform::current().open_popup_session(
863            self.menu.clone(),
864            self.options.clone(),
865            &handler,
866            anchor,
867            edge,
868        )
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875    use std::sync::atomic::{AtomicUsize, Ordering};
876    use std::sync::Arc;
877
878    #[test]
879    fn tray_builder_stores_configuration() {
880        let tray = Tray::new(Icon::Checkmark)
881            .tooltip("usagio")
882            .title("45%")
883            .menu(Menu::new().row(Row::new("quit").label("Quit")))
884            .theme(ThemeSource::System(ThemeMode::Dark));
885        assert_eq!(tray.tooltip_text(), Some("usagio"));
886        assert_eq!(tray.title_text(), Some("45%"));
887        assert_eq!(tray.current_menu().len(), 1);
888        assert!(matches!(
889            tray.menu_options().theme,
890            ThemeSource::System(ThemeMode::Dark)
891        ));
892    }
893
894    #[test]
895    fn tray_handle_setters_post_the_matching_command() {
896        // Guards the mechanism every facade setter relies on: a TrayHandle setter
897        // must post the *right* TrayCommand with the right payload — facade unit
898        // tests can't catch a swap headlessly since no OS backend drains the queue.
899        let tray = Tray::new(Icon::Checkmark);
900        let handle = tray.handle();
901        handle.set_title(Some("45%"));
902        handle.set_tooltip(Some("tip"));
903        handle.set_visible(false);
904        let posted = handle.take_posted();
905        assert!(
906            matches!(&posted[0], TrayCommand::SetTitle(Some(s)) if s == "45%"),
907            "got {:?}",
908            posted.first()
909        );
910        assert!(
911            matches!(&posted[1], TrayCommand::SetTooltip(Some(s)) if s == "tip"),
912            "got {:?}",
913            posted.get(1)
914        );
915        assert!(matches!(&posted[2], TrayCommand::SetVisible(false)));
916    }
917
918    #[test]
919    fn tray_title_defaults_none_and_set_title_replaces_it() {
920        // A tray with no title (the macOS menu-bar text) reports None; the
921        // runtime setter replaces and clears it (issue #8 part 3).
922        let mut tray = Tray::new(Icon::Checkmark);
923        assert_eq!(tray.title_text(), None);
924        tray.set_title(Some("12%".to_owned()));
925        assert_eq!(tray.title_text(), Some("12%"));
926        tray.set_title(None);
927        assert_eq!(tray.title_text(), None);
928    }
929
930    #[test]
931    fn tray_dispatch_invokes_handler_with_id() {
932        let _guard = crate::event::test_lock();
933        let seen = Arc::new(AtomicUsize::new(0));
934        let seen2 = Arc::clone(&seen);
935        let tray = Tray::new(Icon::Checkmark).on_click(move |id| {
936            if id.as_str() == "quit" {
937                seen2.fetch_add(1, Ordering::SeqCst);
938            }
939        });
940        tray.dispatch(&MenuId::from("quit"));
941        tray.dispatch(&MenuId::from("other"));
942        assert_eq!(seen.load(Ordering::SeqCst), 1);
943    }
944
945    #[test]
946    fn dispatch_fires_closure_before_channel_and_skips_inert() {
947        let _guard = crate::event::test_lock();
948        use std::sync::atomic::AtomicBool;
949        use std::sync::Mutex;
950
951        // Drain any stragglers so the in-closure peek below sees only our event.
952        while MenuEvent::receiver().try_recv().is_ok() {}
953
954        let log = Arc::new(Mutex::new(Vec::<String>::new()));
955        // Whether the activation is already on the global channel at the moment
956        // the closure runs. Under correct order (closure first) it must be false;
957        // a reversed emit-then-closure implementation would flip this flag.
958        let seen_on_channel_in_closure = Arc::new(AtomicBool::new(false));
959        let log2 = Arc::clone(&log);
960        let flag2 = Arc::clone(&seen_on_channel_in_closure);
961        let tray = Tray::new(Icon::Checkmark).on_click(move |id| {
962            if let Ok(ev) = MenuEvent::receiver().try_recv() {
963                if ev.id == MenuId::from("m3_order_probe") {
964                    flag2.store(true, Ordering::SeqCst);
965                }
966            }
967            log2.lock()
968                .unwrap()
969                .push(format!("closure:{}", id.as_str()))
970        });
971
972        // An inert `MenuId::none()` fires neither the closure nor the channel.
973        tray.dispatch(&MenuId::none());
974        assert!(
975            log.lock().unwrap().is_empty(),
976            "inert id must not fire the closure"
977        );
978
979        tray.dispatch(&MenuId::from("m3_order_probe"));
980        log.lock().unwrap().push("after_dispatch".to_string());
981
982        assert!(
983            !seen_on_channel_in_closure.load(Ordering::SeqCst),
984            "channel must still be empty while the closure runs (closure-first order)"
985        );
986
987        let mut found = false;
988        while let Ok(ev) = MenuEvent::receiver().try_recv() {
989            if ev.id == MenuId::from("m3_order_probe") {
990                found = true;
991                break;
992            }
993        }
994        assert!(
995            found,
996            "activation must be projected onto the global channel after the closure"
997        );
998
999        let log = log.lock().unwrap();
1000        assert_eq!(log[0], "closure:m3_order_probe");
1001        assert_eq!(log[1], "after_dispatch");
1002    }
1003
1004    #[test]
1005    fn context_menu_dispatch_works() {
1006        let _guard = crate::event::test_lock();
1007        let hit = Arc::new(AtomicUsize::new(0));
1008        let hit2 = Arc::clone(&hit);
1009        let cm = ContextMenu::new(Menu::new()).on_click(move |_| {
1010            hit2.fetch_add(1, Ordering::SeqCst);
1011        });
1012        cm.dispatch(&MenuId::from("x"));
1013        assert_eq!(hit.load(Ordering::SeqCst), 1);
1014    }
1015
1016    #[test]
1017    fn main_thread_marker_new_always_returns_some() {
1018        // Documents the intentional "no portable safe runtime check" contract
1019        // (issue #46): see the type-level compile_fail doctest for the
1020        // !Send/!Sync half of the guarantee.
1021        assert!(MainThreadMarker::new().is_some());
1022    }
1023
1024    #[test]
1025    fn surface_id_is_unique_and_monotonic_across_surface_kinds() {
1026        // issue #51: every Tray/ContextMenu/Popup gets its own id, in
1027        // creation order, from the same process-global counter.
1028        let a = Tray::new(Icon::Checkmark).surface_id();
1029        let b = ContextMenu::new(Menu::new()).surface_id();
1030        let c = Popup::new(Menu::new()).surface_id();
1031        let d = Tray::new(Icon::Checkmark).surface_id();
1032        assert_ne!(a, b);
1033        assert_ne!(b, c);
1034        assert_ne!(a, d);
1035        assert!(b.0 > a.0);
1036        assert!(c.0 > b.0);
1037        assert!(d.0 > c.0);
1038    }
1039
1040    #[test]
1041    fn tray_handle_drop_posts_shutdown_once_on_last_clone_only() {
1042        // issue #47: only the last outstanding clone of a handle family shuts
1043        // the tray down, exactly once. `inspector` is a separate handle family
1044        // (its own `handle()` call) used purely to observe posts without
1045        // counting toward `family`'s clone count.
1046        let tray = Tray::new(Icon::Checkmark);
1047        let family = tray.handle();
1048        let clone = family.clone();
1049        let inspector = tray.handle();
1050
1051        drop(clone);
1052        assert!(
1053            inspector.take_posted().is_empty(),
1054            "an intermediate clone dropping must not post Shutdown"
1055        );
1056
1057        drop(family);
1058        let posted = inspector.take_posted();
1059        assert!(
1060            matches!(posted.as_slice(), [TrayCommand::Shutdown]),
1061            "the last clone dropping must post exactly one Shutdown, got {:?}",
1062            posted
1063        );
1064    }
1065
1066    #[test]
1067    fn concurrent_last_clone_drops_post_shutdown_exactly_once() {
1068        // Race regression: the last two clones dropped concurrently on two
1069        // threads must still post exactly ONE Shutdown. The `Arc<HandleFamily>`
1070        // drop-guard makes this exactly-once regardless of interleaving (issue #47).
1071        let tray = Tray::new(Icon::Checkmark);
1072        let inspector = tray.handle(); // separate family; only observes the queue
1073        let h1 = tray.handle();
1074        let h2 = h1.clone();
1075        let t1 = std::thread::spawn(move || drop(h1));
1076        let t2 = std::thread::spawn(move || drop(h2));
1077        t1.join().unwrap();
1078        t2.join().unwrap();
1079        let posted = inspector.take_posted();
1080        let shutdowns = posted
1081            .iter()
1082            .filter(|c| matches!(c, TrayCommand::Shutdown))
1083            .count();
1084        assert_eq!(
1085            shutdowns, 1,
1086            "the family's final drop must post exactly one Shutdown, got {posted:?}"
1087        );
1088    }
1089
1090    #[test]
1091    fn set_theme_and_options_post_live_swap_commands() {
1092        // The in-menu "Preview theme" path (#45): a handle posts SetTheme /
1093        // SetOptions, which the backend applies on its UI thread.
1094        let tray = Tray::new(Icon::Checkmark);
1095        let inspector = tray.handle();
1096        let control = tray.handle();
1097        control.set_theme(ThemeSource::MacOs(ThemeMode::Dark));
1098        control.set_options(MenuOptions::default().min_width(120.0));
1099        let posted = inspector.take_posted();
1100        assert!(
1101            matches!(
1102                posted.as_slice(),
1103                [TrayCommand::SetTheme(_), TrayCommand::SetOptions(_)]
1104            ),
1105            "set_theme/set_options must post the matching commands in order, got {:?}",
1106            posted
1107        );
1108    }
1109
1110    #[test]
1111    fn anchor_rect_query_times_out_to_none_with_no_live_backend() {
1112        // With no run loop draining the queue, the QueryAnchorRect reply never
1113        // arrives, so the best-effort cross-thread query returns None (#48).
1114        let tray = Tray::new(Icon::Checkmark);
1115        let handle = tray.handle();
1116        assert!(handle.anchor_rect().is_none());
1117        // Prove it actually *posted* the query (not merely returned a stubbed
1118        // None): a QueryAnchorRect command must be sitting in the queue.
1119        let posted = handle.take_posted();
1120        assert!(
1121            posted
1122                .iter()
1123                .any(|c| matches!(c, TrayCommand::QueryAnchorRect(_))),
1124            "anchor_rect must post a QueryAnchorRect command, got {posted:?}"
1125        );
1126    }
1127}