muri/menu.rs
1//! The declarative menu data model: identifiers and events, icons, and the
2//! `Segment` → `Row` → `Item` → `Menu` tree a consumer builds. This layer is
3//! entirely pure (no I/O, no platform calls) and is what both the renderer and
4//! the accessibility tree are derived from.
5
6use std::sync::Arc;
7
8use crate::style::{Color, Font, Weight};
9
10// =============================================================================
11// Identity & events
12// =============================================================================
13
14/// A click identifier for a row. muri treats this as an opaque string and hands
15/// it back verbatim when the row is clicked — the *grammar* of the id (e.g.
16/// `"switch:claude:me@x.com"`, `"quit"`) is entirely the consumer's business.
17#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
18pub struct MenuId(pub String);
19
20impl MenuId {
21 /// A `MenuId` from any string-like value. Mirrors real muda's
22 /// `MenuId::new(impl Into<String>)` so `MenuId::new(id)` migrates through the
23 /// `muda-compat` facade with a pure import swap.
24 pub fn new(id: impl Into<String>) -> Self {
25 MenuId(id.into())
26 }
27
28 /// A sentinel id for non-interactive rows (section headers, separators).
29 /// Rows carrying this id never emit a click event.
30 pub fn none() -> Self {
31 MenuId(String::new())
32 }
33
34 /// Borrow the id as a string slice.
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38
39 /// Whether this is the non-interactive sentinel ([`MenuId::none`]).
40 pub fn is_none(&self) -> bool {
41 self.0.is_empty()
42 }
43}
44
45impl From<&str> for MenuId {
46 fn from(s: &str) -> Self {
47 MenuId(s.to_owned())
48 }
49}
50
51impl From<String> for MenuId {
52 fn from(s: String) -> Self {
53 MenuId(s)
54 }
55}
56
57/// Emitted when a row is activated (click, Enter, or `AXPress`).
58#[derive(Clone, Debug)]
59pub struct MenuEvent {
60 /// The [`MenuId`] of the activated row.
61 pub id: MenuId,
62 /// Which surface — [`Tray`](crate::Tray), [`ContextMenu`](crate::ContextMenu),
63 /// or [`Popup`](crate::Popup) — the activation came from (issue #51). A
64 /// consumer with more than one live surface uses this to tell them apart on
65 /// the process-global channel; the muda-compat facade ignores it (structural
66 /// [`MenuId`] equality is unaffected).
67 pub source: crate::SurfaceId,
68}
69
70/// The callback invoked on row activation. Stored by
71/// [`Tray`](crate::Tray)/[`ContextMenu`](crate::ContextMenu).
72pub type ClickHandler = Box<dyn Fn(&MenuId) + Send + 'static>;
73
74// =============================================================================
75// Icons
76// =============================================================================
77
78/// A leading/trailing icon or logo. Raster (PNG) bytes are decoded at load; SVG
79/// bytes are rasterized by muri's own `zeno`-backed **restricted-subset**
80/// rasterizer (paths, basic shapes, solid fills, `transform`; no filters/text/
81/// gradients — those should ship as PNG). Both feed the same icon path.
82#[derive(Clone, Debug)]
83pub enum Icon {
84 /// A PNG (or other auto-detected raster format) from raw bytes.
85 Png(Arc<[u8]>),
86 /// An SVG from raw bytes, rasterized per target size via muri's restricted
87 /// SVG subset (see the [`Icon`] note for what's supported).
88 Svg(Arc<[u8]>),
89 /// The themed checkmark glyph, drawn in the leading column.
90 Checkmark,
91 /// A named symbol: an SF Symbol on macOS, with a bundled fallback elsewhere.
92 Symbol(&'static str),
93}
94
95impl Icon {
96 /// **The one obvious way** to build a raster icon: from encoded PNG (or any
97 /// auto-detected raster format) bytes. The 90% path for a logo/avatar.
98 pub fn from_png(bytes: impl Into<Arc<[u8]>>) -> Self {
99 Icon::Png(bytes.into())
100 }
101
102 /// **The one obvious way** to build an icon from raw straight-alpha RGBA8
103 /// pixels: `width * height * 4` bytes, row-major. muri keeps a single
104 /// encoded-bytes representation internally (the pixels are encoded to PNG),
105 /// so a consumer never has to choose a representation — hence this returns a
106 /// plain [`Icon`], indistinguishable at the type level from one built with
107 /// [`Icon::from_png`]. Returns [`Error::BadIcon`](crate::Error::BadIcon) when
108 /// the buffer length doesn't equal `width * height * 4` (or either dimension
109 /// is zero).
110 pub fn from_rgba(rgba: &[u8], width: u32, height: u32) -> crate::Result<Self> {
111 crate::render::encode_rgba_png(rgba, width, height)
112 .map(|png| Icon::Png(png.into()))
113 .ok_or_else(|| {
114 crate::Error::BadIcon(format!(
115 "RGBA buffer is {} bytes but {width}x{height} needs {}",
116 rgba.len(),
117 (width as usize)
118 .saturating_mul(height as usize)
119 .saturating_mul(4),
120 ))
121 })
122 }
123
124 /// **The one obvious way** to build an icon from raw SVG bytes (rasterized
125 /// per target size via muri's restricted SVG subset; see the [`Icon`] note).
126 pub fn from_svg(bytes: impl Into<Arc<[u8]>>) -> Self {
127 Icon::Svg(bytes.into())
128 }
129}
130
131// =============================================================================
132// Segments & rows
133// =============================================================================
134
135/// Horizontal alignment of a [`Segment`] within the width it is allotted.
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
137pub enum Align {
138 /// Align to the leading edge. The default.
139 #[default]
140 Left,
141 /// Center within the allotted width.
142 Center,
143 /// Align to the trailing edge (true flush-right).
144 Right,
145}
146
147/// How a [`Segment`] claims horizontal space during row layout.
148#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
149pub enum Flex {
150 /// Occupy only the segment's intrinsic width. The default.
151 #[default]
152 Fixed,
153 /// Absorb all leftover row width. A `Grow` label followed by an
154 /// `Align::Right` value yields a truly flush-right value with **no reserved
155 /// chevron column** — the core reason muri exists.
156 Grow,
157}
158
159/// A per-substring style span within a [`Segment`]'s text, used for severity
160/// coloring (e.g. a red over-limit percentage inside an otherwise normal line).
161///
162/// `start`/`len` are measured in **UTF-16 code units**, matching `NSRange` and
163/// usagio's existing span model.
164#[derive(Clone, Copy, Debug, PartialEq)]
165pub struct StyleRun {
166 /// Start offset, in UTF-16 code units.
167 pub start: usize,
168 /// Length, in UTF-16 code units.
169 pub len: usize,
170 /// The color applied to this span.
171 pub color: Color,
172 /// An optional weight override for this span.
173 pub weight: Option<Weight>,
174}
175
176impl StyleRun {
177 /// A span covering an explicit range with a color.
178 pub fn new(start: usize, len: usize, color: Color) -> Self {
179 StyleRun {
180 start,
181 len,
182 color,
183 weight: None,
184 }
185 }
186
187 /// A span from a Rust byte range into `text`, converted to the UTF-16 code
188 /// units `start`/`len` are measured in (see the [`StyleRun`] note). `range`
189 /// is clamped to `text`'s bounds and, if an endpoint doesn't land on a char
190 /// boundary, snapped **outward** to the enclosing boundary — so the whole
191 /// character a partial range touches is styled — rather than panicking.
192 pub fn from_byte_range(text: &str, range: std::ops::Range<usize>, color: Color) -> Self {
193 let byte_len = text.len();
194 let start_byte = range.start.min(byte_len);
195 let end_byte = range.end.min(byte_len).max(start_byte);
196
197 // Snap each endpoint outward to the enclosing char boundary (start moves
198 // earlier, end moves later) so a partially-covered char is fully styled.
199 let start_byte = (0..=start_byte)
200 .rev()
201 .find(|&i| text.is_char_boundary(i))
202 .unwrap_or(0);
203 let end_byte = (end_byte..=byte_len)
204 .find(|&i| text.is_char_boundary(i))
205 .unwrap_or(byte_len);
206
207 let start = text[..start_byte].encode_utf16().count();
208 let len = text[start_byte..end_byte].encode_utf16().count();
209
210 StyleRun {
211 start,
212 len,
213 color,
214 weight: None,
215 }
216 }
217
218 /// Set an explicit weight for this span.
219 pub fn weight(mut self, weight: Weight) -> Self {
220 self.weight = Some(weight);
221 self
222 }
223}
224
225/// One horizontal piece of a row. Rows are composed left→right from segments so
226/// multi-column layouts (`label ............ value`) are first-class rather than
227/// a tab-stop hack.
228#[derive(Clone, Debug, Default)]
229pub struct Segment {
230 /// The text to draw.
231 pub text: String,
232 /// Per-substring style spans (colors/weights). Empty → whole-segment style.
233 pub runs: Vec<StyleRun>,
234 /// Alignment within the segment's allotted width.
235 pub align: Align,
236 /// How the segment claims horizontal space.
237 pub flex: Flex,
238 /// Optional per-segment font override (else the row/theme font is used).
239 pub font: Option<Font>,
240 /// Optional whole-segment color (else [`Color::Label`]).
241 pub color: Option<Color>,
242}
243
244impl Segment {
245 /// A new segment with the given text (left-aligned, fixed width).
246 pub fn new(text: impl Into<String>) -> Self {
247 Segment {
248 text: text.into(),
249 ..Segment::default()
250 }
251 }
252
253 /// A segment that absorbs leftover row width (`Segment::new(text).flex(Flex::Grow)`).
254 /// Pair with [`Segment::trailing_value`] for a flush-right label/value row.
255 pub fn grow(text: impl Into<String>) -> Self {
256 Segment::new(text).flex(Flex::Grow)
257 }
258
259 /// A right-aligned segment (`Segment::new(text).align(Align::Right)`), for
260 /// the trailing value column of a label/value row.
261 pub fn trailing_value(text: impl Into<String>) -> Self {
262 Segment::new(text).align(Align::Right)
263 }
264
265 /// Set the alignment.
266 pub fn align(mut self, align: Align) -> Self {
267 self.align = align;
268 self
269 }
270
271 /// Set the flex behavior.
272 pub fn flex(mut self, flex: Flex) -> Self {
273 self.flex = flex;
274 self
275 }
276
277 /// Replace the per-substring style runs.
278 pub fn runs(mut self, runs: Vec<StyleRun>) -> Self {
279 self.runs = runs;
280 self
281 }
282
283 /// Append a single per-substring style run (sibling of [`Segment::runs`]
284 /// for building up spans one at a time).
285 pub fn run(mut self, run: StyleRun) -> Self {
286 self.runs.push(run);
287 self
288 }
289
290 /// Set a per-segment font override.
291 pub fn font(mut self, font: Font) -> Self {
292 self.font = Some(font);
293 self
294 }
295
296 /// Set a whole-segment color.
297 pub fn color(mut self, color: Color) -> Self {
298 self.color = Some(color);
299 self
300 }
301}
302
303/// One menu row. A row is interactive (carries a [`MenuId`]) unless it is used
304/// as an [`Item::SectionHeader`].
305#[derive(Clone, Debug)]
306pub struct Row {
307 /// The click id. [`MenuId::none`] marks a non-interactive row.
308 pub id: MenuId,
309 /// Left→right segments (multi-column layout).
310 pub segments: Vec<Segment>,
311 /// Optional leading icon column (logo, avatar, checkmark).
312 pub leading: Option<Icon>,
313 /// Optional trailing icon column.
314 pub trailing: Option<Icon>,
315 /// Whether the row is clickable. Disabled rows are dimmed and inert.
316 pub enabled: bool,
317 /// `Some(true/false)` shows a check column; `None` reserves no check column.
318 pub checked: Option<bool>,
319 /// Optional explicit row background (else theme hover/selection handling).
320 pub background: Option<Color>,
321 /// Optional minimum row height in logical points (else the theme default).
322 pub min_height: Option<f32>,
323 /// Optional override for the accessible name announced by screen readers,
324 /// used in place of [`Row::accessible_name`]. Needed for icon-only rows
325 /// (no segments), whose derived name would otherwise be empty and silent
326 /// to an assistive technology (spec 30 §1.4).
327 pub accessibility_label: Option<String>,
328}
329
330impl Default for Row {
331 fn default() -> Self {
332 Row {
333 id: MenuId::none(),
334 segments: Vec::new(),
335 leading: None,
336 trailing: None,
337 enabled: true,
338 checked: None,
339 background: None,
340 min_height: None,
341 accessibility_label: None,
342 }
343 }
344}
345
346impl Row {
347 /// A new enabled row with the given click id and no segments.
348 pub fn new(id: impl Into<MenuId>) -> Self {
349 Row {
350 id: id.into(),
351 ..Row::default()
352 }
353 }
354
355 /// A non-interactive row (id = [`MenuId::none`]).
356 ///
357 /// Deprecated (issue #62): redundant with [`Row::label_only`] (the canonical
358 /// non-interactive constructor, which also adds the label in one call) and
359 /// with [`Row::default`] (an empty non-interactive row to chain onto).
360 #[deprecated(
361 since = "0.11.0",
362 note = "use Row::label_only(text) for header/label/info rows, or Row::default() for an empty non-interactive row to chain segments onto"
363 )]
364 pub fn info() -> Self {
365 Row::default()
366 }
367
368 /// A non-interactive row (id = [`MenuId::none`], no check column) carrying
369 /// only text, an optional leading icon, and the enabled flag. Intended for
370 /// the label [`Row`] of an [`Item::Submenu`] or [`Item::SectionHeader`],
371 /// where the [`MenuId`] and `checked` state are discarded anyway (see the
372 /// note on [`Menu::submenu`]/[`Menu::section_header`]) — using this
373 /// constructor makes that discard explicit at the call site. **The one
374 /// obvious way** to build a non-interactive header/label/info row.
375 pub fn label_only(text: impl Into<String>) -> Self {
376 Row::default().label(text)
377 }
378
379 /// **The one obvious way** to add text to a row: append a plain-text
380 /// left-aligned segment.
381 pub fn label(mut self, text: impl Into<String>) -> Self {
382 self.segments.push(Segment::new(text));
383 self
384 }
385
386 /// **Convenience (issue #62):** bold the row's label — its first segment —
387 /// without changing its size or family. This is the native styling home for
388 /// the common "make this row bold" case; it renders identically to
389 /// hand-building the equivalent whole-label [`StyleRun`] with
390 /// [`Weight::Bold`]. A no-op on a row with no segments. It **replaces** the
391 /// first segment's runs; for partial or mixed-weight styling, build
392 /// [`StyleRun`]s directly (the full-control escape hatch).
393 pub fn bold(mut self) -> Self {
394 if let Some(seg) = self.segments.first_mut() {
395 let len = seg.text.encode_utf16().count();
396 seg.runs = vec![StyleRun::new(0, len, Color::Label).weight(Weight::Bold)];
397 }
398 self
399 }
400
401 /// **Convenience (issue #62):** color the row's value — its last segment,
402 /// i.e. the trailing value of a [`label_value`](Row::label_value) row — with
403 /// a whole-segment color. This is the native styling home for the common
404 /// "tint this row's value" case. A no-op on a row with no segments. For a
405 /// per-substring tint (e.g. only an over-limit percentage), build
406 /// [`StyleRun`]s directly (the full-control escape hatch).
407 pub fn value_color(mut self, color: Color) -> Self {
408 if let Some(seg) = self.segments.last_mut() {
409 seg.color = Some(color);
410 }
411 self
412 }
413
414 /// Append the common two-column pair: a growing left-aligned label segment
415 /// followed by a right-aligned value segment (`Segment::grow(label)` +
416 /// `Segment::trailing_value(value)`), yielding a flush-right value with no
417 /// reserved chevron column.
418 pub fn label_value(self, label: impl Into<String>, value: impl Into<String>) -> Self {
419 self.segment(Segment::grow(label))
420 .segment(Segment::trailing_value(value))
421 }
422
423 /// Append a pre-built segment.
424 pub fn segment(mut self, segment: Segment) -> Self {
425 self.segments.push(segment);
426 self
427 }
428
429 /// Replace all segments.
430 pub fn segments(mut self, segments: Vec<Segment>) -> Self {
431 self.segments = segments;
432 self
433 }
434
435 /// Set the leading icon.
436 pub fn leading(mut self, icon: Icon) -> Self {
437 self.leading = Some(icon);
438 self
439 }
440
441 /// Set the trailing icon.
442 pub fn trailing(mut self, icon: Icon) -> Self {
443 self.trailing = Some(icon);
444 self
445 }
446
447 /// Set enabled state.
448 pub fn enabled(mut self, enabled: bool) -> Self {
449 self.enabled = enabled;
450 self
451 }
452
453 /// Show a check column in the given state.
454 pub fn checked(mut self, checked: bool) -> Self {
455 self.checked = Some(checked);
456 self
457 }
458
459 /// Set an explicit background color.
460 pub fn background(mut self, color: Color) -> Self {
461 self.background = Some(color);
462 self
463 }
464
465 /// Set an explicit minimum row height.
466 pub fn min_height(mut self, height: f32) -> Self {
467 self.min_height = Some(height);
468 self
469 }
470
471 /// Override the accessible name announced by screen readers, in place of
472 /// [`Row::accessible_name`]. Required for icon-only rows (no segments),
473 /// whose derived name would otherwise be empty (spec 30 §1.4).
474 pub fn accessibility_label(mut self, label: impl Into<String>) -> Self {
475 self.accessibility_label = Some(label.into());
476 self
477 }
478
479 /// The row's accessible name: its segment texts concatenated with spaces.
480 /// This is what screen readers announce for the row (see the design's a11y
481 /// section).
482 pub fn accessible_name(&self) -> String {
483 self.segments
484 .iter()
485 .map(|s| s.text.as_str())
486 .filter(|t| !t.is_empty())
487 .collect::<Vec<_>>()
488 .join(" ")
489 }
490}
491
492// =============================================================================
493// Content stacks (issue #44)
494// =============================================================================
495//
496// A declarative, opt-in layout primitive for a row whose body is an arbitrary
497// nested stack rather than the `Segment`-based column model above (e.g. an
498// hourly-forecast strip). Purely additive: opted into via [`Item::Content`]
499// only. Rendering lives in `crate::render::paint`.
500
501/// The main axis a [`Stack`] lays its children out along.
502#[derive(Clone, Copy, Debug, PartialEq, Eq)]
503pub enum Axis {
504 /// Left-to-right.
505 Horizontal,
506 /// Top-to-bottom.
507 Vertical,
508}
509
510/// A run of plain text inside a [`Content`] tree, styled independently of the
511/// [`Segment`] column model (no `Flex`, no per-substring [`StyleRun`]s — a
512/// content cell is expected to be small and simple; nest a [`Stack`] if a
513/// cell needs more than one styled run).
514#[derive(Clone, Debug)]
515pub struct TextContent {
516 /// The text to draw.
517 pub text: String,
518 /// Optional font override (else the theme's row font).
519 pub font: Option<Font>,
520 /// Optional color override (else the row's resolved base color).
521 pub color: Option<Color>,
522 /// Alignment within the box this content is given.
523 pub align: Align,
524}
525
526impl TextContent {
527 /// A new, unstyled, left-aligned text content.
528 pub fn new(text: impl Into<String>) -> Self {
529 TextContent {
530 text: text.into(),
531 font: None,
532 color: None,
533 align: Align::Left,
534 }
535 }
536
537 /// Set a font override.
538 pub fn font(mut self, font: Font) -> Self {
539 self.font = Some(font);
540 self
541 }
542
543 /// Set a color override.
544 pub fn color(mut self, color: Color) -> Self {
545 self.color = Some(color);
546 self
547 }
548
549 /// Set the alignment within this content's allotted box.
550 pub fn align(mut self, align: Align) -> Self {
551 self.align = align;
552 self
553 }
554}
555
556/// One node in a [`Stack`] tree.
557#[derive(Clone, Debug)]
558pub enum Content {
559 /// A run of text (see [`TextContent`]).
560 Text(TextContent),
561 /// A square-ish icon, drawn at `size` logical points on each side.
562 Image {
563 /// The icon to draw.
564 icon: Icon,
565 /// The side length, in logical points.
566 size: f32,
567 },
568 /// A nested stack (arbitrary depth).
569 Stack(Stack),
570 /// A flexible gap: zero intrinsic size, absorbs an equal share of any
571 /// leftover main-axis space alongside sibling spacers.
572 Spacer,
573}
574
575/// A declarative box-model stack: children laid out along `axis`, separated by
576/// `spacing`, aligned on the cross axis per `align`. The body of an
577/// [`Item::Content`] row, and freely nestable (a horizontal strip of vertical
578/// cells, e.g. the Apple-Weather hourly extra).
579#[derive(Clone, Debug)]
580pub struct Stack {
581 /// The main axis children are laid out along.
582 pub axis: Axis,
583 /// The gap between consecutive children, in logical points.
584 pub spacing: f32,
585 /// Cross-axis alignment of children within the stack's cross-axis extent.
586 pub align: Align,
587 /// The stack's children, in order.
588 pub children: Vec<Content>,
589 /// Optional background fill, painted behind this stack's own bounds
590 /// before its children. Only the **top-level** stack of an
591 /// [`Item::Content`] row paints this (a nested stack's `background` is
592 /// currently ignored) — kept simple rather than churning the row-tint
593 /// story for nested cells.
594 pub background: Option<Color>,
595}
596
597impl Stack {
598 /// A new empty horizontal stack with the given inter-child spacing.
599 pub fn horizontal(spacing: f32) -> Self {
600 Stack {
601 axis: Axis::Horizontal,
602 spacing,
603 align: Align::Left,
604 children: Vec::new(),
605 background: None,
606 }
607 }
608
609 /// A new empty vertical stack with the given inter-child spacing.
610 pub fn vertical(spacing: f32) -> Self {
611 Stack {
612 axis: Axis::Vertical,
613 spacing,
614 align: Align::Left,
615 children: Vec::new(),
616 background: None,
617 }
618 }
619
620 /// Set the cross-axis alignment.
621 pub fn align(mut self, align: Align) -> Self {
622 self.align = align;
623 self
624 }
625
626 /// Set an explicit background fill (top-level stack only; see the field doc).
627 pub fn background(mut self, color: Color) -> Self {
628 self.background = Some(color);
629 self
630 }
631
632 /// Append a single child.
633 pub fn child(mut self, content: Content) -> Self {
634 self.children.push(content);
635 self
636 }
637
638 /// Replace all children.
639 pub fn children(mut self, children: Vec<Content>) -> Self {
640 self.children = children;
641 self
642 }
643}
644
645// =============================================================================
646// Menu tree
647// =============================================================================
648
649/// A single entry in a [`Menu`].
650#[derive(Clone, Debug)]
651pub enum Item {
652 /// An interactive (or info) row.
653 Row(Row),
654 /// A horizontal divider.
655 Separator,
656 /// A styled, non-interactive group heading.
657 SectionHeader(Row),
658 /// A row that opens a nested flyout panel beside it.
659 Submenu {
660 /// The row shown in the parent menu (drawn with a flyout affordance).
661 label: Row,
662 /// The nested menu shown in the flyout.
663 menu: Menu,
664 },
665 /// A non-interactive, non-clickable row whose body is an arbitrary
666 /// declarative [`Stack`] (issue #44) rather than the `Segment` column
667 /// model — e.g. a horizontal hourly-forecast strip. Row height is derived
668 /// from the stack's measured content. Never carries a [`MenuId`]: like
669 /// [`Item::SectionHeader`], it is always non-interactive (see
670 /// [`Item::is_interactive`]).
671 Content(Stack),
672}
673
674impl Item {
675 /// Whether this item can receive focus/clicks (rows and submenus that carry
676 /// a real id). Separators, section headers, and content rows are never
677 /// interactive.
678 pub fn is_interactive(&self) -> bool {
679 match self {
680 Item::Row(row) => row.enabled && !row.id.is_none(),
681 Item::Submenu { label, .. } => label.enabled,
682 Item::Separator | Item::SectionHeader(_) | Item::Content(_) => false,
683 }
684 }
685}
686
687/// Descend `root` through a chain of `parent` indices, following one
688/// [`Item::Submenu`] per step, and borrow the menu at the end of the path
689/// (`root` itself for an empty chain). Returns `None` if any index is out of
690/// range or does not name a submenu.
691///
692/// Shared by every platform backend's flyout stack (macOS/Windows/X11) so the
693/// open-submenu path is resolved by borrowing, never cloning, the nested menus.
694// A tray-only Linux build (`default-features = false`) compiles no flyout backend
695// at all — the SNI tray defers submenu rendering to the host — so this helper has
696// no caller there; allow it rather than warn in that one configuration (#21).
697#[cfg_attr(not(feature = "x11-popup"), allow(dead_code))]
698pub(crate) fn descend(root: &Menu, parents: impl IntoIterator<Item = usize>) -> Option<&Menu> {
699 let mut menu = root;
700 for parent in parents {
701 menu = match menu.items.get(parent) {
702 Some(Item::Submenu { menu, .. }) => menu,
703 _ => return None,
704 };
705 }
706 Some(menu)
707}
708
709/// A declarative menu: an ordered list of [`Item`]s. Build it with the fluent
710/// methods, or populate [`Menu::items`] directly.
711#[derive(Clone, Debug, Default)]
712pub struct Menu {
713 /// The ordered items.
714 pub items: Vec<Item>,
715}
716
717impl Menu {
718 /// An empty menu.
719 pub fn new() -> Self {
720 Menu::default()
721 }
722
723 /// Append any [`Item`] (the escape hatch). Prefer the labeled builder
724 /// methods — [`row`](Menu::row), [`separator`](Menu::separator),
725 /// [`section_header`](Menu::section_header), [`submenu`](Menu::submenu),
726 /// [`content`](Menu::content) — which are the canonical, one-obvious-way path
727 /// (issue #62); reach for `item` only to append a hand-built [`Item`].
728 pub fn item(mut self, item: Item) -> Self {
729 self.items.push(item);
730 self
731 }
732
733 /// Append an [`Item::Row`].
734 pub fn row(mut self, row: Row) -> Self {
735 self.items.push(Item::Row(row));
736 self
737 }
738
739 /// Append an [`Item::Separator`].
740 pub fn separator(mut self) -> Self {
741 self.items.push(Item::Separator);
742 self
743 }
744
745 /// Append an [`Item::SectionHeader`].
746 ///
747 /// Note: the label `Row`'s [`MenuId`] and `checked` state are ignored for
748 /// this position; only its text, leading icon, and `enabled` flag are
749 /// used. Consider [`Row::label_only`] to make that explicit at the call
750 /// site.
751 pub fn section_header(mut self, row: Row) -> Self {
752 self.items.push(Item::SectionHeader(row));
753 self
754 }
755
756 /// Append an [`Item::Submenu`].
757 ///
758 /// Note: the label `Row`'s [`MenuId`] and `checked` state are ignored for
759 /// this position; only its text, leading icon, and `enabled` flag are
760 /// used. Consider [`Row::label_only`] to make that explicit at the call
761 /// site.
762 pub fn submenu(mut self, label: Row, menu: Menu) -> Self {
763 self.items.push(Item::Submenu { label, menu });
764 self
765 }
766
767 /// Append an [`Item::Content`] row (issue #44): a non-interactive row
768 /// whose body is an arbitrary declarative [`Stack`].
769 pub fn content(mut self, stack: Stack) -> Self {
770 self.items.push(Item::Content(stack));
771 self
772 }
773
774 /// The number of items at this level (not counting nested submenu items).
775 pub fn len(&self) -> usize {
776 self.items.len()
777 }
778
779 /// Whether the menu has no items.
780 pub fn is_empty(&self) -> bool {
781 self.items.is_empty()
782 }
783
784 /// Total number of interactive (focusable) items at this level.
785 pub fn interactive_count(&self) -> usize {
786 self.items.iter().filter(|i| i.is_interactive()).count()
787 }
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn menu_id_none_is_inert() {
796 assert!(MenuId::none().is_none());
797 assert!(MenuId::from("quit").as_str() == "quit");
798 assert!(!MenuId::from("quit").is_none());
799 }
800
801 #[test]
802 fn menu_id_new_accepts_str_and_string() {
803 // muda-compatible constructor: builds a MenuId from any string-like value
804 // (issue #4 — `MenuId::new(...)` must work through the facade).
805 assert_eq!(MenuId::new("quit").as_str(), "quit");
806 assert_eq!(MenuId::new(String::from("open")).as_str(), "open");
807 assert!(!MenuId::new("open").is_none());
808 }
809
810 #[test]
811 fn builder_produces_expected_item_sequence() {
812 let menu = Menu::new()
813 .section_header(Row::label_only("Claude"))
814 .row(Row::new("a").label("Account A"))
815 .separator()
816 .submenu(
817 Row::new("more").label("More"),
818 Menu::new().row(Row::new("x").label("X")),
819 );
820
821 assert_eq!(menu.len(), 4);
822 assert!(matches!(menu.items[0], Item::SectionHeader(_)));
823 assert!(matches!(menu.items[1], Item::Row(_)));
824 assert!(matches!(menu.items[2], Item::Separator));
825 assert!(matches!(menu.items[3], Item::Submenu { .. }));
826 }
827
828 #[test]
829 fn interactivity_rules() {
830 let header = Item::SectionHeader(Row::label_only("H"));
831 let sep = Item::Separator;
832 let info = Item::Row(Row::label_only("info"));
833 let disabled = Item::Row(Row::new("x").label("X").enabled(false));
834 let live = Item::Row(Row::new("x").label("X"));
835
836 assert!(!header.is_interactive());
837 assert!(!sep.is_interactive());
838 assert!(!info.is_interactive()); // id == none
839 assert!(!disabled.is_interactive());
840 assert!(live.is_interactive());
841 }
842
843 #[test]
844 fn interactive_count_skips_headers_and_separators() {
845 let menu = Menu::new()
846 .section_header(Row::label_only("H"))
847 .row(Row::new("a").label("A"))
848 .row(Row::label_only("info only"))
849 .separator()
850 .row(Row::new("b").label("B"));
851 assert_eq!(menu.interactive_count(), 2);
852 }
853
854 #[test]
855 fn accessible_name_joins_segments() {
856 let row = Row::new("q").segments(vec![Segment::new("Quit"), Segment::new("usagio v1")]);
857 assert_eq!(row.accessible_name(), "Quit usagio v1");
858 }
859
860 #[test]
861 fn row_defaults_are_enabled_with_no_check_column() {
862 let row = Row::new("x");
863 assert!(row.enabled);
864 assert_eq!(row.checked, None);
865 assert!(row.leading.is_none());
866 }
867
868 #[test]
869 fn accessibility_label_defaults_to_none_and_is_settable() {
870 let row = Row::new("x");
871 assert_eq!(row.accessibility_label, None);
872 let labeled = Row::new("x").accessibility_label("Custom name");
873 assert_eq!(labeled.accessibility_label.as_deref(), Some("Custom name"));
874 }
875
876 // The usagio `RowStyle` → muri mapping from the design doc, exercised as a
877 // data-model test (no GUI needed).
878 #[test]
879 fn usagio_rowstyle_mapping() {
880 // `bold` → Segment.font.weight = Bold
881 let bold = Segment::new("label").font(Font::system(13.0, Weight::Bold));
882 assert_eq!(bold.font.unwrap().weight, Weight::Bold);
883
884 // `colors: Vec<(off, len, Severity)>` → Segment.runs with system colors;
885 // offsets are UTF-16 code units.
886 let value = Segment::new("47% / 89%")
887 .align(Align::Right)
888 .runs(vec![StyleRun::new(6, 3, Color::SystemRed)]);
889 assert_eq!(value.align, Align::Right);
890 assert_eq!(value.runs.len(), 1);
891 assert_eq!(value.runs[0].color, Color::SystemRed);
892
893 // `tab_x_kind: MenuRight` measuring hack → Flex::Grow + Align::Right.
894 let label = Segment::new("me@example.com").flex(Flex::Grow);
895 assert_eq!(label.flex, Flex::Grow);
896
897 // `checkmark` → Row.checked = Some(true) + leading Icon::Checkmark.
898 let active = Row::new("switch:claude:me")
899 .checked(true)
900 .leading(Icon::Checkmark);
901 assert_eq!(active.checked, Some(true));
902 assert!(matches!(active.leading, Some(Icon::Checkmark)));
903
904 // `grey_tail_from` → trailing Segment with Color::SecondaryLabel.
905 let tail = Segment::new("usagio v1").color(Color::SecondaryLabel);
906 assert_eq!(tail.color, Some(Color::SecondaryLabel));
907
908 // `disabled_but_white` info rows → Row::default() (id none), Color::Label.
909 let info = Row::default();
910 assert!(info.id.is_none());
911 assert!(info.enabled);
912 }
913
914 // Issue #49 — convenience constructors for the two-column / per-span build path.
915 #[test]
916 fn segment_grow_and_trailing_value() {
917 let label = Segment::grow("me@example.com");
918 assert_eq!(label.flex, Flex::Grow);
919 assert_eq!(label.align, Align::Left);
920
921 let value = Segment::trailing_value("99%");
922 assert_eq!(value.align, Align::Right);
923 assert_eq!(value.flex, Flex::Fixed);
924 }
925
926 #[test]
927 fn segment_run_appends_single_style_run() {
928 let seg = Segment::new("47% / 89%")
929 .run(StyleRun::new(0, 3, Color::SystemRed))
930 .run(StyleRun::new(6, 3, Color::SystemGreen));
931 assert_eq!(seg.runs.len(), 2);
932 assert_eq!(seg.runs[0].color, Color::SystemRed);
933 assert_eq!(seg.runs[1].color, Color::SystemGreen);
934 }
935
936 #[test]
937 fn row_label_value_produces_grow_and_right_segments() {
938 let row = Row::default().label_value("me@example.com", "Active");
939 assert_eq!(row.segments.len(), 2);
940 assert_eq!(row.segments[0].text, "me@example.com");
941 assert_eq!(row.segments[0].flex, Flex::Grow);
942 assert_eq!(row.segments[1].text, "Active");
943 assert_eq!(row.segments[1].align, Align::Right);
944 }
945
946 #[test]
947 fn style_run_from_byte_range_ascii() {
948 let text = "47% / 89%";
949 // "89%" starts at byte 6, len 3 — pure ASCII, so byte offsets == UTF-16 units.
950 let run = StyleRun::from_byte_range(text, 6..9, Color::SystemRed);
951 assert_eq!(run.start, 6);
952 assert_eq!(run.len, 3);
953 }
954
955 #[test]
956 fn style_run_from_byte_range_multibyte() {
957 // "café" — 'é' is a 2-byte UTF-8 char but a single UTF-16 unit, so byte
958 // offset 4 (start of 'é') is UTF-16 offset 3, and its byte length 2
959 // is UTF-16 length 1.
960 let text = "café";
961 let run = StyleRun::from_byte_range(text, 4..6, Color::SystemRed);
962 assert_eq!(run.start, 3);
963 assert_eq!(run.len, 1);
964
965 // Emoji (4-byte UTF-8, but 2 UTF-16 code units — a surrogate pair).
966 let text = "hi 🎉!";
967 let emoji_byte_start = text.find('🎉').unwrap();
968 let emoji_byte_len = '🎉'.len_utf8();
969 let run = StyleRun::from_byte_range(
970 text,
971 emoji_byte_start..emoji_byte_start + emoji_byte_len,
972 Color::SystemRed,
973 );
974 assert_eq!(run.start, "hi ".encode_utf16().count());
975 assert_eq!(run.len, 2);
976 }
977
978 #[test]
979 fn style_run_from_byte_range_clamps_to_char_boundary() {
980 // A start that lands mid-codepoint (byte 4 is between 'é's two UTF-8
981 // bytes; 'é' spans bytes 3..5 in "café") snaps OUTWARD to the enclosing
982 // boundary rather than panicking, so the whole 'é' is styled.
983 let text = "café";
984 let run = StyleRun::from_byte_range(text, 4..text.len() + 10, Color::SystemRed);
985 // Start snaps back to byte 3 (the start of 'é') = UTF-16 index 3.
986 assert_eq!(run.start, "caf".encode_utf16().count());
987 // End clamps to the string's byte length, so the run covers just 'é'.
988 assert_eq!(run.len, 1);
989 assert_eq!(run.start + run.len, text.encode_utf16().count());
990 }
991
992 // Issue #50 — Row::label_only makes the MenuId/checked discard explicit.
993 #[test]
994 fn row_label_only_has_no_id_and_no_checked() {
995 let row = Row::label_only("Section");
996 assert!(row.id.is_none());
997 assert_eq!(row.checked, None);
998 assert_eq!(row.accessible_name(), "Section");
999 assert!(row.enabled);
1000 }
1001
1002 // Issue #62 — Row-level styling conveniences. `Row::bold()` must produce the
1003 // same whole-label bold StyleRun a consumer would hand-build, so it renders
1004 // identically (the compat set_bold path lands on this same shape).
1005 #[test]
1006 fn row_bold_bolds_the_label_segment() {
1007 let row = Row::new("x").label("Hi").bold();
1008 let runs = &row.segments[0].runs;
1009 assert_eq!(runs.len(), 1);
1010 assert_eq!(runs[0].start, 0);
1011 assert_eq!(runs[0].len, "Hi".encode_utf16().count());
1012 assert_eq!(runs[0].weight, Some(Weight::Bold));
1013 assert_eq!(runs[0].color, Color::Label);
1014 }
1015
1016 #[test]
1017 fn row_bold_is_identical_to_hand_built_style_run() {
1018 let convenient = Row::new("x").label("Account").bold();
1019 let hand_built = Row::new("x").segment(Segment::new("Account").run(
1020 StyleRun::new(0, "Account".encode_utf16().count(), Color::Label).weight(Weight::Bold),
1021 ));
1022 assert_eq!(convenient.segments[0].runs, hand_built.segments[0].runs);
1023 assert_eq!(convenient.segments[0].text, hand_built.segments[0].text);
1024 }
1025
1026 #[test]
1027 fn row_bold_on_empty_row_is_a_noop() {
1028 let row = Row::new("x").bold();
1029 assert!(row.segments.is_empty());
1030 }
1031
1032 #[test]
1033 fn row_value_color_colors_the_value_segment() {
1034 let row = Row::new("acct")
1035 .label_value("me@example.com", "Active")
1036 .value_color(Color::SystemRed);
1037 assert_eq!(row.segments.len(), 2);
1038 // Colors the *last* segment (the value), not the label.
1039 assert_eq!(row.segments[1].color, Some(Color::SystemRed));
1040 assert_eq!(row.segments[0].color, None);
1041 }
1042
1043 #[test]
1044 fn row_value_color_on_empty_row_is_a_noop() {
1045 let row = Row::new("x").value_color(Color::SystemRed);
1046 assert!(row.segments.is_empty());
1047 }
1048
1049 // Issue #62 — unified Icon constructors.
1050 #[test]
1051 fn icon_from_svg_builds_an_svg_icon() {
1052 let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#f00"/></svg>"##;
1053 let icon = Icon::from_svg(svg.to_vec());
1054 assert!(matches!(icon, Icon::Svg(_)));
1055 }
1056
1057 #[test]
1058 fn icon_from_png_builds_a_png_icon() {
1059 let icon = Icon::from_png(vec![1u8, 2, 3]);
1060 assert!(matches!(icon, Icon::Png(_)));
1061 }
1062
1063 #[test]
1064 fn icon_from_rgba_encodes_to_a_single_representation() {
1065 // A 2x2 opaque-white RGBA buffer becomes an (encoded-PNG) Icon — the
1066 // consumer never sees a separate raw-RGBA representation.
1067 let rgba = vec![255u8; 2 * 2 * 4];
1068 let icon = Icon::from_rgba(&rgba, 2, 2).expect("valid rgba");
1069 assert!(matches!(icon, Icon::Png(_)));
1070 }
1071
1072 #[test]
1073 fn icon_from_rgba_rejects_mismatched_buffer() {
1074 // Wrong length for the stated dimensions → BadIcon rather than a panic.
1075 let err = Icon::from_rgba(&[0u8; 3], 2, 2).unwrap_err();
1076 assert!(matches!(err, crate::Error::BadIcon(_)));
1077 }
1078}