Skip to main content

frust_material/menu/
items.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/menus/models/m3e_menu_node.dart` (the sealed node vocabulary
3// and `m3eMenuPartitionSurfaces`) plus `utils/m3e_menu_placer.dart`'s
4// `approximateItemCount` (the depth-first walk this module's `span` mirrors),
5// retrieved 2026-08-19.
6// Porting decisions: the reference's `M3EMenuWidget` (an arbitrary host-built
7// row body) is **not** ported — a Rust node tree is plain data, so a row's body
8// is the label/icon/shortcut vocabulary below rather than a boxed child view;
9// its `badge` slot is dropped for the same reason. `leading`/`trailing` narrow
10// from `Widget?` to the vendored `crate::icons` set.
11
12//! The menu content tree: the six node kinds a caller composes, and the
13//! surface partitioning the panel renders them through.
14//!
15//! # The six kinds
16//!
17//! | Node | Reference | Row |
18//! |---|---|---|
19//! | [`MenuEntry`] | `M3EMenuEntry` | a plain action row |
20//! | [`MenuSelectable`] | `M3EMenuSelectable` | radio-style: carries a value, checks when selected |
21//! | [`MenuToggleable`] | `M3EMenuToggleable` | checkbox-style: carries a checked state |
22//! | [`MenuNode::Divider`] | `M3EMenuDivider` | a hairline between sections of one surface |
23//! | [`MenuGroup`] | `M3EMenuGroup` | a labelled section — **its own elevated surface** |
24//! | [`MenuSubmenu`] | `M3EMenuSubmenu` | a row that opens a nested panel |
25//!
26//! # Surfaces: a group is a container, not an indent
27//!
28//! [`partition_surfaces`] ports `m3eMenuPartitionSurfaces` exactly: each
29//! top-level [`MenuGroup`] becomes its own elevated menu container, and every
30//! run of consecutive non-group nodes collapses into one implicit, unlabelled
31//! surface. A menu is therefore a *stack* of cards, not one card with headings.
32//!
33//! # Indices
34//!
35//! Every **rendered row** takes one index in a depth-first, pre-order walk of
36//! the tree — a divider counts (it is a row), a submenu row counts and its
37//! children take the indices that follow, and a [`MenuGroup`] counts for
38//! nothing of its own (it is a surface, and its label is chrome, not a row).
39//! [`MenuSelection::index`] reports that number, so a caller matches on a
40//! number it can read straight off its own node list. [`MenuNode::span`] is how
41//! many indices a node and its whole subtree consume (the same shape
42//! `frust_shadcn`'s menu list uses for its own numbering).
43
44use frust::IconSource;
45
46/// An icon slot on a menu row.
47///
48/// A thin wrapper over [`frust::IconSource`] that adds the `PartialEq` the
49/// generated constants do not carry, so a node tree can be compared across a
50/// rebuild. Build one from any `crate::icons` constant — every slot below takes
51/// `impl Into<MenuIcon>`, so `icons::CONTENT_COPY` passes straight in.
52#[derive(Clone, Copy, Debug)]
53pub struct MenuIcon(pub IconSource);
54
55impl PartialEq for MenuIcon {
56    fn eq(&self, other: &Self) -> bool {
57        // `IconSource` is `{ d: &'static str, design: f64 }`; two slots holding
58        // the same path in the same design box paint identically, which is the
59        // only equality a rebuild diff needs.
60        self.0.d == other.0.d && self.0.design == other.0.design
61    }
62}
63
64impl From<IconSource> for MenuIcon {
65    fn from(source: IconSource) -> Self {
66        MenuIcon(source)
67    }
68}
69
70/// One node in a menu content tree. See the [module docs](self).
71#[derive(Clone, Debug, PartialEq)]
72pub enum MenuNode {
73    /// A plain action row.
74    Entry(MenuEntry),
75    /// A radio-style row carrying a value.
76    Selectable(MenuSelectable),
77    /// A checkbox-style row carrying a checked state.
78    Toggleable(MenuToggleable),
79    /// A hairline rule between sections of one surface.
80    Divider,
81    /// A labelled section, rendered as its own elevated surface.
82    Group(MenuGroup),
83    /// A row that opens a nested panel.
84    Submenu(MenuSubmenu),
85}
86
87impl MenuNode {
88    /// How many indices this node and its whole subtree consume in the
89    /// depth-first numbering (see the [module docs](self)).
90    pub fn span(&self) -> usize {
91        match self {
92            // A group renders no row of its own — only a surface holding its
93            // children's.
94            MenuNode::Group(group) => spans(&group.children),
95            MenuNode::Submenu(submenu) => 1 + spans(&submenu.children),
96            _ => 1,
97        }
98    }
99
100    /// Whether this node can be activated at all — a divider and a group never
101    /// are, and a disabled row refuses.
102    pub fn is_activatable(&self) -> bool {
103        match self {
104            MenuNode::Entry(e) => e.enabled,
105            MenuNode::Selectable(e) => e.enabled,
106            MenuNode::Toggleable(e) => e.enabled,
107            MenuNode::Submenu(e) => e.enabled,
108            MenuNode::Divider | MenuNode::Group(_) => false,
109        }
110    }
111}
112
113/// The total span of `nodes` — their count plus every nested subtree's.
114pub fn spans(nodes: &[MenuNode]) -> usize {
115    nodes.iter().map(MenuNode::span).sum()
116}
117
118/// A plain action row (`M3EMenuEntry`). Build one with [`menu_entry`].
119#[derive(Clone, Debug, PartialEq)]
120pub struct MenuEntry {
121    pub(super) label: String,
122    pub(super) leading: Option<MenuIcon>,
123    pub(super) trailing: Option<MenuIcon>,
124    pub(super) shortcut: Option<String>,
125    pub(super) supporting: Option<String>,
126    pub(super) enabled: bool,
127    pub(super) destructive: bool,
128}
129
130/// A plain action row labelled `label`.
131pub fn menu_entry(label: impl Into<String>) -> MenuEntry {
132    MenuEntry {
133        label: label.into(),
134        leading: None,
135        trailing: None,
136        shortcut: None,
137        supporting: None,
138        enabled: true,
139        destructive: false,
140    }
141}
142
143impl MenuEntry {
144    /// Set the leading icon.
145    pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
146        self.leading = Some(icon.into());
147        self
148    }
149
150    /// Set the trailing icon (drawn after the shortcut text, per the
151    /// reference's row order).
152    pub fn trailing(mut self, icon: impl Into<MenuIcon>) -> Self {
153        self.trailing = Some(icon.into());
154        self
155    }
156
157    /// Set the trailing shortcut hint (`trailingText`, e.g. `⌘C`).
158    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
159        self.shortcut = Some(shortcut.into());
160        self
161    }
162
163    /// Set the second line under the label (`supportingText`).
164    pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
165        self.supporting = Some(supporting.into());
166        self
167    }
168
169    /// Enable or disable the row. Enabled by default.
170    pub fn enabled(mut self, enabled: bool) -> Self {
171        self.enabled = enabled;
172        self
173    }
174
175    /// Paint the row's label and icons in the `error` role (`isDestructive`).
176    pub fn destructive(mut self, destructive: bool) -> Self {
177        self.destructive = destructive;
178        self
179    }
180
181    /// The row's label — also its accessible name.
182    pub fn label(&self) -> &str {
183        &self.label
184    }
185}
186
187impl From<MenuEntry> for MenuNode {
188    fn from(entry: MenuEntry) -> Self {
189        MenuNode::Entry(entry)
190    }
191}
192
193/// A radio-style row carrying a value (`M3EMenuSelectable`). Build one with
194/// [`menu_selectable`].
195#[derive(Clone, Debug, PartialEq)]
196pub struct MenuSelectable {
197    pub(super) label: String,
198    pub(super) value: String,
199    pub(super) leading: Option<MenuIcon>,
200    pub(super) trailing: Option<MenuIcon>,
201    pub(super) shortcut: Option<String>,
202    pub(super) supporting: Option<String>,
203    pub(super) selected: bool,
204    pub(super) enabled: bool,
205}
206
207/// A radio-style row labelled `label`, reporting `value` when chosen.
208///
209/// Controlled, like every selection control in this catalog: the row reports
210/// the *requested* value and never flips its own `selected` — hand the panel
211/// the confirmed value through
212/// [`MenuPanelView::selected`](super::MenuPanelView::selected) (or
213/// [`MenuView::selected`](super::MenuView::selected)) on the next rebuild.
214pub fn menu_selectable(label: impl Into<String>, value: impl Into<String>) -> MenuSelectable {
215    MenuSelectable {
216        label: label.into(),
217        value: value.into(),
218        leading: None,
219        trailing: None,
220        shortcut: None,
221        supporting: None,
222        selected: false,
223        enabled: true,
224    }
225}
226
227impl MenuSelectable {
228    /// Set the leading icon. Without one, a selected row takes the reference's
229    /// `M3EIcons.check_rounded` (`m3e_menu_node_builders.dart:55`).
230    pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
231        self.leading = Some(icon.into());
232        self
233    }
234
235    /// Set the trailing icon.
236    pub fn trailing(mut self, icon: impl Into<MenuIcon>) -> Self {
237        self.trailing = Some(icon.into());
238        self
239    }
240
241    /// Set the trailing shortcut hint.
242    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
243        self.shortcut = Some(shortcut.into());
244        self
245    }
246
247    /// Set the second line under the label.
248    pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
249        self.supporting = Some(supporting.into());
250        self
251    }
252
253    /// Mark the row selected outright, independent of the panel's own selected
254    /// value (the reference's per-item `selected` flag, OR-ed with the value
255    /// match — `m3e_menu_content.dart:184`).
256    pub fn selected(mut self, selected: bool) -> Self {
257        self.selected = selected;
258        self
259    }
260
261    /// Enable or disable the row. Enabled by default.
262    pub fn enabled(mut self, enabled: bool) -> Self {
263        self.enabled = enabled;
264        self
265    }
266
267    /// The row's label.
268    pub fn label(&self) -> &str {
269        &self.label
270    }
271
272    /// The value this row reports when chosen.
273    pub fn value(&self) -> &str {
274        &self.value
275    }
276}
277
278impl From<MenuSelectable> for MenuNode {
279    fn from(item: MenuSelectable) -> Self {
280        MenuNode::Selectable(item)
281    }
282}
283
284/// A checkbox-style row (`M3EMenuToggleable`). Build one with
285/// [`menu_toggleable`].
286#[derive(Clone, Debug, PartialEq)]
287pub struct MenuToggleable {
288    pub(super) label: String,
289    pub(super) checked: bool,
290    pub(super) leading: Option<MenuIcon>,
291    pub(super) trailing: Option<MenuIcon>,
292    pub(super) shortcut: Option<String>,
293    pub(super) supporting: Option<String>,
294    pub(super) enabled: bool,
295}
296
297/// A checkbox-style row labelled `label`, currently `checked`.
298///
299/// Controlled: activating it reports the *requested* state (`!checked`) and
300/// leaves `checked` untouched until the caller feeds the confirmed value back
301/// down on the next rebuild.
302pub fn menu_toggleable(label: impl Into<String>, checked: bool) -> MenuToggleable {
303    MenuToggleable {
304        label: label.into(),
305        checked,
306        leading: None,
307        trailing: None,
308        shortcut: None,
309        supporting: None,
310        enabled: true,
311    }
312}
313
314impl MenuToggleable {
315    /// Set the leading icon. Without one, the row takes the reference's
316    /// `check_box_rounded`/`check_box_outline_blank_rounded` pair
317    /// (`m3e_menu_node_builders.dart:88`).
318    pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
319        self.leading = Some(icon.into());
320        self
321    }
322
323    /// Set the trailing icon.
324    pub fn trailing(mut self, icon: impl Into<MenuIcon>) -> Self {
325        self.trailing = Some(icon.into());
326        self
327    }
328
329    /// Set the trailing shortcut hint.
330    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
331        self.shortcut = Some(shortcut.into());
332        self
333    }
334
335    /// Set the second line under the label.
336    pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
337        self.supporting = Some(supporting.into());
338        self
339    }
340
341    /// Enable or disable the row. Enabled by default.
342    pub fn enabled(mut self, enabled: bool) -> Self {
343        self.enabled = enabled;
344        self
345    }
346
347    /// The row's label.
348    pub fn label(&self) -> &str {
349        &self.label
350    }
351
352    /// Whether the row currently reads as checked.
353    pub fn checked(&self) -> bool {
354        self.checked
355    }
356}
357
358impl From<MenuToggleable> for MenuNode {
359    fn from(item: MenuToggleable) -> Self {
360        MenuNode::Toggleable(item)
361    }
362}
363
364/// A labelled section rendered as its own elevated surface (`M3EMenuGroup`).
365/// Build one with [`menu_group`].
366#[derive(Clone, Debug, PartialEq)]
367pub struct MenuGroup {
368    pub(super) label: Option<String>,
369    pub(super) children: Vec<MenuNode>,
370}
371
372/// A section holding `children`, unlabelled until [`MenuGroup::label`].
373pub fn menu_group(children: Vec<MenuNode>) -> MenuGroup {
374    MenuGroup {
375        label: None,
376        children,
377    }
378}
379
380impl MenuGroup {
381    /// Set the section header drawn above the group's rows.
382    pub fn label(mut self, label: impl Into<String>) -> Self {
383        self.label = Some(label.into());
384        self
385    }
386
387    /// The group's rows.
388    pub fn children(&self) -> &[MenuNode] {
389        &self.children
390    }
391
392    /// The section header, if any.
393    pub fn section_label(&self) -> Option<&str> {
394        self.label.as_deref()
395    }
396}
397
398impl From<MenuGroup> for MenuNode {
399    fn from(group: MenuGroup) -> Self {
400        MenuNode::Group(group)
401    }
402}
403
404/// A row that opens a nested panel (`M3EMenuSubmenu`). Build one with
405/// [`menu_submenu`].
406#[derive(Clone, Debug, PartialEq)]
407pub struct MenuSubmenu {
408    pub(super) label: String,
409    pub(super) leading: Option<MenuIcon>,
410    pub(super) children: Vec<MenuNode>,
411    pub(super) enabled: bool,
412}
413
414/// A row labelled `label` opening a nested panel over `children`.
415pub fn menu_submenu(label: impl Into<String>, children: Vec<MenuNode>) -> MenuSubmenu {
416    MenuSubmenu {
417        label: label.into(),
418        leading: None,
419        children,
420        enabled: true,
421    }
422}
423
424impl MenuSubmenu {
425    /// Set the leading icon. The trailing slot is always the reference's
426    /// `arrow_right_rounded` chevron (`m3e_menu_node_builders.dart:128`).
427    pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
428        self.leading = Some(icon.into());
429        self
430    }
431
432    /// Enable or disable the row. A disabled submenu row opens nothing.
433    pub fn enabled(mut self, enabled: bool) -> Self {
434        self.enabled = enabled;
435        self
436    }
437
438    /// The row's label.
439    pub fn label(&self) -> &str {
440        &self.label
441    }
442
443    /// The nested panel's own nodes.
444    pub fn children(&self) -> &[MenuNode] {
445        &self.children
446    }
447}
448
449impl From<MenuSubmenu> for MenuNode {
450    fn from(submenu: MenuSubmenu) -> Self {
451        MenuNode::Submenu(submenu)
452    }
453}
454
455/// What an activated row asks the app to do.
456///
457/// Every kind is a *request*: the panel never mutates its own nodes, so an app
458/// that ignores one simply leaves the menu as it was (this catalog's
459/// controlled-component rule).
460#[derive(Clone, Debug, PartialEq, Eq)]
461pub enum MenuAction {
462    /// A [`MenuEntry`] fired.
463    Press,
464    /// A [`MenuSelectable`] asks to become its group's selected value.
465    Select(String),
466    /// A [`MenuToggleable`] asks to flip to this checked state.
467    Toggle(bool),
468}
469
470/// One activation reported through a panel's `on_select`.
471#[derive(Clone, Debug, PartialEq, Eq)]
472pub struct MenuSelection {
473    /// The activated node's depth-first index (see the [module docs](self)).
474    pub index: usize,
475    /// The row's label, so a caller can match on text rather than a number.
476    pub label: String,
477    /// What the row requests.
478    pub action: MenuAction,
479}
480
481impl MenuSelection {
482    /// The value a [`MenuAction::Select`] carries, if that is what this is.
483    pub fn value(&self) -> Option<&str> {
484        match &self.action {
485            MenuAction::Select(value) => Some(value.as_str()),
486            _ => None,
487        }
488    }
489
490    /// The requested state a [`MenuAction::Toggle`] carries, if that is what
491    /// this is.
492    pub fn checked(&self) -> Option<bool> {
493        match &self.action {
494            MenuAction::Toggle(checked) => Some(*checked),
495            _ => None,
496        }
497    }
498}
499
500/// Partition top-level `nodes` into elevated surface groups — the port of
501/// `m3eMenuPartitionSurfaces` (`m3e_menu_node.dart:272`).
502///
503/// Each [`MenuNode::Group`] becomes its own surface (keeping its label);
504/// every run of consecutive non-group nodes collapses into one unlabelled
505/// surface. An empty input partitions into no surfaces at all.
506pub fn partition_surfaces(nodes: &[MenuNode]) -> Vec<MenuGroup> {
507    let mut surfaces: Vec<MenuGroup> = Vec::new();
508    let mut flat: Vec<MenuNode> = Vec::new();
509    for node in nodes {
510        match node {
511            MenuNode::Group(group) => {
512                if !flat.is_empty() {
513                    surfaces.push(menu_group(std::mem::take(&mut flat)));
514                }
515                surfaces.push(group.clone());
516            }
517            other => flat.push(other.clone()),
518        }
519    }
520    if !flat.is_empty() {
521        surfaces.push(menu_group(flat));
522    }
523    surfaces
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use crate::icons;
530
531    fn sample() -> Vec<MenuNode> {
532        vec![
533            menu_entry("Cut").shortcut("⌘X").into(),
534            MenuNode::Divider,
535            menu_group(vec![
536                menu_selectable("Grid", "grid").into(),
537                menu_selectable("List", "list").into(),
538            ])
539            .label("View")
540            .into(),
541            menu_submenu(
542                "Share",
543                vec![
544                    menu_entry("Copy link").into(),
545                    menu_toggleable("Public", true).into(),
546                ],
547            )
548            .into(),
549        ]
550    }
551
552    #[test]
553    fn a_group_opens_its_own_surface_and_a_flat_run_shares_one() {
554        let surfaces = partition_surfaces(&sample());
555        assert_eq!(surfaces.len(), 3, "flat run, the group, then the flat tail");
556        assert_eq!(surfaces[0].section_label(), None);
557        assert_eq!(surfaces[0].children().len(), 2, "the entry and the divider");
558        assert_eq!(surfaces[1].section_label(), Some("View"));
559        assert_eq!(surfaces[1].children().len(), 2);
560        assert_eq!(surfaces[2].section_label(), None);
561        assert!(matches!(surfaces[2].children()[0], MenuNode::Submenu(_)));
562    }
563
564    #[test]
565    fn an_empty_tree_partitions_into_no_surfaces() {
566        assert!(partition_surfaces(&[]).is_empty());
567    }
568
569    #[test]
570    fn consecutive_groups_never_merge() {
571        let nodes = vec![
572            menu_group(vec![menu_entry("a").into()]).into(),
573            menu_group(vec![menu_entry("b").into()]).into(),
574        ];
575        assert_eq!(partition_surfaces(&nodes).len(), 2);
576    }
577
578    #[test]
579    fn span_counts_the_node_plus_its_whole_subtree() {
580        let nodes = sample();
581        assert_eq!(nodes[0].span(), 1, "a leaf entry");
582        assert_eq!(nodes[1].span(), 1, "a divider still takes an index");
583        assert_eq!(nodes[2].span(), 2, "a group is a surface, not a row");
584        assert_eq!(nodes[3].span(), 3, "the submenu row plus its two children");
585        assert_eq!(spans(&nodes), 7);
586    }
587
588    #[test]
589    fn only_enabled_leaf_rows_are_activatable() {
590        assert!(MenuNode::from(menu_entry("a")).is_activatable());
591        assert!(!MenuNode::from(menu_entry("a").enabled(false)).is_activatable());
592        assert!(MenuNode::from(menu_selectable("a", "v")).is_activatable());
593        assert!(MenuNode::from(menu_toggleable("a", false)).is_activatable());
594        assert!(MenuNode::from(menu_submenu("a", vec![])).is_activatable());
595        assert!(!MenuNode::from(menu_submenu("a", vec![]).enabled(false)).is_activatable());
596        assert!(!MenuNode::Divider.is_activatable());
597        assert!(!MenuNode::from(menu_group(vec![])).is_activatable());
598    }
599
600    #[test]
601    fn an_icon_slot_compares_by_path_and_design_box() {
602        let a: MenuIcon = icons::CHECK_ROUNDED.into();
603        let b: MenuIcon = icons::CHECK_ROUNDED.into();
604        let c: MenuIcon = icons::CONTENT_COPY.into();
605        assert_eq!(a, b);
606        assert_ne!(a, c);
607        assert_eq!(
608            menu_entry("x").leading(icons::CHECK),
609            menu_entry("x").leading(icons::CHECK)
610        );
611        assert_ne!(menu_entry("x").leading(icons::CHECK), menu_entry("x"));
612    }
613
614    #[test]
615    fn a_selection_reads_its_own_payload() {
616        let select = MenuSelection {
617            index: 2,
618            label: "Grid".into(),
619            action: MenuAction::Select("grid".into()),
620        };
621        assert_eq!(select.value(), Some("grid"));
622        assert_eq!(select.checked(), None);
623        let toggle = MenuSelection {
624            index: 3,
625            label: "Public".into(),
626            action: MenuAction::Toggle(false),
627        };
628        assert_eq!(toggle.checked(), Some(false));
629        assert_eq!(toggle.value(), None);
630    }
631}