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}