muri 0.14.6

Menu Utilities for Rust Interfaces — a cross-platform, fully-styleable tray-icon and popup-menu system (a custom-drawn muda/tray-icon replacement).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
//! # muri — Menu Utilities for Rust Interfaces
//!
//! `muri` is a cross-platform, fully-styleable **tray-icon + popup-menu** system
//! for Rust: a custom-drawn replacement for the `muda` + `tray-icon` pairing.
//! Unlike native menus (which delegate pixels to AppKit / Win32 USER / GTK and
//! can't be restyled), muri draws **one consistent custom appearance on every
//! OS**, enabling true alignment, arbitrary colors/fonts, embedded logos, and
//! **flush right-aligned values with no reserved chevron column**.
//!
//! muri owns the whole stack: the **tray icon**, the **styled popup**, and the
//! **anchoring** (where the popup appears relative to the icon). A consuming app
//! drives it with a small builder API:
//!
//! ```no_run
//! use muri::{Tray, Menu, Row, Icon};
//!
//! # fn demo(icon_png: &[u8]) {
//! let menu = Menu::new()
//!     .row(Row::new("open").label("Open"))
//!     .separator()
//!     .row(Row::new("quit").label("Quit"));
//!
//! let _tray = Tray::new(Icon::from_png(icon_png))
//!     .tooltip("My App")
//!     .menu(menu)
//!     .on_click(|id| println!("clicked {}", id.as_str()));
//! // let _m = muri::MainThreadMarker::new().unwrap(); // call this on the real main thread
//! // _tray.run(_m); // installs the tray icon and enters the platform event loop
//! # }
//! ```
//!
//! ## Native API: the one obvious way
//!
//! The native surface deliberately has **one canonical call per task**, with
//! alternatives kept only as clearly-labeled thin sugar or full-control escape
//! hatches (issue #62). When in doubt, reach for the canonical path:
//!
//! | Task | Canonical native call | Escape hatch |
//! |------|-----------------------|--------------|
//! | 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`] |
//! | An interactive row | [`Row::new(id)`](Row::new) | — |
//! | A header / label / info row | [`Row::label_only(text)`](Row::label_only) | [`Row::default`] + segments |
//! | Row text | [`Row::label`] / [`Row::label_value`] | [`Row::segments`] (hand-built [`Segment`]s) |
//! | Bold a row's label | [`Row::bold`] | a whole-label [`StyleRun`] with [`Weight::Bold`] |
//! | Color a row's value | [`Row::value_color`] | per-substring [`StyleRun`]s |
//! | An icon | [`Icon::from_png`] / [`Icon::from_rgba`] / [`Icon::from_svg`] | — |
//! | Choose the look | [`MenuOptions`] (carrying a [`ThemeSource`]) | [`Tray::theme`] / [`TrayHandle::set_theme`] (derived conveniences that set the `MenuOptions` theme) |
//!
//! Per-run [`StyleRun`] styling (via [`Segment::run`]/[`Segment::runs`]) is the
//! **one** styling system; [`Row::bold`]/[`Row::value_color`] are ergonomic
//! front doors onto it and render identically to hand-built runs.
//! [`MenuOptions`] is the single source of truth for "which look".
//!
//! ## Status
//!
//! **0.9.0 testing release — all three backends implemented.** **macOS** draws
//! a real styled popup (`NSStatusItem` + non-activating `NSPanel`), with
//! flyout submenus, keyboard navigation ([`keynav`]), and an accessibility
//! tree ([`a11y`]) exposed to VoiceOver via AccessKit (`a11y` feature).
//! **Windows** anchors a `WS_EX_NOACTIVATE` layered popup and exposes UIA via
//! `accesskit_windows`. **Linux** installs an SNI/AppIndicator native menu and
//! supports pointer-anchored popups via X11 override-redirect. [`ContextMenu::open_at`]
//! / [`Popup::anchored_to`] / [`TrayHandle::open`] work on all three
//! platforms; on-device verification (real hardware, real screen readers) is
//! what this testing release is for. See the README for the honest platform
//! matrix.
//!
//! ## Crate layout
//!
//! - [`menu`] — the declarative `Segment` → `Row` → `Item` → `Menu` tree,
//!   identifiers, events, and icons (pure data model).
//! - [`style`] / [`theme`] — visual primitives ([`Color`], [`Font`]) and the
//!   [`Theme`] surface with pure semantic-color resolution.
//! - [`layout`] — pure `Flex`/`Align` width resolution (the flush-right layout).
//! - [`flyout`] — pure flyout-submenu placement (right/left flip, clamp) and
//!   hover-stack transitions.
//! - [`geometry`] — logical points/sizes/rects and [`Insets`]/[`Edge`].
//! - [`render`] — the [`SceneDrawer`](render::SceneDrawer) interface shared by
//!   the one CPU-raster backend on every OS.
//! - [`platform`] — the single per-OS [`Platform`] seam
//!   (tray anchor, popup event loop, environment) selected once.
//! - [`error`] — [`Error`] / [`Unsupported`].
//!
//! ## Rendering stack
//!
//! Text is shaped/rasterized with `fontdb` + `harfrust` + `swash`; everything
//! else is composited by muri's own CPU [`Framebuffer`](render::Framebuffer)
//! blitter — a tiny binary with no GPU warm-up. Windowing is native per-OS:
//! macOS uses a non-activating `NSPanel`/`CALayer`; Windows a
//! `WS_EX_NOACTIVATE` layered window; Linux an SNI tray plus an X11
//! override-redirect window for [`ContextMenu::open_at`].
//!
//! ## Platform support (honest matrix)
//!
//! Legend: ✅ working (automated-tested / live) · 🔬 code-complete, on-device
//! verification pending (this is what the 0.9.0 testing release is for) ·
//! ❌ not offered (by design).
//!
//! | OS      | Tray icon | Styled anchored popup | Context menu (`open_at`) | Screen reader |
//! |---------|-----------|-----------------------|---------------------------|---------------|
//! | macOS   | 🔬 `NSStatusItem` | 🔬 non-activating `NSPanel`, N-level flyouts, mouse + keyboard nav | 🔬 `open_at` + `Popup` (shared `PopupSession`) | 🔬 VoiceOver (per-window AccessKit adapters wired) |
//! | 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`) |
//! | 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) |
//!
//! The 🔬 cells are muri code that *builds* and is clippy-clean on its target
//! in CI but hasn't been exercised on real hardware yet — verifying them, and
//! the four screen readers, is exactly what the 0.9.0 testing release is for;
//! each flips to ✅ as it's confirmed on the road to 1.0. The Linux
//! tray-anchored styled popup stays ❌ permanently.
//!
//! **Linux caveat:** the SNI/AppIndicator tray *host* owns the icon in its own
//! process, so the app never gets the icon's rect or click coordinate; Wayland
//! also forbids a client from positioning its own toplevel. A tray-anchored
//! styled popup is therefore architecturally impossible there. [`Tray::run`]
//! still installs a native SNI/AppIndicator menu, but reports
//! [`Error::Unsupported`]`(`[`Unsupported::TrayAnchor`]`)` for the anchor rect
//! — use that native menu or a pointer-anchored [`ContextMenu`] instead. See
//! [`Unsupported`].

