1use frust::IconSource;
45
46#[derive(Clone, Copy, Debug)]
53pub struct MenuIcon(pub IconSource);
54
55impl PartialEq for MenuIcon {
56 fn eq(&self, other: &Self) -> bool {
57 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#[derive(Clone, Debug, PartialEq)]
72pub enum MenuNode {
73 Entry(MenuEntry),
75 Selectable(MenuSelectable),
77 Toggleable(MenuToggleable),
79 Divider,
81 Group(MenuGroup),
83 Submenu(MenuSubmenu),
85}
86
87impl MenuNode {
88 pub fn span(&self) -> usize {
91 match self {
92 MenuNode::Group(group) => spans(&group.children),
95 MenuNode::Submenu(submenu) => 1 + spans(&submenu.children),
96 _ => 1,
97 }
98 }
99
100 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
113pub fn spans(nodes: &[MenuNode]) -> usize {
115 nodes.iter().map(MenuNode::span).sum()
116}
117
118#[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
130pub 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 pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
146 self.leading = Some(icon.into());
147 self
148 }
149
150 pub fn trailing(mut self, icon: impl Into<MenuIcon>) -> Self {
153 self.trailing = Some(icon.into());
154 self
155 }
156
157 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
159 self.shortcut = Some(shortcut.into());
160 self
161 }
162
163 pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
165 self.supporting = Some(supporting.into());
166 self
167 }
168
169 pub fn enabled(mut self, enabled: bool) -> Self {
171 self.enabled = enabled;
172 self
173 }
174
175 pub fn destructive(mut self, destructive: bool) -> Self {
177 self.destructive = destructive;
178 self
179 }
180
181 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#[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
207pub 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 pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
231 self.leading = Some(icon.into());
232 self
233 }
234
235 pub fn trailing(mut self, icon: impl Into<MenuIcon>) -> Self {
237 self.trailing = Some(icon.into());
238 self
239 }
240
241 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
243 self.shortcut = Some(shortcut.into());
244 self
245 }
246
247 pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
249 self.supporting = Some(supporting.into());
250 self
251 }
252
253 pub fn selected(mut self, selected: bool) -> Self {
257 self.selected = selected;
258 self
259 }
260
261 pub fn enabled(mut self, enabled: bool) -> Self {
263 self.enabled = enabled;
264 self
265 }
266
267 pub fn label(&self) -> &str {
269 &self.label
270 }
271
272 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#[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
297pub 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 pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
319 self.leading = Some(icon.into());
320 self
321 }
322
323 pub fn trailing(mut self, icon: impl Into<MenuIcon>) -> Self {
325 self.trailing = Some(icon.into());
326 self
327 }
328
329 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
331 self.shortcut = Some(shortcut.into());
332 self
333 }
334
335 pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
337 self.supporting = Some(supporting.into());
338 self
339 }
340
341 pub fn enabled(mut self, enabled: bool) -> Self {
343 self.enabled = enabled;
344 self
345 }
346
347 pub fn label(&self) -> &str {
349 &self.label
350 }
351
352 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#[derive(Clone, Debug, PartialEq)]
367pub struct MenuGroup {
368 pub(super) label: Option<String>,
369 pub(super) children: Vec<MenuNode>,
370}
371
372pub fn menu_group(children: Vec<MenuNode>) -> MenuGroup {
374 MenuGroup {
375 label: None,
376 children,
377 }
378}
379
380impl MenuGroup {
381 pub fn label(mut self, label: impl Into<String>) -> Self {
383 self.label = Some(label.into());
384 self
385 }
386
387 pub fn children(&self) -> &[MenuNode] {
389 &self.children
390 }
391
392 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#[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
414pub 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 pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
428 self.leading = Some(icon.into());
429 self
430 }
431
432 pub fn enabled(mut self, enabled: bool) -> Self {
434 self.enabled = enabled;
435 self
436 }
437
438 pub fn label(&self) -> &str {
440 &self.label
441 }
442
443 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#[derive(Clone, Debug, PartialEq, Eq)]
461pub enum MenuAction {
462 Press,
464 Select(String),
466 Toggle(bool),
468}
469
470#[derive(Clone, Debug, PartialEq, Eq)]
472pub struct MenuSelection {
473 pub index: usize,
475 pub label: String,
477 pub action: MenuAction,
479}
480
481impl MenuSelection {
482 pub fn value(&self) -> Option<&str> {
484 match &self.action {
485 MenuAction::Select(value) => Some(value.as_str()),
486 _ => None,
487 }
488 }
489
490 pub fn checked(&self) -> Option<bool> {
493 match &self.action {
494 MenuAction::Toggle(checked) => Some(*checked),
495 _ => None,
496 }
497 }
498}
499
500pub 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}