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
# 01 — API contract: muri's native, styleable public API

Status: **foundational spec — the north star.** This document specifies muri's
**own** public API. Per the pivot (locked decision #1, [`00`](00-overview.md) §2/§4),
the native API is the **primary, first-class** surface — designed for a live menu
app, not derived from muda. The muda-compat drop-in is a first-class on-ramp that
maps **1:1 onto this API** ([`02-muda-compat.md`](02-muda-compat.md)); threading and
event semantics are [`03-threading-events-versioning.md`](03-threading-events-versioning.md).

Signatures below are grounded in the shipped crate. The 1.0 native API adds a
fluent, **owned `Menu::new().add(…)` builder** with typed item kinds, per-item
styling, native typed events, N-level submenus, and an extension seam; where this
differs from the shipped `Row`-centric model it is marked **1.0-change** and the
migration is noted. Where a type is **shipped** it is quoted as-is; **1.0-new** marks
additions.

---

## 1. Design principles

1. **The data model is the product.** `Menu` / `Item` / `Row` / `Segment` is a
   pure, backend-agnostic, `Clone`+`Debug` tree with no I/O. A consumer builds it,
   hands it to a surface, and muri draws it. The same tree drives rendering, the
   hit-test map, keyboard nav, and the accessibility tree — so it must be
   expressive enough to be *all* of those at once.
2. **Semantic-by-default, literal-when-you-want.** Colors and fonts default to
   *semantic* roles that resolve against the active `Theme` (and to live OS colors
   under `FollowSystem`), so a menu looks native without the consumer choosing a
   single hex value. Literal `Color::Rgba` / named fonts are always available.
3. **Builders are consuming (`self`-taking) and cheap.** Every builder method
   takes `self` and returns `Self`, so construction is a fluent expression. The
   tree is plain data (`pub` fields) so a consumer can also mutate it directly.
4. **Opaque `MenuId` strings.** muri hands a row's id back verbatim on activation
   and never interprets it. The id *grammar* is entirely the consumer's business.
5. **The flexibility unlock is progressive.** Facade → `Theme::native()` (looks
   native) → `ThemeSource::Custom(Theme)` (restyle globally) → per-`Segment`
   `Flex`/`Align`/`Color`/`Font` (restyle per row). No cliff between "muda-like"
   and "fully custom."

## 2. The declarative menu tree and the `add(…)` builder

All types in `muri::menu`, re-exported at the crate root. The **1.0 primary surface**
is a fluent, owned `Menu::new().add(…)` builder; the north-star shape (owner's
sketch):

```rust
Menu::new()
    .add(text("Text row"))
    .add(submenu("Sub Menu")
        .add(text("SubMenu Text 1"))
        .add(separator())
        .add(image("logo.png"))
        .add(text("Quit").color(Color::Red).align(Align::Right).on(|| quit())));
```

### 2.1 `Menu` and the one stable door (`add`)

```rust
pub struct Menu { /* items + a MenuId→handler registry (see §6) */ }

impl Menu {
    pub fn new() -> Self;
    pub fn add(self, item: impl Into<Item>) -> Self;   // 1.0-new — THE stable door
    pub fn len(&self) -> usize;
    pub fn is_empty(&self) -> bool;
    pub fn interactive_count(&self) -> usize;          // focusable items at this level
    pub fn items(&self) -> &[Item];                    // pure data view (layout/a11y/keynav)
}
```

`add(impl Into<Item>)` accepts **any** item kind — `Text`, `Image`, `Separator`,
`Submenu`, `Check`, `Predefined`, or a `Custom` node — via `Into<Item>`. It is
deliberately the *only* growth point: new content kinds (1.x) plug into the same
`add` (§9, [`00` §11](00-overview.md)). N-level nesting is **inherent**: a `Submenu`
is itself `.add()`-able into a `Submenu`.

**1.0-change (migration from the shipped `Row` model).** The shipped crate models
`Menu { pub items: Vec<Item> }` with `Item::{Row, Separator, SectionHeader, Submenu}`
and `Menu::{item,row,separator,section_header,submenu}` builders. 1.0 keeps that
`Vec<Item>` **data representation** (so the pure layout/keynav/a11y core is
unchanged) but reshapes the *public builder* to the typed-item `add(…)` form above.
The shipped `row`/`item`/`submenu` methods and the `Row`/`Segment` primitives are
**retained as the lower-level API** (§2.4); the typed builders (`Text`, `Submenu`,
…) are ergonomic constructors over them. Migration is mechanical and additive.

### 2.2 `Item` — the open kind set

```rust
#[non_exhaustive]                       // 1.0-change — future kinds are additive
pub enum Item {
    Text(Text),                         // a label row (the common case)
    Image(Image),                       // an image/logo row
    Separator,                          // a divider
    SectionHeader(Text),                // styled, non-interactive heading
    Submenu(Submenu),                   // opens a flyout panel beside it (N-level)
    Check(Check),                       // a checkable row
    Predefined(Predefined),             // a muda-style OS-action item (§ compat)
    Custom(Box<dyn MenuNode>),          // 1.0-new — third-party / future rows (§9)
}

impl Item {
    pub fn is_interactive(&self) -> bool;   // enabled rows/submenus/checks with a real id
}
```

`Item` is `#[non_exhaustive]` so promoting a future built-in (e.g. `Video`) is not a
breaking change ([`00` §11](00-overview.md)). `interactive_count` / `is_interactive`
are the single source of truth for "what can be focused/clicked," consumed by both
`keynav` and `a11y` — a separator, a section header, a disabled row, and an inert
info row (`MenuId::none()`) are all non-interactive.

### 2.3 Typed item builders + free-function constructors (1.0-new)

Each kind has a fluent builder with per-item modifiers, and a lowercase free-function
constructor as an alternative to `::new` (idiomatic-Rust ergonomics):

```rust
pub fn text(s: impl Into<String>) -> Text;         // == Text::new(s)
pub fn image(src: impl Into<Icon>) -> Image;       // == Image::new(src)
pub fn submenu(label: impl Into<String>) -> Submenu;
pub fn separator() -> Item;                          // == Item::Separator
pub fn check(label: impl Into<String>) -> Check;

pub struct Text { /* segments, id, enabled, on-handler … */ }
impl Text {
    pub fn new(s: impl Into<String>) -> Self;
    pub fn id(self, id: impl Into<MenuId>) -> Self;   // else an internal id is assigned
    pub fn color(self, c: Color) -> Self;             // per-item color (headline advantage)
    pub fn align(self, a: Align) -> Self;             // Left | Center | Right
    pub fn bold(self) -> Self;                        // == .weight(Weight::Bold)
    pub fn font(self, f: Font) -> Self;
    pub fn icon(self, icon: impl Into<Icon>) -> Self; // leading icon/logo
    pub fn trailing(self, icon: impl Into<Icon>) -> Self;
    pub fn enabled(self, yes: bool) -> Self;
    pub fn accelerator(self, a: Accelerator) -> Self; // displayed + in-menu (doc 40 §3)
    pub fn segment(self, s: Segment) -> Self;         // advanced multi-column (§2.4)
    pub fn on(self, f: impl FnMut() + Send + 'static) -> Self;  // native click (§6)
}
// From<Text>/From<Image>/From<Submenu>/From<Check>/From<Predefined> for Item;
// From<&str>/From<String> for Text (so .add("Quit") works).

pub struct Submenu { /* label: Text, menu: Menu */ }
impl Submenu {
    pub fn new(label: impl Into<String>) -> Self;
    pub fn add(self, item: impl Into<Item>) -> Self;  // nest (N-level, inherent)
    pub fn icon(self, icon: impl Into<Icon>) -> Self;
    pub fn enabled(self, yes: bool) -> Self;
    pub fn id(self, id: impl Into<MenuId>) -> Self;
}

pub struct Check { /* like Text + checked: bool */ }
impl Check {
    pub fn new(label: impl Into<String>) -> Self;
    pub fn checked(self, yes: bool) -> Self;
    // + the same color/align/enabled/on/… modifiers as Text
}

pub struct Image { /* icon + optional id/on */ }
impl Image { pub fn new(src: impl Into<Icon>) -> Self; pub fn on(self, f: impl FnMut()+Send+'static) -> Self; /* … */ }
```

Per-item modifiers (`.color`/`.align`/`.icon`/`.bold`/`.enabled`/`.checked`/
`.accelerator`/`.on`) apply uniformly to the built-in kinds and, where meaningful, to
`Custom` nodes (§9). **Per-item styling is muri's headline advantage over muda** — a
native menu cannot color one value red or right-align it — so it is first-class on
the common `Text`/`Check` row, not buried in a `Segment` API.

### 2.3.1 Each muda item kind maps 1:1 onto a native constructor

Because the native kinds were designed to *cover* muda's, the compat wrapper is
mechanical — it never needs anything but these built-in constructors:

| muda | muri native constructor |
|---|---|
| `MenuItem::new(text, enabled, accel)` | `text(text).enabled(..).accelerator(..)` |
| `CheckMenuItem::new(text, .., checked, ..)` | `check(text).checked(..)` |
| `IconMenuItem::new(text, icon, ..)` | `text(text).icon(..)` (or `image(..)`) |
| `Submenu::new(text, enabled)` + children | `submenu(text).enabled(..).add(..)…` |
| `PredefinedMenuItem::separator()` | `separator()` |
| `PredefinedMenuItem::quit()/copy()/about()/…` | `Predefined::quit()/copy()/about()/…` |
| `MenuId` / `with_id` | `.id(..)` (same `MenuId(String)`) |
| accelerator | `.accelerator(Accelerator)` |

The compat layer ([`02`](02-muda-compat.md)) is thus a thin translation onto these
constructors; it never touches the `MenuNode` extension seam (§6.5).

### 2.4 The lower-level `Row` / `Segment` primitive (retained)

`Text` is sugar over a single-segment row; the multi-column, flush-right primitive
(`Row` + `Segment`, §2.5 below and §`10`) is retained for rows that need several
independently-aligned columns (a `Flex::Grow` label + an `Align::Right` value). A
`Text` with more than one `.segment(…)` produces exactly such a `Row`. The `Row`
data type (below) is what the pure layout/hit-test/a11y core consumes; the typed
builders are constructors over it.

### 2.5 `Row` (the lower-level row data type, retained)

```rust
pub struct Row {
    pub id: MenuId,                  // MenuId::none() ⇒ inert (info rows, headers)
    pub segments: Vec<Segment>,      // laid out left→right across columns
    pub leading: Option<Icon>,       // logo / avatar / checkmark column
    pub trailing: Option<Icon>,
    pub enabled: bool,               // default true
    pub checked: Option<bool>,       // Some ⇒ check column; None ⇒ no column
    pub background: Option<Color>,
    pub min_height: Option<f32>,
}

impl Row {
    pub fn new(id: impl Into<MenuId>) -> Self;   // enabled, no segments
    pub fn info() -> Self;                        // id = none(); for info lines/headers
    pub fn label(self, text: impl Into<String>) -> Self;   // append a plain left segment
    pub fn segment(self, segment: Segment) -> Self;
    pub fn segments(self, segments: Vec<Segment>) -> Self;
    pub fn leading(self, icon: Icon) -> Self;
    pub fn trailing(self, icon: Icon) -> Self;
    pub fn enabled(self, enabled: bool) -> Self;
    pub fn checked(self, checked: bool) -> Self;
    pub fn background(self, color: Color) -> Self;
    pub fn min_height(self, height: f32) -> Self;
    pub fn accessible_name(&self) -> String;      // segment texts joined by spaces
}
```

`accessible_name()` is the a11y and type-ahead name; it is **not** an override
point in 1.0 (the name is derived from segment text). **1.0-new opportunity:** an
optional `accessibility_label: Option<String>` for rows whose visible text is not
their best spoken name (e.g. a row that is only an icon). Flagged for
[`30-accessibility.md`](30-accessibility.md) to decide; deferring it is acceptable
for 1.0 as long as icon-only rows are rare.

### Segment, StyleRun, and the flush-right guarantee

```rust
pub struct Segment {
    pub text: String,
    pub runs: Vec<StyleRun>,   // per-substring color/weight spans; empty ⇒ whole-segment
    pub align: Align,          // Left (default) | Center | Right
    pub flex: Flex,            // Fixed (default) | Grow
    pub font: Option<Font>,    // else the row/theme font
    pub color: Option<Color>,  // else Color::Label
}
impl Segment {
    pub fn new(text: impl Into<String>) -> Self;
    pub fn align(self, align: Align) -> Self;
    pub fn flex(self, flex: Flex) -> Self;
    pub fn runs(self, runs: Vec<StyleRun>) -> Self;
    pub fn font(self, font: Font) -> Self;
    pub fn color(self, color: Color) -> Self;
}

pub struct StyleRun { pub start: usize, pub len: usize, pub color: Color, pub weight: Option<Weight> }
// start/len are UTF-16 code units — matches NSRange and usagio's existing spans.
impl StyleRun {
    pub fn new(start: usize, len: usize, color: Color) -> Self;
    pub fn weight(self, weight: Weight) -> Self;
}

pub enum Align { Left, Center, Right }   // default Left
pub enum Flex  { Fixed, Grow }           // default Fixed
```

**The core guarantee (muri's whole reason to exist):** a `Flex::Grow` label
followed by an `Align::Right` value produces a **truly flush-right value with no
reserved chevron/submenu column**. The layout engine (`layout::resolve_segments`,
[`10-rendering-layout.md`](10-rendering-layout.md)) gives all leftover row width to
the `Grow` segment and right-aligns the value against the band edge. Native menus
cannot do this because they always reserve a submenu column; muri can because it
owns the pixels. This is contractually part of the API: a consumer relying on it
is relying on a guarantee, not an accident.

**UTF-16 offset caveat (1.0 gate for doc 10):** `StyleRun` offsets are UTF-16 code
units. `render::paint::style_pieces` currently walks `char`s accumulating
`len_utf16()` to slice pieces — correct for LTR. Under RTL/BiDi reordering these
offsets must map to the *logical* string, not the visual order; 1.0 must confirm
`cosmic-text` shaping preserves this or document the limitation. See
[`10-rendering-layout.md`](10-rendering-layout.md).

## 3. Style primitives

All in `muri::style`.

```rust
pub enum Color {                          // literal OR semantic (theme/OS-resolved)
    Rgba(u8, u8, u8, u8),
    Label, SecondaryLabel, Accent, Separator,
    SystemRed, SystemOrange, SystemGreen, SystemYellow,
}
impl Color { pub fn rgb(r,g,b) -> Self; pub fn is_literal(&self) -> bool; }

pub struct Rgba { pub r: u8, pub g: u8, pub b: u8, pub a: u8 }   // fully resolved
// consts BLACK/WHITE/TRANSPARENT; new(r,g,b,a); opaque(r,g,b)

pub enum FontFamily { System, SystemMono, Named(String) }        // default System
pub enum Weight { Regular, Medium, Semibold, Bold }              // ot_weight()->u16
pub struct Font { pub family: FontFamily, pub size: f32, pub weight: Weight }
impl Font { pub fn system(size,weight); pub fn mono(size,weight); pub fn with_weight(self,w); }
// Font::default() = System 13.0 Regular

pub enum Icon {
    Png(Arc<[u8]>),     // decoded at draw (tiny-skia); shipped-drawn
    Svg(Arc<[u8]>),     // rasterize per-DPI — 1.0-new (not yet drawn)
    Checkmark,          // themed check glyph in the leading column; shipped-drawn
    Symbol(&'static str),  // SF Symbol on macOS, bundled fallback elsewhere — 1.0-new
}
impl Icon { pub fn from_png_bytes(bytes: impl Into<Arc<[u8]>>); pub fn from_svg_bytes(...); }
```

**Fidelity status the API must not overstate:** today the drawer renders
`Icon::Png` and `Icon::Checkmark` (and `checked == Some(true)` without a leading
icon draws a checkmark). `Icon::Svg` and `Icon::Symbol` are *accepted by the type*
but **not yet drawn** (`render::paint::draw_row_content` skips them). 1.0 must
either draw them or the facade/docs must be explicit. `Icon` uses `Arc<[u8]>` so
icons are cheap to clone across the ~0.75s menu rebuilds a consumer like usagio
does.

## 4. Theming

All in `muri::theme`.

```rust
pub enum ThemeSource { FollowSystem, Light, Dark, Custom(Theme) }   // default FollowSystem
impl ThemeSource {
    pub fn wants_dark(&self, system_dark: impl Fn() -> bool) -> bool;
    pub fn resolve_theme(&self, system_is_dark: bool) -> Theme;
}

pub struct Theme {
    pub background, label, secondary_label, accent, separator, row_highlight: Color,
    pub row_font, header_font: Font,
    pub row_height, corner_radius, column_gap: f32,
    pub padding: Insets,
}
impl Theme {
    pub fn light() -> Self;
    pub fn dark() -> Self;
    pub fn native() -> Self;                     // 1.0-new — see below
    pub fn resolve(&self, color: Color) -> Rgba; // semantic → concrete
}

pub struct MenuOptions { pub min_width: Option<f32>, pub max_width: Option<f32>, pub theme: ThemeSource }
```

### `Theme::native()` — the muda-parity default (1.0-new)

Locked decision #1 requires a `Theme::native()` that makes a facade-built menu
look native. Specification:

- `Theme::native()` returns a theme whose color fields are the **semantic roles
  themselves** (`background = Color::Label`? no — `label = Color::Label`,
  `accent = Color::Accent`, `separator = Color::Separator`, `row_highlight =
  Color::Accent`, `background` = a semantic `Color::MenuBackground`), so that at
  draw time every color resolves to the **live OS color** (NSColor / UISettings)
  rather than a baked palette. Fonts are `FontFamily::System` at the platform
  default menu size. Spacing/radius match the platform menu metrics.
- **1.0-new semantic colors required:** the shipped `Color` enum lacks a
  menu-background role and control colors. 1.0 adds at minimum
  `Color::MenuBackground` (resolves to `NSColor.controlBackgroundColor` /
  `menu` background per OS) so `Theme::native()` can defer the background to the OS
  too. Without it, `Theme::light()/dark()` bake `rgb(246,246,246)` / `rgb(40,40,40)`
  which is *close* but not the exact OS menu material (no vibrancy/translucency).
- **Vibrancy is required (locked decision #6).** `Theme::native()` renders with
  **real translucent vibrancy**, not an opaque approximation: on macOS the raster
  layer is hosted over an `NSVisualEffectView` so the panel is a genuine blurred
  material; the Windows equivalent is **acrylic**. This is not a future
  enhancement — it is a 1.0 promise, because "looks native" is load-bearing for the
  muda drop-in. It forces a transparency/compositing rework (the drawer must present
  alpha-preserving output; the panel background draws at reduced alpha or is skipped
  so the effect view shows through) specified in
  [`10-rendering-layout.md`]10-rendering-layout.md §11 and
  [`20-platform-macos.md`]20-platform-macos.md §2. On Linux the styled surface is
  opaque (compositor-owned blur is not client-controllable) and the native-menu
  fallback is host-drawn — the one place vibrancy does not apply.

`ThemeSource::FollowSystem` (the default `MenuOptions.theme`) already resolves to
`Theme::light()` / `Theme::dark()` by querying `system_is_dark`. **1.0-change:**
`FollowSystem` should resolve to `Theme::native()`'s light/dark variants so the
default look is OS colors, and the facade sets `MenuOptions { theme:
ThemeSource::FollowSystem, .. }` implicitly. The existing pure `resolve_theme` test
seam (a fixed bool) is preserved.

**Custom-theme dark-mode gap (shipped bug to fix in 1.0):**
`ThemeSource::Custom(theme)` ignores `system_is_dark` — a custom theme is a single
fixed look. That is fine for a consumer that wants one look, but a consumer that
wants "my colors, but follow dark/light" has no path. 1.0 should add either
`ThemeSource::CustomFollowSystem { light: Theme, dark: Theme }` or a
`Theme::variant(dark: bool)` hook. Flagged; not blocking M1–M2.

## 5. Surfaces

muri's surfaces are the objects that put a `Menu` on screen. All carry the `Menu`'s
per-item `.on()` handlers plus an optional surface-level `.on_event(&MenuId)` sink
(§6), and expose `accessibility_tree()`.

### 5.1 `Tray` + `TrayHandle` (the live-app model — primary)

Owns the tray icon + the tray-anchored popup + anchoring. Subsumes `tray-icon`. The
**retained handle is the primary runtime model** (locked decision #1): muri is built
for a live app that rewrites its menu ~0.75s, so updating the menu *while the loop
runs* is a first-class path, not an afterthought.

```rust
pub struct Tray { /* icon, menu, tooltip, options */ }
impl Tray {
    pub fn new(icon: impl Into<Icon>) -> Self;
    pub fn menu(self, menu: Menu) -> Self;
    pub fn tooltip(self, text: impl Into<String>) -> Self;
    pub fn options(self, options: MenuOptions) -> Self;
    pub fn theme(self, theme: ThemeSource) -> Self;             // convenience for options.theme
    pub fn accessibility_tree(&self) -> AxTree;
    pub fn dispatch(&self, id: &MenuId);                        // testable click path
    pub fn handle(&self) -> TrayHandle;   // 1.0-new — Clone+Send; valid to clone before run()
    pub fn run(self) -> Result<()>;       // install icon + run event loop; consumes self
}

// 1.0-new — the retained, thread-safe handle for live updates (doc 03 §2)
pub struct TrayHandle { /* posts commands into the running loop via EventLoopProxy */ }
impl TrayHandle {                          // Clone + Send
    pub fn set_menu(&self, menu: Menu);    // swap content; next paint re-renders
    pub fn set_icon(&self, icon: impl Into<Icon>);
    pub fn set_tooltip(&self, text: impl Into<String>);
    pub fn set_visible(&self, visible: bool);
    pub fn open(&self);
    pub fn close(&self);
}
```

Behavior contract:

- **The retained-handle pattern (the resolution of the old `run(self)` tension).**
  Obtain a `TrayHandle` from `tray.handle()` **before** `run` takes over, keep it,
  and call `handle.set_menu(new_menu)` / `set_icon` / … from any thread as the app's
  state changes. The handle posts commands into the UI loop (via the `winit`
  `EventLoopProxy` the macOS backend already uses), which applies them on the UI
  thread (rebuild `LaidMenu`, re-render). `TrayHandle` is `Clone + Send`. This is
  **load-bearing for any live app** and an M1 deliverable, not 1.0 polish
  ([`03`]03-threading-events-versioning.md §2). It also cleanly replaces the muda
  drop-in tension: a native consumer holds a handle; a *compat* consumer keeps its
  own passive loop and never sees `run`/`TrayHandle` ([`02`]02-muda-compat.md).
- **`run` consumes `self` and blocks** running the platform event loop. On macOS it
  must be called on the main thread (`MainThreadMarker`); a **tray-only** native app
  sets `Accessory` activation policy (no Dock icon). The menu-bar-app conflict — an
  app that *also* installs a native menu bar cannot be `Accessory` — is decided by
  the surface combination (the compat layer sets `Regular` when `init_for_nsapp` is
  used); see [`20-platform-macos.md`]20-platform-macos.md §5.
- On **Linux**, `run` returns `Err(Unsupported::TrayAnchor)` (decision #3).
- **`open` / `close`** are `todo!()` today; 1.0 implements programmatic show/hide via
  `TrayHandle`.

### 5.2 `ContextMenu` (skeleton)

A styled menu at an explicit screen point. muri's **portable** primitive — works
on Linux via `xdg_positioner`.

```rust
pub struct ContextMenu { /* menu, options, on_click */ }
impl ContextMenu {
    pub fn new(menu: Menu) -> Self;
    pub fn options(self, options: MenuOptions) -> Self;
    pub fn on_click(self, handler: impl Fn(&MenuId) + Send + 'static) -> Self;
    pub fn accessibility_tree(&self) -> AxTree;
    pub fn dispatch(&self, id: &MenuId);
    pub fn open_at(&self, point: LogicalPoint, edge: Edge) -> Result<()>;   // todo! today
}
```

`open_at` is `todo!()` today; it is the M2 deliverable and the Linux styled path.
**Open question:** `open_at(&self, ...)` takes `&self` and must run/participate in
an event loop — on Linux/Wayland it needs the caller's own surface handle for
`xdg_positioner`. 1.0 must decide whether `ContextMenu` runs its own nested loop
or integrates with the caller's `winit` loop (see the `raw-window-handle` question
in [`22-platform-linux.md`](22-platform-linux.md)).

### 5.3 `Popup` / `Dropdown` (1.0-new)

The generic dropdown surface: a popup anchored to an **arbitrary caller
rectangle** (e.g. a toolbar button), not the tray icon. Reuses the exact
`place_popup` math the tray uses.

```rust
// 1.0-new
pub struct Popup { /* menu, options, on_click */ }
impl Popup {
    pub fn new(menu: Menu) -> Self;
    pub fn options(self, options: MenuOptions) -> Self;
    pub fn on_click(self, handler: impl Fn(&MenuId) + Send + 'static) -> Self;
    pub fn anchored_to(&self, anchor: LogicalRect, edge: Edge) -> Result<()>;
}
```

`Tray`, `ContextMenu`, and `Popup` differ only in *how the anchor rectangle is
obtained* (tray icon rect / a point / a caller rect); all three funnel into the
same `place_popup` + scene drawer + dismiss logic. 1.0 should factor a shared
`PopupSession` so the three surfaces are thin adapters, not three copies of the
macOS `App` event handler.

## 6. Event delivery — native first (decision #5)

muri emits its **own** native events (decision #5 — no dependency on the muda crate
for events). The native surface has two mechanisms; the muda-style global channel is
a **compat-only projection** of them, not the native model.

1. **Per-item `.on(FnMut)` callback (1.0-new, native primary).** `text("Quit").on(||
   quit())` attaches a typed handler to *that item*. It runs on the UI thread when
   the item is activated by mouse, keyboard (`NavAction::Activate`), or an AccessKit
   `Click`. Zero-arg — the item's identity is implicit. Inert items never fire.
2. **muri event stream keyed by id (1.0-new, native).** A surface-level stream /
   handler delivering the activated `MenuId`, for consumers who prefer a single
   sink over per-item closures: a surface `.on_event(impl FnMut(&MenuId))` (and a
   pollable `muri::events()` stream). This is muri's *own* stream — not muda's global
   channel.
3. **Global `MenuEvent` channel (compat projection):** the process-global
   `muri::MenuEvent::receiver() -> &'static MenuEventReceiver` delivering
   `MenuEvent { id }` exists so the **muda-compat** door works unchanged; muri
   *projects* native activations onto it. A native consumer need never touch it.
   See [`03`]03-threading-events-versioning.md §3 for ordering/threading.

**The purity tension and its resolution (1.0-change).** Attaching an `FnMut` to an
item would make the menu tree non-`Clone`/non-`Debug`, breaking the pure-core
testing invariant that layout / keynav / a11y / snapshot tests depend on
([`50`](50-testing-verification.md), [`03` §1](03-threading-events-versioning.md)).
So `.on(…)` does **not** store the closure in the data tree: the builder assigns the
item a `MenuId` (an internal one if the consumer gave none) and registers the handler
in the `Menu`/surface's **handler registry keyed by `MenuId`**. The `items(): &[Item]`
data view stays pure, `Clone + Debug`, and drives all the pure engine functions;
handlers are consulted only at dispatch. This keeps per-item `.on()` ergonomic
*and* the core testable.

`MenuEvent` and `MenuId`:

`MenuEvent` and `MenuId`:

```rust
pub struct MenuEvent { pub id: MenuId }
pub struct MenuId(pub String);
impl MenuId {
    pub fn none() -> Self;            // inert sentinel (empty string)
    pub fn as_str(&self) -> &str;
    pub fn is_none(&self) -> bool;
}
// From<&str>, From<String>
```

`MenuId` is deliberately `pub struct MenuId(pub String)` — **structurally
identical to muda's `MenuId`** — which is what lets the facade share ids without
conversion (decision #1). The empty-string `none()` sentinel is a muri addition
for inert rows and has no muda equivalent (muda auto-assigns ids); the facade
handles that mapping ([`02-muda-compat.md`](02-muda-compat.md) §3).

## 6.5 The `MenuNode` extension seam (1.0-new — ships in 1.0)

`add(…)` is the one stable door, and `Item::Custom(Box<dyn MenuNode>)` is how a
row that is *not* a built-in kind plugs into the **same** builder and the **same**
render / keynav / a11y pipeline. The trait ships in 1.0 even though the rich nodes
built on it (Video, etc.) are 1.x roadmap ([`00` §11](00-overview.md)):

```rust
// 1.0-new
pub trait MenuNode: Debug + Send + 'static {
    fn measure(&self, ctx: &MeasureCtx, constraints: LogicalSize) -> LogicalSize; // intrinsic layout
    fn draw(&self, scene: &mut dyn SceneDrawer, layout: LogicalRect, ctx: &DrawCtx); // into the tiny-skia scene
    fn hit_test(&self, layout: LogicalRect, point: LogicalPoint) -> bool;
    fn accessibility(&self) -> AxNodeSpec;   // role / name / state → the AxNode model (doc 30)
    fn is_interactive(&self) -> bool { false }
    fn id(&self) -> Option<&MenuId> { None }
}
```

- **Same pipeline.** `render_menu` measures/draws a `Custom` node through the same
  `SceneDrawer` ([`10`]10-rendering-layout.md §2); `keynav` treats it as focusable
  iff `is_interactive()`; `a11y::build_tree` folds its `accessibility()` into the
  tree ([`30`]30-accessibility.md). Per-item modifiers (`.on()`, `.enabled`) apply
  to a custom node where meaningful (a custom node with an id + `is_interactive` can
  carry an `.on()` handler).
- **Dyn dispatch is fine** — menus have few rows.
- **The compat layer never touches this seam.** muda's kinds map only onto the
  *built-in* nodes ([`02`]02-muda-compat.md); `Custom` is a native-API-only
  extension.

## 7. Geometry and errors

```rust
pub struct LogicalPoint { pub x: f32, pub y: f32 }
pub struct LogicalSize  { pub width: f32, pub height: f32 }
pub struct LogicalRect  { pub origin: LogicalPoint, pub size: LogicalSize }
    // max_x/max_y/contains
pub enum Edge { Bottom, Top, Left, Right }   // grow direction; default Bottom
pub struct Insets { pub top,right,bottom,left: f32 }   // uniform/symmetric/horizontal/vertical

pub enum Unsupported { TrayAnchor, ClientPositioning }
pub enum Error { Unsupported(Unsupported), BadIcon(String), Platform(String) }
pub type Result<T> = std::result::Result<T, Error>;
```

All muri coordinates are **logical** (DPI-independent); the drawer owns
device-scale conversion. `LogicalRect` origin is top-left (the macOS backend
flips `NSStatusItem`'s bottom-left frame into this space). Errors are a small
closed set; the facade maps muda's richer `Error` onto these
([`02-muda-compat.md`](02-muda-compat.md) §7).

## 8. Ownership and lifetimes

- **The menu tree is owned data** (`Clone + Debug`, `'static`). No borrows leak
  into it; `Icon` holds `Arc<[u8]>`. A consumer can build a `Menu`, clone it, hand
  it to a surface, and keep or drop its copy freely.
- **Handlers live in a `MenuId`-keyed registry, not in the data tree.** Per-item
  `.on(FnMut + Send + 'static)` handlers (and a surface `.on_event`) are owned by the
  surface's handler registry keyed by `MenuId` (§6), so the `Item` data tree stays
  pure `Clone + Debug` for the engine and tests. Handlers are `Send` (movable to the
  UI thread) and `'static`; a handler needing shared state uses interior mutability
  (`Arc<Mutex<_>>` / atomics), as the shipped tests do.
- **Surfaces are not `Send`/`Sync`** once running — they own platform windows and
  must live on the UI thread. Runtime interaction from other threads goes through the
  **`TrayHandle`** (write side — live menu/icon updates) and muri's native event
  stream / the compat global channel (read side), never by moving the surface.
- **The a11y tree (`AxTree`) is a snapshot**, rebuilt from the menu on demand;
  it borrows nothing and is safe to build off the UI thread for testing.

## 9. What the public surface is (and is not)

The complete 1.0 public export set: the surfaces `Tray`, `TrayHandle`,
`ContextMenu`, `Popup`; the builder `Menu` + `add`; the item kinds and their
builders `Item`, `Text`, `Image`, `Submenu`, `Check`, `Predefined`, and the
`MenuNode` trait; the free-function constructors `text`, `image`, `submenu`,
`separator`, `check`; the lower-level `Row`, `Segment`, `StyleRun`; the style
primitives `Align`, `Flex`, `Color`, `Rgba`, `Font`, `FontFamily`, `Weight`, `Icon`;
the accelerator model `Accelerator`, `Modifiers`, `Code` (1.0-new, [`40`](40-input-interaction.md));
theming `Theme`, `ThemeSource`, `MenuOptions`, `Insets`; geometry `Edge`,
`LogicalPoint`, `LogicalSize`, `LogicalRect`; events `MenuId`, `MenuEvent`,
`MenuEventReceiver`; errors `Error`, `Unsupported`, `Result`; plus the `compat`
module (doc 02) and the lower-level `a11y` / `keynav` / `anchor` / `flyout` /
`layout` / `render` modules (exposed for advanced integration and testing,
semver-covered but lower-churn-risk-documented in doc 03).

Nothing consumer-specific leaks in: no usagio id grammar, no `switch:`/`capture:`
strings, no snapshot types. muri knows only `Menu` and opaque `MenuId`s.