// The OS backends require `unsafe` (NSStatusItem/objc2, NSPanel/CALayer,
// layered HWND, X11 override-redirect); the portable scene drawer and data
// model do not. `unsafe` is denied crate-wide and re-allowed only inside the
// per-OS platform modules, which already localize it without a target_os gate.
#![deny(unsafe_code)]
#![deny(missing_docs)]

pub mod a11y;
pub mod anchor;
// The muda-compatibility facade (spec 02/60), behind the `muda-compat` feature.
#[cfg(feature = "muda-compat")]
pub mod compat;
pub mod error;
// The process-global `MenuEvent` channel (spec 03 §3). Part of the native crate
// surface (always compiled); the muda-compat facade re-exports it.
pub mod event;
pub mod flyout;
pub mod geometry;
pub mod keynav;
pub mod layout;
pub mod menu;
pub mod platform;
pub mod render;
pub mod style;
pub mod theme;

pub use a11y::{announcement, build_tree, focused_id, locate, AxId, AxNode, AxRole, AxTree};
pub use anchor::place_popup;
pub use error::{Error, Result, Unsupported};
pub use event::MenuEventReceiver;
pub use flyout::{next_flyout, place_flyout, FlyoutPlacement, FlyoutSide, HoverTarget};
pub use geometry::{Edge, Insets, LogicalPoint, LogicalRect, LogicalSize};
pub use keynav::{handle_key, FlyoutFocus, MenuFocus, NavAction, NavKey};
pub use menu::{
    Align, Axis, ClickHandler, Content, Flex, Icon, Item, Menu, MenuEvent, MenuId, Row, Segment,
    Stack, StyleRun, TextContent,
};
pub use platform::{Appearance, Platform, PlatformEvent};
pub use render::{render_menu_to_png, render_menu_to_rgba};
pub use style::{Color, Font, FontFamily, Rgba, Weight};
pub use theme::{
    GutterPolicy, MenuOptions, OsFamily, Preset, Theme, ThemeMode, ThemeSource,
    TrailingGutterPolicy,
};

use std::marker::PhantomData;
use std::sync::atomic::{AtomicU64, Ordering};

// =============================================================================
// MainThreadMarker
// =============================================================================

/// A zero-cost, `!Send + !Sync` proof that the calling code is running on the
/// thread that obtained it — required by [`Tray::run`] and [`Tray::spawn`]
/// because installing an `NSStatusItem` is only safe from AppKit's main thread
/// on macOS (issue #46); Windows/Linux take the same proof for one uniform
/// contract across backends.
///
/// `PhantomData<*const ()>` makes this `!Send + !Sync` for free, so a marker
/// obtained on one thread can't be smuggled to another via a channel or
/// closure.
///
/// ```compile_fail
/// fn is_send<T: Send>() {}
/// is_send::<muri::MainThreadMarker>(); // fails: MainThreadMarker is !Send
/// ```
#[derive(Clone, Copy, Debug)]
pub struct MainThreadMarker(PhantomData<*const ()>);

impl MainThreadMarker {
    /// Obtain a proof that the caller is on the main thread.
    ///
    /// muri has no portable, safe way to verify this without OS-specific FFI
    /// (and the crate root denies `unsafe_code`), so this is intentionally
    /// **not** a runtime check — it always returns `Some`; the guarantee is
    /// purely compile-time (see [`MainThreadMarker`]'s type doc), so the caller
    /// must actually call this from the real main thread. The per-OS backend
    /// (e.g. macOS's `objc2::MainThreadMarker`) still performs its own runtime
    /// check before touching AppKit, catching a caller that gets this wrong.
    #[allow(clippy::unnecessary_wraps)]
    pub fn new() -> Option<Self> {
        Some(MainThreadMarker(PhantomData))
    }
}

// =============================================================================
// SurfaceId
// =============================================================================

/// A process-global, monotonically increasing identifier for one *surface*
/// instance — a [`Tray`], [`ContextMenu`], or [`Popup`] — so a [`MenuEvent`]
/// consumer can tell which surface an activation came from (issue #51).
///
/// Assigned once, in the surface's constructor, from a process-wide
/// [`AtomicU64`] counter; every live surface has a distinct id, and ids are
/// issued in creation order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SurfaceId(u64);

impl SurfaceId {
    /// Issue the next process-global id.
    pub(crate) fn next() -> Self {
        static NEXT: AtomicU64 = AtomicU64::new(1);
        SurfaceId(NEXT.fetch_add(1, Ordering::Relaxed))
    }
}

