Skip to main content

ashlar_command/
menu.rs

1//! The menu bar as plain, serializable data: one model for every renderer.
2//!
3//! An app builds a [`MenuSpec`] from its commands ([`MenuSpec::from_commands`]), adds what commands
4//! do not say (separators, check marks, OS roles), and hands it to a renderer: `ashlar-native`
5//! turns it into the OS menu bar (macOS, Windows), `ashlar-ui` draws it in the window (web,
6//! Linux, or by choice). Activating an item delivers its `id`, the command id for items built from
7//! commands, which the app runs through its one `execute` chokepoint.
8//!
9//! Renderers that keep live OS menus rebuild them only when [`MenuSpec::structure_hash`] changes
10//! (items added or removed, menu key equivalents changed) and otherwise patch labels, enabled and
11//! checked state in place when [`MenuSpec::state_hash`] changes.
12//!
13//! The model has no OS or UI types and is bounded: [`MAX_DEPTH`], [`MAX_NODES`], [`MAX_TEXT`]
14//! ([`MenuSpec::validate`]); walks stop at the depth limit instead of recursing without end.
15
16use std::collections::hash_map::DefaultHasher;
17use std::hash::{Hash, Hasher};
18
19use serde::{Deserialize, Serialize};
20
21use crate::CommandInfo;
22use crate::shortcut::{self, Chord, Key};
23
24/// Deepest submenu nesting accepted (the top-level menu counts as 1).
25pub const MAX_DEPTH: usize = 8;
26/// Most nodes in one menu bar.
27pub const MAX_NODES: usize = 10_000;
28/// Longest label or id.
29pub const MAX_TEXT: usize = 256;
30
31/// Items the OS performs itself (no command dispatch). Edit roles (`Undo` … `SelectAll`) go to
32/// the OS's focused native control (a web view, a native text field); apps that draw their own
33/// text fields use plain items for those instead. Renderers leave out roles their platform lacks
34/// (an in-window menu bar draws none).
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub enum Role {
38    /// The standard About panel (name and version from the bundle).
39    About,
40    /// macOS Services submenu.
41    Services,
42    Hide,
43    HideOthers,
44    ShowAll,
45    /// Quit at once. Apps with unsaved documents use a plain `app.quit` item instead and ask first.
46    Quit,
47    CloseWindow,
48    Minimize,
49    Zoom,
50    Fullscreen,
51    BringAllToFront,
52    Undo,
53    Redo,
54    Cut,
55    Copy,
56    Paste,
57    SelectAll,
58}
59
60/// What a submenu is to the OS.
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub enum SubmenuRole {
64    /// macOS lists open windows here.
65    Window,
66    /// macOS adds its help search field here.
67    Help,
68}
69
70/// A clickable item. Activating it delivers `id`.
71#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct Item {
74    /// Unique in the bar; the command id for items built from commands (`file.save`).
75    pub id: String,
76    pub label: String,
77    /// Portable shortcut (`Mod+Shift+S`, alternatives separated by ` / `). Only menu-safe ones
78    /// become native key equivalents ([`Chord::is_menu_safe`]); the rest stay the app's window
79    /// shortcuts.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub accelerator: Option<String>,
82    #[serde(default = "yes")]
83    pub enabled: bool,
84    /// Why the item is disabled (shown on hover where the renderer can).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub disabled_reason: Option<String>,
87    /// `Some` makes it a check item.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub checked: Option<bool>,
90}
91
92fn yes() -> bool {
93    true
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct Submenu {
99    pub label: String,
100    #[serde(default = "yes")]
101    pub enabled: bool,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub role: Option<SubmenuRole>,
104    pub children: Vec<MenuNode>,
105}
106
107/// One node of the menu tree.
108#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
109#[serde(tag = "type", rename_all = "camelCase")]
110pub enum MenuNode {
111    Item(Item),
112    Separator,
113    Submenu(Submenu),
114    /// An OS-provided item; `label` overrides the OS's text (translations).
115    Role {
116        role: Role,
117        #[serde(default, skip_serializing_if = "Option::is_none")]
118        label: Option<String>,
119    },
120}
121
122/// The whole menu bar: top-level menus in order. On macOS the first is the application menu (its
123/// title is replaced by the app name by the OS).
124#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
125pub struct MenuSpec {
126    pub menus: Vec<Submenu>,
127}
128
129impl Chord {
130    /// Whether the chord may become a native menu key equivalent. Only ones with ⌘ / Ctrl (or a
131    /// function key) do: a bare `V` or an Alt/Shift-only key would be swallowed by the menu while
132    /// the user types into a text field, so those stay the app's own window shortcuts.
133    pub fn is_menu_safe(&self) -> bool {
134        self.mods.command || self.mods.ctrl || matches!(self.key, Key::F(_))
135    }
136}
137
138/// The menu key equivalent of portable shortcut text: `None` when it does not parse or is not
139/// menu-safe ([`Chord::is_menu_safe`]). Of alternatives (`Mod+Y / Mod+Shift+Z`) the first counts.
140pub fn menu_chord(text: &str) -> Option<Chord> {
141    let first = text.split(" / ").next()?;
142    Chord::parse(first).ok().filter(Chord::is_menu_safe)
143}
144
145impl Item {
146    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
147        Self { id: id.into(), label: label.into(), accelerator: None, enabled: true, disabled_reason: None, checked: None }
148    }
149
150    pub fn accelerator(mut self, text: impl Into<String>) -> Self {
151        self.accelerator = Some(text.into());
152        self
153    }
154
155    pub fn enabled(mut self, enabled: bool) -> Self {
156        self.enabled = enabled;
157        self
158    }
159
160    pub fn checked(mut self, checked: bool) -> Self {
161        self.checked = Some(checked);
162        self
163    }
164
165    /// The native key equivalent, when the accelerator has one ([`menu_chord`]).
166    pub fn menu_chord(&self) -> Option<Chord> {
167        self.accelerator.as_deref().and_then(menu_chord)
168    }
169}
170
171impl From<Item> for MenuNode {
172    fn from(i: Item) -> Self {
173        MenuNode::Item(i)
174    }
175}
176
177impl From<Submenu> for MenuNode {
178    fn from(s: Submenu) -> Self {
179        MenuNode::Submenu(s)
180    }
181}
182
183impl MenuNode {
184    pub fn role(role: Role) -> Self {
185        MenuNode::Role { role, label: None }
186    }
187}
188
189impl Submenu {
190    pub fn new(label: impl Into<String>, children: Vec<MenuNode>) -> Self {
191        Self { label: label.into(), enabled: true, role: None, children }
192    }
193
194    pub fn with_role(mut self, role: SubmenuRole) -> Self {
195        self.role = Some(role);
196        self
197    }
198}
199
200/// Where a node sits: indices from the top-level menu down.
201pub type Path = Vec<usize>;
202
203impl MenuSpec {
204    pub fn new(menus: Vec<Submenu>) -> Self {
205        Self { menus }
206    }
207
208    /// A menu bar mirrored from a command list: each command with a non-empty `menu` path goes
209    /// into the submenu that path names (`["File", "Export"]` = File ▸ Export), in list order,
210    /// with its label, shortcut (normalized when it parses), enabled state and disabled reason.
211    /// Top-level menus follow `order`; menus not in it come after, in first-seen order. Paths
212    /// deeper than [`MAX_DEPTH`] are cut there. Separators, check marks and roles are the app's to
213    /// add ([`Self::separator_before`], [`Self::set_checked`]).
214    pub fn from_commands(commands: &[CommandInfo], order: &[&str]) -> Self {
215        Self::from_items(
216            commands.iter().map(|c| {
217                let mut item = Item::new(c.id.clone(), c.label.clone()).enabled(c.enabled);
218                item.accelerator = c.shortcut.as_deref().map(|s| shortcut::normalize(s).unwrap_or_else(|| s.to_owned()));
219                item.disabled_reason = if c.enabled { None } else { c.disabled_reason.clone() };
220                (c.menu.as_slice(), item)
221            }),
222            order,
223        )
224    }
225
226    /// A menu bar from items with their menu paths, for apps whose menu entries carry more than a
227    /// [`CommandInfo`] (check marks, translated titles): each `(path, item)` goes into the submenu
228    /// `path` names (`["File", "Export"]` = File ▸ Export), in order, each submenu created where
229    /// its first item appears. Entries with an empty path are skipped; paths deeper than
230    /// [`MAX_DEPTH`] are cut there. Top-level menus follow `order`; menus not in it come after, in
231    /// first-seen order.
232    pub fn from_items<P: AsRef<[String]>>(entries: impl IntoIterator<Item = (P, Item)>, order: &[&str]) -> Self {
233        let mut spec = MenuSpec::default();
234        for (path, item) in entries {
235            let Some((top, rest)) = path.as_ref().split_first() else { continue };
236            let rest = rest.get(..MAX_DEPTH.saturating_sub(1)).unwrap_or(rest);
237            let idx = match spec.menus.iter().position(|m| &m.label == top) {
238                Some(i) => i,
239                None => {
240                    spec.menus.push(Submenu::new(top.clone(), vec![]));
241                    spec.menus.len() - 1
242                }
243            };
244            let Some(menu) = spec.menus.get_mut(idx) else { continue };
245            let Some(children) = submenu_children(&mut menu.children, rest) else { continue };
246            children.push(item.into());
247        }
248        let rank = |label: &str| order.iter().position(|o| *o == label).unwrap_or(order.len());
249        spec.menus.sort_by_key(|m| rank(&m.label));
250        spec
251    }
252
253    /// Every item, depth first.
254    pub fn items(&self) -> Vec<&Item> {
255        let mut out = vec![];
256        self.walk(&mut |_, n| {
257            if let MenuNode::Item(i) = n {
258                out.push(i);
259            }
260        });
261        out
262    }
263
264    /// The item with `id` (for items built from commands, the command id).
265    pub fn find(&self, id: &str) -> Option<&Item> {
266        self.items().into_iter().find(|i| i.id == id)
267    }
268
269    /// Visit every node with its path, depth first, at most [`MAX_DEPTH`] deep.
270    pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&[usize], &'a MenuNode)) {
271        fn rec<'a>(nodes: &'a [MenuNode], path: &mut Vec<usize>, f: &mut dyn FnMut(&[usize], &'a MenuNode)) {
272            if path.len() > MAX_DEPTH {
273                return;
274            }
275            for (i, n) in nodes.iter().enumerate() {
276                path.push(i);
277                f(path, n);
278                if let MenuNode::Submenu(s) = n {
279                    rec(&s.children, path, f);
280                }
281                path.pop();
282            }
283        }
284        for (i, m) in self.menus.iter().enumerate() {
285            rec(&m.children, &mut vec![i], f);
286        }
287    }
288
289    /// The children lists of every menu and submenu, depth first, until `f` returns true.
290    fn any_children_mut(&mut self, f: &mut dyn FnMut(&mut Vec<MenuNode>) -> bool) -> bool {
291        fn rec(nodes: &mut Vec<MenuNode>, depth: usize, f: &mut dyn FnMut(&mut Vec<MenuNode>) -> bool) -> bool {
292            if depth > MAX_DEPTH {
293                return false;
294            }
295            if f(nodes) {
296                return true;
297            }
298            nodes.iter_mut().any(|n| match n {
299                MenuNode::Submenu(s) => rec(&mut s.children, depth + 1, f),
300                _ => false,
301            })
302        }
303        self.menus.iter_mut().any(|m| rec(&mut m.children, 1, f))
304    }
305
306    /// Insert a separator before the item `id`, unless it is first in its menu or already follows
307    /// one. Returns whether the item was found.
308    pub fn separator_before(&mut self, id: &str) -> bool {
309        self.any_children_mut(&mut |nodes| {
310            let Some(i) = nodes.iter().position(|n| matches!(n, MenuNode::Item(it) if it.id == id)) else { return false };
311            if i > 0 && !matches!(nodes.get(i - 1), Some(MenuNode::Separator)) {
312                nodes.insert(i, MenuNode::Separator);
313            }
314            true
315        })
316    }
317
318    /// Set the check mark of the item `id` (`None`: a plain item again). Returns whether it was
319    /// found.
320    pub fn set_checked(&mut self, id: &str, checked: Option<bool>) -> bool {
321        self.any_children_mut(&mut |nodes| {
322            for n in nodes.iter_mut() {
323                if let MenuNode::Item(it) = n
324                    && it.id == id
325                {
326                    it.checked = checked;
327                    return true;
328                }
329            }
330            false
331        })
332    }
333
334    /// Fingerprint of everything that needs a native rebuild when it changes: the tree's shape,
335    /// item ids, check-item-ness, roles and menu key equivalents. Labels, enabled and checked
336    /// state are patched in place instead.
337    pub fn structure_hash(&self) -> u64 {
338        let mut h = DefaultHasher::new();
339        fn rec(nodes: &[MenuNode], depth: usize, h: &mut DefaultHasher) {
340            if depth > MAX_DEPTH {
341                return;
342            }
343            for n in nodes {
344                match n {
345                    MenuNode::Item(i) => {
346                        0u8.hash(h);
347                        i.id.hash(h);
348                        i.checked.is_some().hash(h);
349                        i.menu_chord().hash(h);
350                    }
351                    MenuNode::Separator => 1u8.hash(h),
352                    MenuNode::Submenu(s) => {
353                        2u8.hash(h);
354                        s.role.hash(h);
355                        rec(&s.children, depth + 1, h);
356                        3u8.hash(h);
357                    }
358                    MenuNode::Role { role, label } => {
359                        4u8.hash(h);
360                        role.hash(h);
361                        // OS items can't be relabelled in place everywhere.
362                        label.hash(h);
363                    }
364                }
365            }
366        }
367        for m in &self.menus {
368            5u8.hash(&mut h);
369            m.role.hash(&mut h);
370            rec(&m.children, 1, &mut h);
371        }
372        h.finish()
373    }
374
375    /// Fingerprint of what is patched in place: labels, enabled and checked state, disabled
376    /// reasons.
377    pub fn state_hash(&self) -> u64 {
378        let mut h = DefaultHasher::new();
379        for m in &self.menus {
380            (&m.label, m.enabled).hash(&mut h);
381        }
382        self.walk(&mut |_, n| match n {
383            MenuNode::Item(i) => (&i.label, i.enabled, i.checked, &i.disabled_reason).hash(&mut h),
384            MenuNode::Submenu(s) => (&s.label, s.enabled).hash(&mut h),
385            _ => {}
386        });
387        h.finish()
388    }
389
390    /// Check the spec: unique non-empty ids, bounded size, depth and text, parseable
391    /// accelerators. Returns every problem found.
392    pub fn validate(&self) -> Result<(), Vec<String>> {
393        let mut problems = vec![];
394        let mut ids = std::collections::HashSet::new();
395        let mut nodes = 0usize;
396        /// Nesting depth below `nodes`, counted no further than one past the limit.
397        fn depth(nodes: &[MenuNode], d: usize) -> usize {
398            if d > MAX_DEPTH {
399                return d;
400            }
401            nodes.iter().map(|n| if let MenuNode::Submenu(s) = n { depth(&s.children, d + 1) } else { d }).max().unwrap_or(d)
402        }
403        for m in &self.menus {
404            if depth(&m.children, 1) > MAX_DEPTH {
405                problems.push(format!("menu `{}` nests deeper than {MAX_DEPTH}", clip(&m.label)));
406            }
407            if m.label.chars().count() > MAX_TEXT {
408                problems.push("a menu title is too long".into());
409            }
410        }
411        self.walk(&mut |_, n| {
412            nodes += 1;
413            match n {
414                MenuNode::Item(i) => {
415                    if i.id.is_empty() || i.id.chars().count() > MAX_TEXT {
416                        problems.push(format!("item `{}` has an empty or too long id", clip(&i.label)));
417                    } else if !ids.insert(i.id.as_str()) {
418                        problems.push(format!("id `{}` is used twice", clip(&i.id)));
419                    }
420                    if i.label.chars().count() > MAX_TEXT {
421                        problems.push(format!("item `{}` has a too long label", clip(&i.id)));
422                    }
423                    if let Some(a) = &i.accelerator
424                        && let Err(e) = shortcut::parse_list(a)
425                    {
426                        problems.push(format!("item `{}`: {e}", clip(&i.id)));
427                    }
428                }
429                MenuNode::Submenu(s) if s.label.chars().count() > MAX_TEXT => problems.push("a submenu title is too long".into()),
430                _ => {}
431            }
432        });
433        if nodes > MAX_NODES {
434            problems.push(format!("{nodes} nodes; at most {MAX_NODES}"));
435        }
436        if problems.is_empty() { Ok(()) } else { Err(problems) }
437    }
438
439    /// The node at `path`, if any.
440    pub fn node(&self, path: &[usize]) -> Option<&MenuNode> {
441        let (top, rest) = path.split_first()?;
442        let mut nodes = &self.menus.get(*top)?.children;
443        let (last, mid) = rest.split_last()?;
444        for i in mid {
445            match nodes.get(*i)? {
446                MenuNode::Submenu(s) => nodes = &s.children,
447                _ => return None,
448            }
449        }
450        nodes.get(*last)
451    }
452}
453
454/// The children of the submenu at `path` below `children`, created where missing.
455fn submenu_children<'a>(children: &'a mut Vec<MenuNode>, path: &[String]) -> Option<&'a mut Vec<MenuNode>> {
456    let Some((name, rest)) = path.split_first() else { return Some(children) };
457    let pos = match children.iter().position(|n| matches!(n, MenuNode::Submenu(s) if &s.label == name)) {
458        Some(p) => p,
459        None => {
460            children.push(Submenu::new(name.clone(), vec![]).into());
461            children.len() - 1
462        }
463    };
464    match children.get_mut(pos)? {
465        MenuNode::Submenu(s) => submenu_children(&mut s.children, rest),
466        _ => None,
467    }
468}
469
470fn clip(s: &str) -> String {
471    s.chars().take(64).collect()
472}
473
474#[cfg(test)]
475mod tests;