Skip to main content

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}