// =============================================================================
// Tray
// =============================================================================

/// A live tray icon with an attached styled menu.
///
/// Construct with [`Tray::new`], configure via the builder methods, then call
/// [`Tray::run`] to install the icon and enter the platform event loop. Content
/// can be swapped at runtime with [`Tray::set_menu`] (or from another thread via
/// a [`TrayHandle`]) — usagio rebuilds its menu on a ~0.75s tick.
///
/// ## Anchoring, per OS
///
/// - **macOS:** anchored to the `NSStatusItem` button; AppKit computes the
///   on-screen rect and which display the menu bar is on.
/// - **Windows:** anchored via `Shell_NotifyIconGetRect`, positioning a
///   `WS_EX_NOACTIVATE` layered window toward screen center.
/// - **Linux:** [`Tray::run`] installs an SNI/AppIndicator **native** menu (it
///   does not block on anchoring); a styled *tray-anchored* popup is not offered
///   (the anchor rect is unavailable — the backend reports
///   [`Error::Unsupported`]`(`[`Unsupported::TrayAnchor`]`)`). Use that native
///   menu or a pointer-anchored [`ContextMenu`].
///
/// ## Shutdown
///
/// Dropping a [`Tray`] itself does **not** post `TrayCommand::Shutdown` — it's
/// normally consumed by [`Tray::run`]/[`Tray::spawn`] first. What auto-shuts-down
/// the tray is dropping the **last** outstanding [`TrayHandle`] from a given
/// [`Tray::handle`] call: its `Drop` (issue #47) posts `Shutdown` exactly once,
/// matching `tray-icon`'s drop-removes contract with no explicit
/// [`TrayHandle::shutdown`] call needed.
pub struct Tray {
    icon: Icon,
    menu: Menu,
    tooltip: Option<String>,
    /// Optional text shown *beside/instead of* the icon in the status item —
    /// the macOS menu-bar title (e.g. a live "45%"). Rendered on the
    /// `NSStatusItem` button; on Windows/Linux the notification area has no text
    /// label, so it is retained but not drawn.
    title: Option<String>,
    options: MenuOptions,
    on_click: Option<ClickHandler>,
    /// Commands posted by a [`TrayHandle`] from any thread, drained on the
    /// platform run loop. Shared with every handle handed out via
    /// [`Tray::handle`]; the backend installs the wake mechanism in
    /// [`Tray::run`] so posts made before `run` simply buffer here.
    commands: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
    /// The main-thread wake, installed by the backend once its run loop is
    /// live. Posting a command calls this (if present) to schedule a drain.
    waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
    /// This surface's process-global identity (issue #51). Exposed via
    /// [`Tray::surface_id`].
    surface_id: SurfaceId,
}

/// A thread-safe wake callback the backend installs to poke its native run
/// loop when a [`TrayHandle`] posts a command.
type WakeFn = Box<dyn Fn() + Send + Sync + 'static>;

/// A live command from a [`TrayHandle`] to a running [`Tray`], applied on the
/// platform's UI thread. Deliberately OS-neutral (carries only data model
/// types) so the same vocabulary drives every backend.
///
/// Every variant's payload is consumed by all three backends' `apply_command`
/// (macOS, Windows, Linux — see `src/platform/{mac,windows,linux}.rs`).
#[derive(Debug)]
pub(crate) enum TrayCommand {
    /// Replace the menu content and repaint/refresh any open popup.
    SetMenu(Menu),
    /// Replace the tray icon.
    SetIcon(Icon),
    /// Replace the tooltip / accessible name.
    SetTooltip(Option<String>),
    /// Replace the status-item text title (macOS menu-bar text).
    SetTitle(Option<String>),
    /// Show or hide the tray status item.
    SetVisible(bool),
    /// Programmatically open the popup anchored to the tray icon.
    Open,
    /// Dismiss the popup if shown.
    Close,
    /// Stop the tray: remove the OS status item and end the backend's run loop
    /// (and background thread, for a spawned tray). Posted by compat facade
    /// `Drop`, explicit [`TrayHandle::shutdown`], and automatically by
    /// [`TrayHandle`]'s own `Drop` when the last handle in a family goes out of
    /// scope (issue #47), matching `tray-icon`'s drop-removes contract.
    Shutdown,
    /// Swap the live theme source. Applied on the backend's UI thread: the next
    /// popup open uses it, and any currently-open popup is repainted with it —
    /// so a consumer can offer an in-menu "Preview theme" switcher (issue #45).
    /// On Linux the persistent tray is a native `dbusmenu` the host renders, so
    /// this only affects muri's own styled context-menu popups, not the SNI tray.
    SetTheme(ThemeSource),
    /// Swap the live [`MenuOptions`] wholesale (theme + width bounds + gutter
    /// policy). Applied exactly like [`SetTheme`](TrayCommand::SetTheme) (#45).
    SetOptions(MenuOptions),
    /// Best-effort cross-thread request for the tray icon's current on-screen
    /// anchor rectangle: the backend replies on its UI thread with the live rect
    /// (or `None` when unavailable / unsupported, e.g. the Linux SNI tray, whose
    /// host never exposes icon geometry) (issue #48).
    QueryAnchorRect(std::sync::mpsc::Sender<Option<LogicalRect>>),
}

/// A cheap, `Clone + Send` remote control for a running [`Tray`].
///
/// [`Tray::run`] consumes the tray, so `TrayHandle` closes the gap: obtain one
/// with [`Tray::handle`] *before* `run`, move it to any thread, and post
/// commands the backend applies on its UI thread. Commands posted before the
/// run loop is live simply buffer and apply once it starts.
#[derive(Clone)]
pub struct TrayHandle {
    queue: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
    waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
    /// Shared across every clone of *this handle family* — fresh per
    /// [`Tray::handle`] call, shared via `.clone()`. Its [`Drop`] posts
    /// `TrayCommand::Shutdown` exactly once, when the last clone releases the
    /// final `Arc` (issue #47) — deliberately its own `Arc`, independent of
    /// `queue`/`waker`, so the backend's own reference never factors in.
    ///
    /// Held purely for its `Drop` guard; never read directly, hence
    /// `allow(dead_code)` — cloning it is what shares the family.
    #[allow(dead_code)]
    family: std::sync::Arc<HandleFamily>,
}

/// The shared drop-guard for a [`TrayHandle`] family. Posting `Shutdown` from
/// *this* type's `Drop` (rather than from `TrayHandle::drop` gated on
/// `Arc::strong_count == 1`) makes the "last clone gone" signal race-free: the
/// `Arc` runtime guarantees `HandleFamily::drop` runs exactly once, when the
/// final clone is released, even if two final clones on different threads drop
/// concurrently. A `strong_count == 1` check in `TrayHandle::drop` could let both
/// such drops read `count > 1` (each still counts itself, and neither field
/// decrement has happened yet) and neither post — leaking the tray + its thread.
struct HandleFamily {
    queue: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
    waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
}

impl Drop for HandleFamily {
    fn drop(&mut self) {
        if let Ok(mut q) = self.queue.lock() {
            q.push(TrayCommand::Shutdown);
        }
        if let Ok(waker) = self.waker.lock() {
            if let Some(wake) = waker.as_ref() {
                wake();
            }
        }
    }
}

impl std::fmt::Debug for TrayHandle {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TrayHandle").finish_non_exhaustive()
    }
}

impl TrayHandle {
    /// Test-only: drain and return the commands posted so far, so a unit test can
    /// assert that a setter posted the *right* `TrayCommand` with the right
    /// payload (the facade setters go through this path but the OS backend that
    /// would otherwise consume it is not installed headlessly).
    #[cfg(test)]
    pub(crate) fn take_posted(&self) -> Vec<TrayCommand> {
        self.queue
            .lock()
            .map(|mut q| std::mem::take(&mut *q))
            .unwrap_or_default()
    }

    fn post(&self, command: TrayCommand) {
        if let Ok(mut q) = self.queue.lock() {
            q.push(command);
        }
        if let Ok(waker) = self.waker.lock() {
            if let Some(wake) = waker.as_ref() {
                wake();
            }
        }
    }

    /// Replace the menu shown on the next open (and repaint + refresh the
    /// accessibility tree live if the popup is already open).
    pub fn set_menu(&self, menu: Menu) {
        self.post(TrayCommand::SetMenu(menu));
    }

    /// Replace the tray icon.
    pub fn set_icon(&self, icon: Icon) {
        self.post(TrayCommand::SetIcon(icon));
    }

    /// Replace the tooltip / accessible name.
    pub fn set_tooltip(&self, tooltip: Option<impl Into<String>>) {
        self.post(TrayCommand::SetTooltip(tooltip.map(Into::into)));
    }

    /// Replace the status-item text title (macOS menu-bar text, e.g. a live
    /// "45%"). A no-op on the drawn item on Windows/Linux.
    pub fn set_title(&self, title: Option<impl Into<String>>) {
        self.post(TrayCommand::SetTitle(title.map(Into::into)));
    }

    /// Show or hide the tray status item.
    pub fn set_visible(&self, visible: bool) {
        self.post(TrayCommand::SetVisible(visible));
    }

    /// Programmatically open the popup anchored to the tray icon.
    pub fn open(&self) {
        self.post(TrayCommand::Open);
    }

    /// Dismiss the popup if shown.
    pub fn close(&self) {
        self.post(TrayCommand::Close);
    }

    /// Stop the tray: remove the OS status item and end its backend run loop (and
    /// background thread, for a spawned tray). Best-effort and asynchronous — the
    /// removal is applied on the backend's UI thread. Used by the compat facade
    /// to remove the icon when its `TrayIcon` is dropped, matching `tray-icon`.
    pub fn shutdown(&self) {
        self.post(TrayCommand::Shutdown);
    }

    /// Swap the live theme source (issue #45). Applied asynchronously on the
    /// backend's UI thread: the next popup open uses it, and any currently-open
    /// popup is repainted — so a consumer can wire an in-menu "Preview theme"
    /// submenu. See `TrayCommand::SetTheme` for the Linux SNI-tray caveat.
    pub fn set_theme(&self, theme: ThemeSource) {
        self.post(TrayCommand::SetTheme(theme));
    }

    /// Swap the live [`MenuOptions`] wholesale — theme, width bounds, gutter
    /// policy (issue #45). Applied like [`set_theme`](TrayHandle::set_theme).
    pub fn set_options(&self, options: MenuOptions) {
        self.post(TrayCommand::SetOptions(options));
    }

    /// Best-effort cross-thread query for the tray icon's current on-screen
    /// rectangle (issue #48). The native, main-thread counterpart is
    /// [`Tray::anchor_rect`].
    ///
    /// Posts a `TrayCommand::QueryAnchorRect` and waits briefly for the
    /// backend to reply on its UI thread. Returns `None` if the run loop is not
    /// yet live, the query times out, or the platform can't report geometry
    /// (the Linux SNI tray never can — its host owns the icon).
    pub fn anchor_rect(&self) -> Option<LogicalRect> {
        let (tx, rx) = std::sync::mpsc::channel();
        self.post(TrayCommand::QueryAnchorRect(tx));
        rx.recv_timeout(std::time::Duration::from_millis(200))
            .ok()
            .flatten()
    }
}

impl Tray {
    /// Create a tray with the given status-bar icon.
    pub fn new(icon: Icon) -> Self {
        Tray {
            icon,
            menu: Menu::new(),
            tooltip: None,
            title: None,
            options: MenuOptions::default(),
            on_click: None,
            commands: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
            waker: std::sync::Arc::new(std::sync::Mutex::new(None)),
            surface_id: SurfaceId::next(),
        }
    }

    /// This tray's process-global [`SurfaceId`] (issue #51), assigned once in
    /// [`Tray::new`]. Lets a [`MenuEvent`] consumer correlate an activation
    /// back to the surface it came from.
    pub fn surface_id(&self) -> SurfaceId {
        self.surface_id
    }

    /// A cheap, `Clone + Send` [`TrayHandle`] that can drive this tray from any
    /// thread once [`Tray::run`] is live (posts made earlier buffer). Obtain it
    /// before `run` consumes the tray.
    ///
    /// Each call starts a fresh, independently-tracked handle *family*: dropping
    /// every handle/clone in one `handle()` call's family auto-shuts-down the
    /// tray (issue #47). Calling `handle()` again creates a separate family —
    /// prefer calling it once and fanning out with `.clone()`.
    pub fn handle(&self) -> TrayHandle {
        TrayHandle {
            queue: std::sync::Arc::clone(&self.commands),
            waker: std::sync::Arc::clone(&self.waker),
            family: std::sync::Arc::new(HandleFamily {
                queue: std::sync::Arc::clone(&self.commands),
                waker: std::sync::Arc::clone(&self.waker),
            }),
        }
    }

    /// The tray icon's current on-screen rectangle in logical coordinates
    /// (issue #48) — the native, main-thread counterpart of
    /// [`TrayHandle::anchor_rect`].
    ///
    /// **Honest limitation:** [`Tray::run`]/[`Tray::spawn`] consume the `Tray`,
    /// so there's no `&self` left once a live icon exists. This queries a
    /// **fresh** [`platform::current()`] instance that never had `install_tray`
    /// called, so on macOS/Windows it reliably returns `Err` until the engine
    /// can query a running backend's live state. Provided for API symmetry with
    /// [`TrayHandle::anchor_rect`]; Linux's answer ([`Unsupported::TrayAnchor`])
    /// doesn't depend on installation state.
    pub fn anchor_rect(&self) -> Result<LogicalRect> {
        platform::current().tray_anchor_rect()
    }

    /// Attach the menu shown when the icon is clicked.
    pub fn menu(mut self, menu: Menu) -> Self {
        self.menu = menu;
        self
    }

    /// Set the tray icon tooltip / accessible name.
    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
        self.tooltip = Some(text.into());
        self
    }

    /// Set the status-item text title — the macOS menu-bar text shown beside (or
    /// instead of) the icon, e.g. a live "45%". Rendered on the `NSStatusItem`
    /// button; on Windows/Linux the notification area has no text label, so this
    /// is retained but not drawn.
    pub fn title(mut self, text: impl Into<String>) -> Self {
        self.title = Some(text.into());
        self
    }

    /// Set popup options (width bounds, theme source). [`MenuOptions`] is the
    /// canonical, single source of truth for a tray's look and layout (issue
    /// #62); [`theme`](Tray::theme) below is a derived convenience over it.
    pub fn options(mut self, options: MenuOptions) -> Self {
        self.options = options;
        self
    }

    /// Convenience: set just the theme source. A thin wrapper over
    /// [`options`](Tray::options) — it sets the [`MenuOptions::theme`] field,
    /// which is the canonical "which look" source of truth (issue #62).
    pub fn theme(mut self, theme: ThemeSource) -> Self {
        self.options.theme = theme;
        self
    }

    /// Register a click handler invoked with the activated row's [`MenuId`].
    pub fn on_click(mut self, handler: impl Fn(&MenuId) + Send + 'static) -> Self {
        self.on_click = Some(Box::new(handler));
        self
    }

    /// Replace the menu content at runtime (cheap; re-rendered on next open).
    pub fn set_menu(&mut self, menu: Menu) {
        self.menu = menu;
    }

    /// Replace the status-bar icon at runtime (re-published on next backend
    /// update; on Linux this re-registers the SNI `icon_pixmap`).
    pub fn set_icon(&mut self, icon: Icon) {
        self.icon = icon;
    }

    /// Replace the status-item text title at runtime (macOS menu-bar text).
    pub fn set_title(&mut self, title: Option<String>) {
        self.title = title;
    }

    /// The status-item text title, if set.
    pub fn title_text(&self) -> Option<&str> {
        self.title.as_deref()
    }

    /// The tooltip, if set.
    pub fn tooltip_text(&self) -> Option<&str> {
        self.tooltip.as_deref()
    }

    /// Borrow the current menu.
    pub fn current_menu(&self) -> &Menu {
        &self.menu
    }

    /// Borrow the icon.
    pub fn icon(&self) -> &Icon {
        &self.icon
    }

    /// Borrow the options.
    pub fn menu_options(&self) -> &MenuOptions {
        &self.options
    }

    /// Build the [`AxTree`] the platform screen reader walks over the current
    /// menu. The backend rebuilds this whenever the menu is (re)opened or swapped
    /// and feeds it to the platform accessibility API (via AccessKit).
    pub fn accessibility_tree(&self) -> AxTree {
        a11y::build_tree(&self.menu)
    }

    /// Dispatch a click to the registered handler, if any. Used by the backend
    /// when a row is activated; exposed so the data flow is testable without a
    /// live event loop.
    ///
    /// Order (spec 03 §3): the per-surface `on_click` closure runs first
    /// (synchronously), then the same activation is projected onto the global
    /// [`MenuEvent`] channel and any `set_event_handler`. An inert
    /// [`MenuId::none`] fires neither.
    pub fn dispatch(&self, id: &MenuId) {
        if id.is_none() {
            return;
        }
        if let Some(handler) = &self.on_click {
            handler(id);
        }
        event::emit(id.clone(), self.surface_id);
    }

    /// Install the tray icon and run the platform event loop, dispatching row
    /// activations to the registered handler and the global [`MenuEvent`]
    /// channel. Consumes the [`Tray`] and blocks for its lifetime; obtain a
    /// [`TrayHandle`] with [`Tray::handle`] *before* calling this to drive it
    /// from any thread.
    ///
    /// Per-OS backend, selected once in [`platform::current`]: macOS runs the
    /// native `NSApplication` loop, Windows the Win32 message pump, Linux its
    /// SNI/AppIndicator worker loop. Takes a [`MainThreadMarker`] (issue #46)
    /// since installing the `NSStatusItem` is only safe on AppKit's main thread.
    pub fn run(self, _m: MainThreadMarker) -> Result<()> {
        // One seam: the per-OS backend selected once in `platform::current()`.
        platform::current().run_tray(self)
    }

    /// Install the tray icon and begin driving it **without blocking**, returning
    /// a [`TrayHandle`] to mutate it from any thread. The non-blocking counterpart
    /// to [`Tray::run`], for hosts that own their own event loop (e.g. the
    /// `tray-icon` compat facade).
    ///
    /// On Windows/Linux the UI pump runs on a dedicated background thread. On
    /// macOS this is **best-effort**: `spawn` must be called from the main thread
    /// and relies on the host's existing `NSApplication` loop (see
    /// [`Platform::spawn_tray`]). Takes a [`MainThreadMarker`] (issue #46) for the
    /// same reason as [`Tray::run`].
    pub fn spawn(self, _m: MainThreadMarker) -> Result<TrayHandle> {
        let handle = self.handle();
        platform::current().spawn_tray(self)?;
        Ok(handle)
    }
}

// =============================================================================
// ContextMenu
// =============================================================================

/// A free-standing styled menu shown at an explicit screen point. Unlike a
/// tray-anchored popup, this works anywhere a pointer coordinate is available —
/// including Linux/Wayland via `xdg_positioner` relative to the caller's own
/// surface — so it is muri's portable styled-menu primitive.
pub struct ContextMenu {
    menu: Menu,
    options: MenuOptions,
    on_click: Option<ClickHandler>,
    /// This surface's process-global identity (issue #51).
    surface_id: SurfaceId,
}

impl ContextMenu {
    /// Create a context menu from a [`Menu`].
    pub fn new(menu: Menu) -> Self {
        ContextMenu {
            menu,
            options: MenuOptions::default(),
            on_click: None,
            surface_id: SurfaceId::next(),
        }
    }

    /// This surface's process-global [`SurfaceId`] (issue #51), assigned once
    /// in [`ContextMenu::new`].
    pub fn surface_id(&self) -> SurfaceId {
        self.surface_id
    }

    /// Set popup options.
    pub fn options(mut self, options: MenuOptions) -> Self {
        self.options = options;
        self
    }

    /// Register a click handler.
    pub fn on_click(mut self, handler: impl Fn(&MenuId) + Send + 'static) -> Self {
        self.on_click = Some(Box::new(handler));
        self
    }

    /// Borrow the menu.
    pub fn menu(&self) -> &Menu {
        &self.menu
    }

    /// Borrow the options.
    pub fn menu_options(&self) -> &MenuOptions {
        &self.options
    }

    /// Build the [`AxTree`] the platform screen reader walks over this menu.
    pub fn accessibility_tree(&self) -> AxTree {
        a11y::build_tree(&self.menu)
    }

    /// Dispatch a click to the registered handler, if any. Fires the `on_click`
    /// closure first, then projects the activation onto the global
    /// [`MenuEvent`] channel (spec 03 §3); an inert [`MenuId::none`] fires
    /// neither.
    pub fn dispatch(&self, id: &MenuId) {
        if id.is_none() {
            return;
        }
        if let Some(handler) = &self.on_click {
            handler(id);
        }
        event::emit(id.clone(), self.surface_id);
    }

    /// Show the menu at the given screen point, growing from `edge`, and block
    /// until it is dismissed.
    ///
    /// The point is treated as a zero-size anchor rectangle and funnelled through
    /// the shared `PopupSession` (the same `place_popup` + scene drawer + dismiss
    /// machinery the tray uses — spec 20 §3). Row activation is dispatched to the
    /// [`on_click`](ContextMenu::on_click) handler. On platforms whose styled
    /// popup loop is not implemented yet this returns [`Error::Platform`].
    pub fn open_at(&self, point: LogicalPoint, edge: Edge) -> Result<()> {
        let anchor = LogicalRect::new(point, LogicalSize::new(0.0, 0.0));
        let handler = |id: &MenuId| self.dispatch(id);
        platform::current().open_popup_session(
            self.menu.clone(),
            self.options.clone(),
            &handler,
            anchor,
            edge,
        )
    }
}

// =============================================================================
// Popup / Dropdown
// =============================================================================

/// A styled dropdown popup anchored to an **arbitrary caller rectangle** (e.g. a
/// toolbar button), rather than the tray icon or a bare point. It reuses the exact
/// `place_popup` math the tray uses; [`Tray`], [`ContextMenu`], and `Popup` differ
/// only in how the anchor rectangle is obtained, and all funnel into one shared
/// `PopupSession` (spec 01 §5.3, spec 20 §3).
pub struct Popup {
    menu: Menu,
    options: MenuOptions,
    on_click: Option<ClickHandler>,
    /// This surface's process-global identity (issue #51).
    surface_id: SurfaceId,
}

impl Popup {
    /// Create a dropdown popup from a [`Menu`].
    pub fn new(menu: Menu) -> Self {
        Popup {
            menu,
            options: MenuOptions::default(),
            on_click: None,
            surface_id: SurfaceId::next(),
        }
    }

    /// This surface's process-global [`SurfaceId`] (issue #51), assigned once
    /// in [`Popup::new`].
    pub fn surface_id(&self) -> SurfaceId {
        self.surface_id
    }

    /// Set popup options (width bounds, theme source).
    pub fn options(mut self, options: MenuOptions) -> Self {
        self.options = options;
        self
    }

    /// Register a click handler invoked with the activated row's [`MenuId`].
    pub fn on_click(mut self, handler: impl Fn(&MenuId) + Send + 'static) -> Self {
        self.on_click = Some(Box::new(handler));
        self
    }

    /// Borrow the menu.
    pub fn menu(&self) -> &Menu {
        &self.menu
    }

    /// Borrow the options.
    pub fn menu_options(&self) -> &MenuOptions {
        &self.options
    }

    /// Build the [`AxTree`] the platform screen reader walks over this menu.
    pub fn accessibility_tree(&self) -> AxTree {
        a11y::build_tree(&self.menu)
    }

    /// Dispatch a click to the registered handler, if any. Fires the `on_click`
    /// closure first, then projects the activation onto the global
    /// [`MenuEvent`] channel (spec 03 §3) — so a `Popup` is a first-class event
    /// source alongside [`Tray`] and [`ContextMenu`], as
    /// [`MenuEvent::receiver`](crate::event) promises. An inert
    /// [`MenuId::none`] fires neither.
    pub fn dispatch(&self, id: &MenuId) {
        if id.is_none() {
            return;
        }
        if let Some(handler) = &self.on_click {
            handler(id);
        }
        event::emit(id.clone(), self.surface_id);
    }

    /// Show the popup anchored to `anchor` (a caller rectangle in screen logical
    /// coordinates), growing from `edge`, and block until it is dismissed.
    ///
    /// This is the tray's session anchored to an arbitrary rect instead of the
    /// tray icon (spec 20 §3). On platforms whose styled popup loop is not
    /// implemented yet this returns [`Error::Platform`].
    pub fn anchored_to(&self, anchor: LogicalRect, edge: Edge) -> Result<()> {
        let handler = |id: &MenuId| self.dispatch(id);
        platform::current().open_popup_session(
            self.menu.clone(),
            self.options.clone(),
            &handler,
            anchor,
            edge,
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;

    #[test]
    fn tray_builder_stores_configuration() {
        let tray = Tray::new(Icon::Checkmark)
            .tooltip("usagio")
            .title("45%")
            .menu(Menu::new().row(Row::new("quit").label("Quit")))
            .theme(ThemeSource::System(ThemeMode::Dark));
        assert_eq!(tray.tooltip_text(), Some("usagio"));
        assert_eq!(tray.title_text(), Some("45%"));
        assert_eq!(tray.current_menu().len(), 1);
        assert!(matches!(
            tray.menu_options().theme,
            ThemeSource::System(ThemeMode::Dark)
        ));
    }

    #[test]
    fn tray_handle_setters_post_the_matching_command() {
        // Guards the mechanism every facade setter relies on: a TrayHandle setter
        // must post the *right* TrayCommand with the right payload — facade unit
        // tests can't catch a swap headlessly since no OS backend drains the queue.
        let tray = Tray::new(Icon::Checkmark);
        let handle = tray.handle();
        handle.set_title(Some("45%"));
        handle.set_tooltip(Some("tip"));
        handle.set_visible(false);
        let posted = handle.take_posted();
        assert!(
            matches!(&posted[0], TrayCommand::SetTitle(Some(s)) if s == "45%"),
            "got {:?}",
            posted.first()
        );
        assert!(
            matches!(&posted[1], TrayCommand::SetTooltip(Some(s)) if s == "tip"),
            "got {:?}",
            posted.get(1)
        );
        assert!(matches!(&posted[2], TrayCommand::SetVisible(false)));
    }

    #[test]
    fn tray_title_defaults_none_and_set_title_replaces_it() {
        // A tray with no title (the macOS menu-bar text) reports None; the
        // runtime setter replaces and clears it (issue #8 part 3).
        let mut tray = Tray::new(Icon::Checkmark);
        assert_eq!(tray.title_text(), None);
        tray.set_title(Some("12%".to_owned()));
        assert_eq!(tray.title_text(), Some("12%"));
        tray.set_title(None);
        assert_eq!(tray.title_text(), None);
    }

    #[test]
    fn tray_dispatch_invokes_handler_with_id() {
        let _guard = crate::event::test_lock();
        let seen = Arc::new(AtomicUsize::new(0));
        let seen2 = Arc::clone(&seen);
        let tray = Tray::new(Icon::Checkmark).on_click(move |id| {
            if id.as_str() == "quit" {
                seen2.fetch_add(1, Ordering::SeqCst);
            }
        });
        tray.dispatch(&MenuId::from("quit"));
        tray.dispatch(&MenuId::from("other"));
        assert_eq!(seen.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn dispatch_fires_closure_before_channel_and_skips_inert() {
        let _guard = crate::event::test_lock();
        use std::sync::atomic::AtomicBool;
        use std::sync::Mutex;

        // Drain any stragglers so the in-closure peek below sees only our event.
        while MenuEvent::receiver().try_recv().is_ok() {}

        let log = Arc::new(Mutex::new(Vec::<String>::new()));
        // Whether the activation is already on the global channel at the moment
        // the closure runs. Under correct order (closure first) it must be false;
        // a reversed emit-then-closure implementation would flip this flag.
        let seen_on_channel_in_closure = Arc::new(AtomicBool::new(false));
        let log2 = Arc::clone(&log);
        let flag2 = Arc::clone(&seen_on_channel_in_closure);
        let tray = Tray::new(Icon::Checkmark).on_click(move |id| {
            if let Ok(ev) = MenuEvent::receiver().try_recv() {
                if ev.id == MenuId::from("m3_order_probe") {
                    flag2.store(true, Ordering::SeqCst);
                }
            }
            log2.lock()
                .unwrap()
                .push(format!("closure:{}", id.as_str()))
        });

        // An inert `MenuId::none()` fires neither the closure nor the channel.
        tray.dispatch(&MenuId::none());
        assert!(
            log.lock().unwrap().is_empty(),
            "inert id must not fire the closure"
        );

        tray.dispatch(&MenuId::from("m3_order_probe"));
        log.lock().unwrap().push("after_dispatch".to_string());

        assert!(
            !seen_on_channel_in_closure.load(Ordering::SeqCst),
            "channel must still be empty while the closure runs (closure-first order)"
        );

        let mut found = false;
        while let Ok(ev) = MenuEvent::receiver().try_recv() {
            if ev.id == MenuId::from("m3_order_probe") {
                found = true;
                break;
            }
        }
        assert!(
            found,
            "activation must be projected onto the global channel after the closure"
        );

        let log = log.lock().unwrap();
        assert_eq!(log[0], "closure:m3_order_probe");
        assert_eq!(log[1], "after_dispatch");
    }

    #[test]
    fn context_menu_dispatch_works() {
        let _guard = crate::event::test_lock();
        let hit = Arc::new(AtomicUsize::new(0));
        let hit2 = Arc::clone(&hit);
        let cm = ContextMenu::new(Menu::new()).on_click(move |_| {
            hit2.fetch_add(1, Ordering::SeqCst);
        });
        cm.dispatch(&MenuId::from("x"));
        assert_eq!(hit.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn main_thread_marker_new_always_returns_some() {
        // Documents the intentional "no portable safe runtime check" contract
        // (issue #46): see the type-level compile_fail doctest for the
        // !Send/!Sync half of the guarantee.
        assert!(MainThreadMarker::new().is_some());
    }

    #[test]
    fn surface_id_is_unique_and_monotonic_across_surface_kinds() {
        // issue #51: every Tray/ContextMenu/Popup gets its own id, in
        // creation order, from the same process-global counter.
        let a = Tray::new(Icon::Checkmark).surface_id();
        let b = ContextMenu::new(Menu::new()).surface_id();
        let c = Popup::new(Menu::new()).surface_id();
        let d = Tray::new(Icon::Checkmark).surface_id();
        assert_ne!(a, b);
        assert_ne!(b, c);
        assert_ne!(a, d);
        assert!(b.0 > a.0);
        assert!(c.0 > b.0);
        assert!(d.0 > c.0);
    }

    #[test]
    fn tray_handle_drop_posts_shutdown_once_on_last_clone_only() {
        // issue #47: only the last outstanding clone of a handle family shuts
        // the tray down, exactly once. `inspector` is a separate handle family
        // (its own `handle()` call) used purely to observe posts without
        // counting toward `family`'s clone count.
        let tray = Tray::new(Icon::Checkmark);
        let family = tray.handle();
        let clone = family.clone();
        let inspector = tray.handle();

        drop(clone);
        assert!(
            inspector.take_posted().is_empty(),
            "an intermediate clone dropping must not post Shutdown"
        );

        drop(family);
        let posted = inspector.take_posted();
        assert!(
            matches!(posted.as_slice(), [TrayCommand::Shutdown]),
            "the last clone dropping must post exactly one Shutdown, got {:?}",
            posted
        );
    }

    #[test]
    fn concurrent_last_clone_drops_post_shutdown_exactly_once() {
        // Race regression: the last two clones dropped concurrently on two
        // threads must still post exactly ONE Shutdown. The `Arc<HandleFamily>`
        // drop-guard makes this exactly-once regardless of interleaving (issue #47).
        let tray = Tray::new(Icon::Checkmark);
        let inspector = tray.handle(); // separate family; only observes the queue
        let h1 = tray.handle();
        let h2 = h1.clone();
        let t1 = std::thread::spawn(move || drop(h1));
        let t2 = std::thread::spawn(move || drop(h2));
        t1.join().unwrap();
        t2.join().unwrap();
        let posted = inspector.take_posted();
        let shutdowns = posted
            .iter()
            .filter(|c| matches!(c, TrayCommand::Shutdown))
            .count();
        assert_eq!(
            shutdowns, 1,
            "the family's final drop must post exactly one Shutdown, got {posted:?}"
        );
    }

    #[test]
    fn set_theme_and_options_post_live_swap_commands() {
        // The in-menu "Preview theme" path (#45): a handle posts SetTheme /
        // SetOptions, which the backend applies on its UI thread.
        let tray = Tray::new(Icon::Checkmark);
        let inspector = tray.handle();
        let control = tray.handle();
        control.set_theme(ThemeSource::MacOs(ThemeMode::Dark));
        control.set_options(MenuOptions::default().min_width(120.0));
        let posted = inspector.take_posted();
        assert!(
            matches!(
                posted.as_slice(),
                [TrayCommand::SetTheme(_), TrayCommand::SetOptions(_)]
            ),
            "set_theme/set_options must post the matching commands in order, got {:?}",
            posted
        );
    }

    #[test]
    fn anchor_rect_query_times_out_to_none_with_no_live_backend() {
        // With no run loop draining the queue, the QueryAnchorRect reply never
        // arrives, so the best-effort cross-thread query returns None (#48).
        let tray = Tray::new(Icon::Checkmark);
        let handle = tray.handle();
        assert!(handle.anchor_rect().is_none());
        // Prove it actually *posted* the query (not merely returned a stubbed
        // None): a QueryAnchorRect command must be sitting in the queue.
        let posted = handle.take_posted();
        assert!(
            posted
                .iter()
                .any(|c| matches!(c, TrayCommand::QueryAnchorRect(_))),
            "anchor_rect must post a QueryAnchorRect command, got {posted:?}"
        );
    }
}