Skip to main content

kui_core/
menu.rs

1//! Menus as data: the rows of a context menu or the application menu bar,
2//! what the window has open, and what a host still has to do after a row
3//! is chosen.
4//!
5//! An app meets this module through `Ui::open_menu` / `Core::open_menu`
6//! (a [`Menu`] over a node, usually from a `contextmenu` event), through
7//! `Core::declare_menu_bar` (a [`MenuBar`] of [`BarMenu`]s), and through
8//! the `menu` event a chosen row posts on the node the menu was about. A
9//! host that holds the core drains [`MenuAction`]s with
10//! `Core::take_menu_actions`: the clipboard work the core cannot do itself.
11//! Nothing here draws; the stock renderer builds ordinary nodes into the
12//! frame, and a platform that owns menus (the macOS menu bar) gets the
13//! same list.
14//!
15//! ```rust
16//! use kui_core::{
17//!     Accel, BarMenu, Core, Key, Menu, MenuAction, MenuBar, MenuItem, MenuRole, Vec2,
18//! };
19//!
20//! let bar = MenuBar::new(vec![
21//!     BarMenu::new("File", vec![
22//!         MenuItem::new("Open...").id("open").accel("mod+o"),
23//!         MenuItem::separator(),
24//!         MenuItem::new("Quit").id("quit"),
25//!     ]),
26//!     BarMenu::new("Edit", vec![
27//!         MenuItem::role(MenuRole::Cut),
28//!         MenuItem::role(MenuRole::Copy),
29//!         MenuItem::role(MenuRole::Paste),
30//!     ]),
31//! ]);
32//! let mut core = Core::new();
33//! core.declare_menu_bar(bar);
34//! assert_eq!(core.menu_bar().map(|b| b.menus.len()), Some(2));
35//!
36//! // A context menu over a node, at the point the press landed.
37//! let items = vec![
38//!     MenuItem::new("Inspect").id("inspect"),
39//!     MenuItem::new("Delete").id("delete").enabled(false),
40//! ];
41//! core.open_menu(Menu::new(Key::ROOT, Vec2::new(40.0, 30.0), items));
42//!
43//! // After a row is chosen, the host finishes what the core cannot.
44//! for action in core.take_menu_actions() {
45//!     match action {
46//!         MenuAction::SetClipboard { text, .. } => println!("copy {text}"),
47//!         MenuAction::Paste => println!("read the clipboard"),
48//!         other => println!("{other:?}"),
49//!     }
50//! }
51//!
52//! // Accelerators are display text; `Accel` parses them for a native bar.
53//! let accel = Accel::parse("mod+shift+s").unwrap();
54//! assert!(accel.mods.shift);
55//! ```
56
57use crate::geom::Vec2;
58use crate::key::Key;
59use crate::tree::OriginId;
60use crate::value::Value;
61
62/// What an item *means*, as far as anything outside the app is concerned.
63///
64/// The roles exist for two reasons and neither is decoration. A host with
65/// a native menu maps them onto its own standard items, so Copy is the
66/// platform's Copy — its wording, its accelerator, its position; and the
67/// core acts on the ones it can act on without asking anybody
68/// ([`MenuRole::SelectAll`]) or with one round trip through the host (the
69/// clipboard three). [`MenuRole::Custom`] is an item the app invented,
70/// which only the app can perform.
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
72pub enum MenuRole {
73    /// The app's own item: choosing it posts the item's payload and the
74    /// core does nothing else.
75    #[default]
76    Custom,
77    /// A divider. Never focusable, never chosen, no payload.
78    Separator,
79    Cut,
80    Copy,
81    Paste,
82    SelectAll,
83    /// Show the platform's definition/Look Up panel for the selection.
84    /// The core cannot draw one: with no host to answer it, the item is
85    /// not offered.
86    LookUp,
87}
88
89impl MenuRole {
90    /// Every role, in wire order: the index a binding that spells roles as
91    /// numbers sends (C's `KUI_MENU_*`), pinned there by name. Append-only,
92    /// like every list a C enum restates.
93    pub const ALL: [MenuRole; 7] = [
94        MenuRole::Custom,
95        MenuRole::Separator,
96        MenuRole::Cut,
97        MenuRole::Copy,
98        MenuRole::Paste,
99        MenuRole::SelectAll,
100        MenuRole::LookUp,
101    ];
102
103    /// The role a wire name spells, for the bindings that take roles as
104    /// strings: the inverse of [`Self::name`], so a binding cannot accept
105    /// a spelling the event will not report back. `None` for a name that
106    /// is no role.
107    pub fn from_name(name: &str) -> Option<MenuRole> {
108        Self::ALL.into_iter().find(|r| r.name() == name)
109    }
110
111    /// The wire name, for the bindings and the report.
112    pub fn name(self) -> &'static str {
113        match self {
114            MenuRole::Custom => "custom",
115            MenuRole::Separator => "separator",
116            MenuRole::Cut => "cut",
117            MenuRole::Copy => "copy",
118            MenuRole::Paste => "paste",
119            MenuRole::SelectAll => "selectAll",
120            MenuRole::LookUp => "lookUp",
121        }
122    }
123
124    /// The label the stock renderer draws when the item declares none.
125    /// A native renderer ignores this and uses the platform's wording,
126    /// which is the point of having a role at all.
127    pub fn default_label(self) -> &'static str {
128        match self {
129            MenuRole::Custom | MenuRole::Separator => "",
130            MenuRole::Cut => "Cut",
131            MenuRole::Copy => "Copy",
132            MenuRole::Paste => "Paste",
133            MenuRole::SelectAll => "Select All",
134            MenuRole::LookUp => "Look Up",
135        }
136    }
137
138    /// The shortcut the stock renderer draws beside the row when the item
139    /// declares none, spelled the way the platform spells it — Command on
140    /// macOS, Control elsewhere, the same split [`KeyMods::primary`] makes
141    /// for the key that produces it.
142    ///
143    /// Display only, like every accelerator here: the core binds nothing,
144    /// and the row only names the key the host is already handling. Empty
145    /// for the roles with no standard shortcut — `Custom` above all, since
146    /// an app's own accelerator is the app's to declare
147    /// ([`MenuItem::accel`]) — and empty for `LookUp`, whose shortcut
148    /// belongs to the one platform that has the panel and draws its own
149    /// menu anyway.
150    ///
151    /// [`KeyMods::primary`]: crate::input::KeyMods::primary
152    pub fn default_accel(self) -> &'static str {
153        let mac = cfg!(target_os = "macos");
154        match self {
155            MenuRole::Cut if mac => "⌘X",
156            MenuRole::Copy if mac => "⌘C",
157            MenuRole::Paste if mac => "⌘V",
158            MenuRole::SelectAll if mac => "⌘A",
159            MenuRole::Cut => "Ctrl+X",
160            MenuRole::Copy => "Ctrl+C",
161            MenuRole::Paste => "Ctrl+V",
162            MenuRole::SelectAll => "Ctrl+A",
163            MenuRole::Custom | MenuRole::Separator | MenuRole::LookUp => "",
164        }
165    }
166
167    /// Whether the core performs this itself, or with one hand from the
168    /// host. `false` is an item only the app can carry out.
169    pub fn is_builtin(self) -> bool {
170        !matches!(self, MenuRole::Custom | MenuRole::Separator)
171    }
172}
173
174/// One row of a menu.
175#[derive(Clone, Debug, PartialEq)]
176pub struct MenuItem {
177    /// What the row reads. Empty takes the role's default wording.
178    pub label: String,
179    pub role: MenuRole,
180    /// A disabled row is drawn dimmed, is not focusable, and cannot be
181    /// chosen — Paste with an empty clipboard, Copy with no selection.
182    /// Present rather than absent on purpose: a menu whose rows move
183    /// depending on what is possible is a menu nobody builds muscle
184    /// memory for.
185    pub enabled: bool,
186    /// Posted as the event payload when the row is chosen. A `Custom`
187    /// item without one posts its label.
188    pub id: Option<Value>,
189    /// Drawn with a checkmark, and the platform's own check state where a
190    /// host renders the menu itself. A setting the row *is* rather than a
191    /// command it runs — View ▸ Show Sidebar — and inert for every other
192    /// row, which is why it is a flag beside the label and not a role.
193    pub checked: bool,
194    /// Drawn right-aligned and dimmed; the core binds nothing to it. The
195    /// keyboard shortcut is the app's or the platform's, and an
196    /// accelerator here only says which one it is. A standard row that
197    /// declares none takes its role's ([`MenuRole::default_accel`]), the
198    /// way an empty label takes the role's wording.
199    pub accel: Option<String>,
200    /// The rows of the menu this row opens: empty for an ordinary row. A
201    /// row with a submenu is drawn with a chevron and is never chosen
202    /// itself — hovering it, clicking it, Enter or the Right arrow opens
203    /// its menu beside it, and Left or Escape closes it again — and what
204    /// a chosen row inside posts is that row's own `menu` event, on the
205    /// node the outermost menu is about (backlog F128). Nests to any
206    /// depth. `MenuItem::submenu(label, items)` builds one.
207    pub submenu: Vec<MenuItem>,
208}
209
210impl MenuItem {
211    /// A row from plain data: a map with `label`, `role` (a wire name;
212    /// absent is `custom`), `enabled` (default true), `checked` (default
213    /// false), `id`, `accel` and `items` (a submenu's rows, the same maps).
214    /// A custom row needs a label, since the label is what it posts when it
215    /// has no `id`. Every binding funnels its rows through here —
216    /// `openMenu`'s list and a menu bar's alike — so a row can never mean
217    /// two things.
218    pub fn from_value(v: &Value) -> Result<Self, String> {
219        let Value::Map(_) = v else {
220            return Err("each menu item is an object".into());
221        };
222        let role = match v.get_str("role") {
223            None => MenuRole::Custom,
224            Some(name) => MenuRole::from_name(name)
225                .ok_or_else(|| format!("unknown menu item role {name:?}"))?,
226        };
227        let label = v.get_str("label").unwrap_or_default().to_string();
228        if label.is_empty() && role == MenuRole::Custom {
229            return Err("a custom menu item needs a label".into());
230        }
231        Ok(MenuItem {
232            label,
233            role,
234            enabled: v.get_bool("enabled").unwrap_or(true),
235            checked: v.get_bool("checked").unwrap_or(false),
236            id: v.get("id").filter(|id| **id != Value::Null).cloned(),
237            accel: v.get_str("accel").map(str::to_string),
238            submenu: match v.get("items") {
239                None | Some(Value::Null) => Vec::new(),
240                Some(items) => Self::list_from_value(items)?,
241            },
242        })
243    }
244
245    /// A menu's rows from plain data: a list of [`Self::from_value`] maps.
246    pub fn list_from_value(v: &Value) -> Result<Vec<Self>, String> {
247        let Value::List(rows) = v else {
248            return Err("a menu's items are an array".into());
249        };
250        rows.iter().map(Self::from_value).collect()
251    }
252
253    /// The keys a row map may carry — everything [`Self::from_value`]
254    /// reads. A binding that drops the rest of a map on the floor checks
255    /// against this first and raises [`crate::diag::unknown_menu_item_key`]
256    /// for what it dropped, so `{label, disabled: true}` is not silently a
257    /// row that is enabled.
258    pub const KEYS: [&'static str; 7] = [
259        "label", "role", "enabled", "checked", "id", "accel", "items",
260    ];
261
262    /// The keys of `rows` — a list of row maps, as [`Self::list_from_value`]
263    /// takes — that no row reads, a submenu's rows' included: what a binding
264    /// raises [`crate::diag::unknown_menu_item_key`] for. A check of the
265    /// outer rows alone let `{label, disabled: true}` inside a submenu be
266    /// a row that is enabled (backlog RG150).
267    pub fn stray_keys(rows: &Value) -> Vec<String> {
268        let mut out = Vec::new();
269        Self::stray_into(rows, false, &mut out);
270        out
271    }
272
273    /// [`Self::stray_keys`] for a select's options: `items` is one of them,
274    /// since an option is chosen and never opens anything —
275    /// [`Self::options_from_value`] leaves it out.
276    pub fn stray_option_keys(options: &Value) -> Vec<String> {
277        let mut out = Vec::new();
278        Self::stray_into(options, true, &mut out);
279        out
280    }
281
282    fn stray_into(rows: &Value, options: bool, out: &mut Vec<String>) {
283        let Value::List(rows) = rows else { return };
284        for row in rows {
285            let Value::Map(fields) = row else { continue };
286            for (k, v) in fields.iter() {
287                if !Self::KEYS.contains(&k.as_str()) || (options && k == "items") {
288                    out.push(k.clone());
289                } else if k == "items" {
290                    Self::stray_into(v, false, out);
291                }
292            }
293        }
294    }
295
296    /// The name a binding reports a row's dropped keys under
297    /// (`diag::unknown_prop` routes it to `diag::unknown_menu_item_key`):
298    /// a row is not an element, so it is not in `schema::ELEMENTS`, and
299    /// the spelling is the type's in JSX (`MenuItemInput`).
300    pub const NAME: &'static str = "menuItem";
301
302    /// A select's options from plain data (`widgets::select_items` in the
303    /// bindings): a list whose entries are strings — an option by its
304    /// label, posting it — or [`Self::from_value`] maps, for an option
305    /// that posts an `id` of its own or is disabled. An option's `items`
306    /// are left out ([`Self::stray_option_keys`] reports them): an option
307    /// is chosen, never opened. An empty list is
308    /// refused: a select with nothing to choose from is a field that opens
309    /// a menu of no rows, which only Escape leaves.
310    pub fn options_from_value(v: &Value) -> Result<Vec<Self>, String> {
311        let Value::List(rows) = v else {
312            return Err("a select's options are an array".into());
313        };
314        if rows.is_empty() {
315            return Err("a select needs at least one option".into());
316        }
317        rows.iter()
318            .map(|row| match row {
319                Value::Str(label) if !label.is_empty() => Ok(Self::new(label.as_str())),
320                Value::Str(_) => Err("an option needs a label".into()),
321                other => Self::from_value(other).map(|mut option| {
322                    option.submenu = Vec::new();
323                    option
324                }),
325            })
326            .collect()
327    }
328
329    /// An item of the app's own, by label.
330    pub fn new(label: impl Into<String>) -> Self {
331        Self {
332            label: label.into(),
333            role: MenuRole::Custom,
334            enabled: true,
335            checked: false,
336            id: None,
337            accel: None,
338            submenu: Vec::new(),
339        }
340    }
341
342    /// One of the standard items, with the role's own wording.
343    pub fn role(role: MenuRole) -> Self {
344        Self {
345            label: String::new(),
346            role,
347            enabled: true,
348            checked: false,
349            id: None,
350            accel: None,
351            submenu: Vec::new(),
352        }
353    }
354
355    /// A row that opens a menu of `items` beside it: "Move to ▸", "Sort
356    /// by ▸". It is chosen through, never itself — see the `submenu`
357    /// field — so it needs no `id`, and an `accel` on it is not drawn (the
358    /// chevron is where it would go). A submenu with no rows is an ordinary
359    /// row ([`Self::has_submenu`]). Its rows want an `id` each: a row
360    /// without one posts its label, which a row of the same name in
361    /// another submenu posts too.
362    ///
363    /// ```rust
364    /// use kui_core::MenuItem;
365    ///
366    /// let sort = MenuItem::submenu("Sort by", vec![
367    ///     MenuItem::new("Name").id("sort.name").checked(true),
368    ///     MenuItem::new("Date modified").id("sort.date"),
369    /// ]);
370    /// assert!(sort.has_submenu());
371    /// ```
372    pub fn submenu(label: impl Into<String>, items: Vec<MenuItem>) -> Self {
373        Self {
374            submenu: items,
375            ..Self::new(label)
376        }
377    }
378
379    /// Whether the row opens a menu rather than being chosen (the
380    /// `submenu` field). A row declared with an empty submenu is an
381    /// ordinary row.
382    pub fn has_submenu(&self) -> bool {
383        !self.submenu.is_empty()
384    }
385
386    /// The row at `path` in `items`: `[2]` is the third row, `[2, 0]` the
387    /// first row of the third row's submenu. `None` past the end of any
388    /// level, for an empty path, and through a row with no submenu.
389    pub fn at_path<'a>(items: &'a [MenuItem], path: &[usize]) -> Option<&'a MenuItem> {
390        let (&last, outer) = path.split_last()?;
391        let mut level = items;
392        for &i in outer {
393            level = &level.get(i)?.submenu;
394        }
395        level.get(last)
396    }
397
398    pub fn separator() -> Self {
399        Self::role(MenuRole::Separator)
400    }
401
402    pub fn enabled(mut self, on: bool) -> Self {
403        self.enabled = on;
404        self
405    }
406
407    /// Draws a checkmark beside the row (and sets the platform's check
408    /// state where a host renders the menu).
409    pub fn checked(mut self, on: bool) -> Self {
410        self.checked = on;
411        self
412    }
413
414    pub fn id(mut self, id: impl Into<Value>) -> Self {
415        self.id = Some(id.into());
416        self
417    }
418
419    pub fn accel(mut self, a: impl Into<String>) -> Self {
420        self.accel = Some(a.into());
421        self
422    }
423
424    /// What the row reads: its own label, or the role's.
425    pub fn text(&self) -> &str {
426        if self.label.is_empty() {
427            self.role.default_label()
428        } else {
429            &self.label
430        }
431    }
432
433    /// The accelerator the row declares: its own, or the role's. `None` is
434    /// a row with neither, which is every `Custom` one the app did not
435    /// spell a shortcut for. As declared — [`Self::accel_label`] is what is
436    /// drawn.
437    pub fn accel_text(&self) -> Option<&str> {
438        match &self.accel {
439            Some(accel) => Some(accel),
440            None => Some(self.role.default_accel()).filter(|a| !a.is_empty()),
441        }
442    }
443
444    /// What the row draws on its right: [`Self::accel_text`] in the
445    /// platform's spelling when kui can parse it (`"mod+shift+n"` reads
446    /// `⇧⌘N` on a Mac and `Ctrl+Shift+N` elsewhere), and exactly as
447    /// written when it cannot (`"gd"`, an app's own hint). See
448    /// [`Accel::label`].
449    pub fn accel_label(&self) -> Option<std::borrow::Cow<'_, str>> {
450        self.accel_text().map(Accel::label)
451    }
452
453    /// Whether the row takes focus and can be chosen — or, for a row with
454    /// a submenu, opened.
455    pub fn selectable(&self) -> bool {
456        self.enabled && self.role != MenuRole::Separator
457    }
458
459    /// Whether the row at `path` can be chosen the way the drawn menu
460    /// reaches it: every row on the way enabled, the last one selectable
461    /// and opening nothing. A host's report of a row the menu could not
462    /// have shown it is refused rather than performed.
463    pub(crate) fn choosable_at(items: &[MenuItem], path: &[usize]) -> bool {
464        let mut level = items;
465        for (n, &i) in path.iter().enumerate() {
466            let Some(item) = level.get(i).filter(|item| item.selectable()) else {
467                return false;
468            };
469            if n + 1 == path.len() {
470                return !item.has_submenu();
471            }
472            level = &item.submenu;
473        }
474        false
475    }
476
477    /// Rewrites every accelerator in `items` that kui can parse into the
478    /// platform's own spelling ([`Accel::label`]) and leaves the rest
479    /// exactly as declared. What `declare_menu_bar` and `open_menu` do on
480    /// the way in, so the menu a host reads back (`Core::menu`,
481    /// `Core::menu_bar`) is the one the drawn menu shows, and a platform
482    /// menu parses the same string into the same key.
483    pub(crate) fn normalize_accels(items: &mut [MenuItem]) {
484        for item in items {
485            if let Some(accel) = &item.accel
486                && let std::borrow::Cow::Owned(display) = Accel::label(accel)
487            {
488                item.accel = Some(display);
489            }
490            Self::normalize_accels(&mut item.submenu);
491        }
492    }
493}
494
495/// The menu a window has open: its items, where it opened, and what it is
496/// about. One per window — opening a second closes the first, the way one
497/// selection closes the last.
498#[derive(Clone, Debug, PartialEq)]
499pub struct Menu {
500    /// The node the menu was opened over. Chosen items post their event
501    /// on it, so an app reads a menu the way it reads a click.
502    pub target: Key,
503    /// Where it opens, logical viewport px — the press point, which is
504    /// where every platform puts a context menu.
505    pub at: Vec2,
506    /// Who asked for it: the host, or the extension whose node it is
507    /// about. Carried onto the events the items post.
508    pub origin: OriginId,
509    pub items: Vec<MenuItem>,
510}
511
512impl Menu {
513    pub fn new(target: Key, at: Vec2, items: Vec<MenuItem>) -> Self {
514        Self {
515            target,
516            at,
517            origin: OriginId::HOST,
518            items,
519        }
520    }
521
522    pub fn origin(mut self, origin: OriginId) -> Self {
523        self.origin = origin;
524        self
525    }
526}
527
528/// What choosing an item leaves for the host to do, drained with
529/// [`crate::runtime::Core::take_menu_actions`] the way window and audio
530/// commands are.
531///
532/// The clipboard is the host's in this library — the runner already reads
533/// and writes it for Cmd-C/X/V — and nothing here changes that. The core
534/// works out *what* to copy, which is the half it is uniquely able to do,
535/// and hands over a string.
536#[derive(Clone, Debug, PartialEq)]
537pub enum MenuAction {
538    /// Put this on the system clipboard. Both Copy and Cut produce one;
539    /// Cut has already removed the text by the time it arrives.
540    ///
541    /// `html` is the same selection with the formatting the core knows
542    /// about (bold, italic, a span's declared colour), for a host that can
543    /// offer a second flavour. It is an addition to `text`, never a
544    /// replacement: a clipboard whose only flavour is HTML pastes markup
545    /// into every plain-text field on the machine.
546    SetClipboard { text: String, html: Option<String> },
547    /// Put this secret on the system clipboard the way a password manager
548    /// does: the text, marked concealed and transient —
549    /// `org.nspasteboard.ConcealedType` and `TransientType` on macOS,
550    /// excluded from monitoring, history and the cloud clipboard on
551    /// Windows, `x-kde-passwordManagerHint: secret` on Linux — so a
552    /// clipboard manager neither shows nor keeps it. Queued by
553    /// `Core::set_clipboard_secret`, never by a menu row. Plain text only:
554    /// a secret has no formatting to offer.
555    SetClipboardSecret { text: String },
556    /// Read the clipboard and deliver it as `InputEvent::Paste`: a
557    /// focused editor takes it as typing, the way it takes Cmd-V, and a
558    /// focused key sink hears it as `{kind:"text"}` — which is how an
559    /// app that owns its text gets a paste it asked for with
560    /// `Core::request_paste`. The core cannot read a clipboard, so Paste
561    /// is the one standard item it can only ask for. The answer carries
562    /// the pasteboard's markers ([`crate::input::ClipboardMarks`]), and an
563    /// answer that is a bare `InputEvent::Commit` is one that marked
564    /// nothing.
565    Paste,
566    /// Show the platform's definition panel for `text`, anchored at
567    /// `rect` (logical viewport px — the word's own box, which is what
568    /// macOS's `showDefinitionForAttributedString:atPoint:` wants). Both
569    /// the Look Up row and a force click over text produce one; a host
570    /// that cannot show a panel drops it, and is never offered the row in
571    /// the first place (`Core::set_lookup_available`).
572    LookUp {
573        text: String,
574        /// The **baseline origin of the selection's first line**, logical
575        /// viewport px — the point
576        /// `showDefinitionForAttributedString:atPoint:` takes and draws
577        /// the term back over. Not a box's corner: a box's bottom puts the
578        /// term a line low, and a multi-run selection's union puts it
579        /// under the last line while the panel shows the first.
580        at: crate::geom::Vec2,
581    },
582}
583
584// -- The application menu bar -----------------------------------------------
585// The bar is the same rows one level up: a list of menus, each a label and
586// the `MenuItem`s above, so an Edit menu's Copy is the *same item* the
587// context menu's Copy is and the core performs it the same way.
588
589/// One menu of the bar: what the bar reads, and what drops out of it.
590#[derive(Clone, Debug, Default, PartialEq)]
591pub struct BarMenu {
592    /// What the bar shows. On macOS the first menu is the application menu
593    /// and the platform titles that one with the app's own name, whatever
594    /// this says.
595    pub label: String,
596    pub items: Vec<MenuItem>,
597    /// A disabled menu is dimmed and opens nothing.
598    pub enabled: bool,
599}
600
601impl BarMenu {
602    pub fn new(label: impl Into<String>, items: Vec<MenuItem>) -> Self {
603        Self {
604            label: label.into(),
605            items,
606            enabled: true,
607        }
608    }
609
610    pub fn enabled(mut self, on: bool) -> Self {
611        self.enabled = on;
612        self
613    }
614}
615
616/// The application menu: what a frame declares, in order
617/// (`Core::declare_menu_bar`).
618///
619/// Declared and not commanded, like the window title: a frame that declares
620/// none leaves the last one in force, and a frame that declares an empty
621/// bar takes it away. Where the platform owns a menu bar the driver hands
622/// this over (macOS: `NSApp.mainMenu`); everywhere else
623/// [`crate::widgets::menu_bar`] draws it, and each of its titles opens the
624/// ordinary [`Menu`] machinery — so the dropdown, its keyboard, its
625/// dismissal and its access tree are the ones already built for the context
626/// menu.
627#[derive(Clone, Debug, Default, PartialEq)]
628pub struct MenuBar {
629    pub menus: Vec<BarMenu>,
630}
631
632impl MenuBar {
633    pub fn new(menus: Vec<BarMenu>) -> Self {
634        Self { menus }
635    }
636
637    /// A bar from plain data: a list of `{ label, items, enabled? }`,
638    /// whose `items` are the rows `openMenu` takes. A menu with no `items`
639    /// is a shape error and not an empty menu: the two read the same on
640    /// screen and only one of them was meant.
641    pub fn from_value(v: &Value) -> Result<Self, String> {
642        let Value::List(menus) = v else {
643            return Err("menu is an array of menus".into());
644        };
645        let mut out = Vec::with_capacity(menus.len());
646        for entry in menus {
647            let Value::Map(_) = entry else {
648                return Err("each menu is an object { label, items }".into());
649            };
650            let label = entry
651                .get_str("label")
652                .ok_or("each menu needs a label")?
653                .to_string();
654            let items = entry
655                .get("items")
656                .ok_or_else(|| format!("menu entry `{label}` needs `items` (a list of rows)"))?;
657            out.push(BarMenu {
658                label,
659                items: MenuItem::list_from_value(items)?,
660                enabled: entry.get_bool("enabled").unwrap_or(true),
661            });
662        }
663        Ok(Self::new(out))
664    }
665
666    /// [`MenuItem::stray_keys`] over every menu's rows.
667    pub fn stray_keys(v: &Value) -> Vec<String> {
668        let Value::List(menus) = v else {
669            return Vec::new();
670        };
671        menus
672            .iter()
673            .filter_map(|menu| menu.get("items"))
674            .flat_map(MenuItem::stray_keys)
675            .collect()
676    }
677
678    /// Nothing declared: the bar the platform is asked to take away.
679    pub fn is_empty(&self) -> bool {
680        self.menus.is_empty()
681    }
682
683    /// The item at `(menu, item)`, if it is there.
684    pub fn item(&self, menu: usize, item: usize) -> Option<&MenuItem> {
685        self.menus.get(menu)?.items.get(item)
686    }
687
688    /// The item at `path` inside menu `menu`, through its submenus
689    /// ([`MenuItem::at_path`]).
690    pub fn item_at(&self, menu: usize, path: &[usize]) -> Option<&MenuItem> {
691        MenuItem::at_path(&self.menus.get(menu)?.items, path)
692    }
693}
694
695/// A keyboard shortcut, parsed out of the string an item declares.
696///
697/// The core binds nothing to it and never has ([`MenuItem::accel`] is
698/// display); this exists for the one consumer that needs the parts rather
699/// than the words — a platform menu bar, which sets a real key equivalent
700/// and then matches it before the window ever sees the key.
701///
702/// Both spellings parse, because both are written in the field: the
703/// portable one (`"mod+shift+s"`, where `mod` is Command on macOS and
704/// Control elsewhere) and the platform one a menu is read in (`"⇧⌘S"`,
705/// `"Ctrl+Shift+S"`). [`Accel::display`] is the second, which is what
706/// `declare_menu_bar` normalizes a declaration into so the drawn bar and
707/// the platform's read the same.
708#[derive(Clone, Copy, Debug, PartialEq, Eq)]
709pub struct Accel {
710    pub code: crate::input::KeyCode,
711    pub mods: crate::input::KeyMods,
712}
713
714impl Accel {
715    /// Parses `"mod+s"`, `"ctrl+shift+p"`, `"f5"`, `"⇧⌘S"`. `None` for
716    /// anything this vocabulary cannot name — the caller then leaves the
717    /// string alone and draws it as written, since a shortcut kui cannot
718    /// parse is still a shortcut the app's own keymap runs.
719    pub fn parse(s: &str) -> Option<Accel> {
720        use crate::input::{KeyCode, KeyMods};
721        let mut mods = KeyMods::default();
722        let mut rest = s.trim();
723        // The glyph spelling has no separators: ⌃⌥⇧⌘ in that order, then
724        // the key. Stripped first so `"⌘S"` and `"cmd+s"` land together.
725        loop {
726            let mut chars = rest.chars();
727            match chars.next() {
728                Some('\u{2303}') => mods.ctrl = true,
729                Some('\u{2325}') => mods.alt = true,
730                Some('\u{21e7}') => mods.shift = true,
731                Some('\u{2318}') => mods.super_key = true,
732                _ => break,
733            }
734            rest = chars.as_str();
735        }
736        let mut code = None;
737        for part in rest.split('+') {
738            let part = part.trim();
739            if part.is_empty() {
740                return None;
741            }
742            let lower = part.to_ascii_lowercase();
743            match lower.as_str() {
744                // The one token that is not a key on any keyboard:
745                // whichever modifier this platform puts shortcuts behind.
746                "mod" | "cmdorctrl" => {
747                    if cfg!(target_os = "macos") {
748                        mods.super_key = true;
749                    } else {
750                        mods.ctrl = true;
751                    }
752                }
753                "cmd" | "command" | "super" | "meta" | "win" => mods.super_key = true,
754                "ctrl" | "control" => mods.ctrl = true,
755                "alt" | "option" | "opt" => mods.alt = true,
756                "shift" => mods.shift = true,
757                // The key, and only one of them: `"s+s"` is a typo.
758                _ if code.is_some() => return None,
759                // A key as `display` writes it reads back as that key, so
760                // the menu that normalized `"mod+backspace"` into `⌘⌫`
761                // binds Backspace and not a `⌫` no keyboard types.
762                _ => {
763                    code = Some(if part.chars().count() == 1 {
764                        key_from_label(part).unwrap_or(KeyCode::Char(part.chars().next().unwrap()))
765                    } else {
766                        KeyCode::from_name(&lower).or_else(|| key_from_label(part))?
767                    });
768                }
769            }
770        }
771        let code = code?;
772        // A lock key turns a state rather than being a key a shortcut is
773        // held against: no menu bar takes `ctrl+capslock`.
774        let lock = matches!(
775            code,
776            KeyCode::CapsLock | KeyCode::NumLock | KeyCode::ScrollLock
777        );
778        (code != KeyCode::Unknown && !lock).then_some(Accel { code, mods })
779    }
780
781    /// How the platform writes it: the macOS glyph run (`⇧⌘S`, in AppKit's
782    /// order, no separators) or the spelled form (`Ctrl+Shift+S`).
783    pub fn display(&self) -> String {
784        let key = key_label(self.code);
785        if cfg!(target_os = "macos") {
786            let mut out = String::new();
787            for (on, glyph) in [
788                (self.mods.ctrl, '\u{2303}'),
789                (self.mods.alt, '\u{2325}'),
790                (self.mods.shift, '\u{21e7}'),
791                (self.mods.super_key, '\u{2318}'),
792            ] {
793                if on {
794                    out.push(glyph);
795                }
796            }
797            out.push_str(&key);
798            out
799        } else {
800            let mut parts = Vec::new();
801            for (on, name) in [
802                (self.mods.ctrl, "Ctrl"),
803                (self.mods.super_key, "Super"),
804                (self.mods.alt, "Alt"),
805                (self.mods.shift, "Shift"),
806            ] {
807                if on {
808                    parts.push(name);
809                }
810            }
811            parts.push(&key);
812            parts.join("+")
813        }
814    }
815
816    /// What a menu draws for the accelerator `spelled`: its
817    /// [`Accel::display`] when it parses, and `spelled` itself when it
818    /// does not — a shortcut kui cannot name is still one the app's own
819    /// keymap runs, and its spelling is the app's (ADR 0018, decision 7).
820    /// The drawn menu, the drawn bar and `open_menu` all read an
821    /// accelerator through this, so a portable `"mod+shift+n"` never
822    /// reaches the screen as written (backlog F127).
823    pub fn label(spelled: &str) -> std::borrow::Cow<'_, str> {
824        match Accel::parse(spelled) {
825            Some(a) => std::borrow::Cow::Owned(a.display()),
826            None => std::borrow::Cow::Borrowed(spelled),
827        }
828    }
829
830    /// The portable spelling, the one [`Accel::parse`] reads back to the
831    /// same chord on every platform: the modifiers held as `ctrl`,
832    /// `super`, `alt`, `shift` in that order, then the key by its wire
833    /// name (`"ctrl+shift+i"`, `"super+alt+f12"`). What a binding hands
834    /// out when it reads a chord back; [`Accel::display`] is what a
835    /// person reads.
836    pub fn spelling(&self) -> String {
837        let mut parts = Vec::new();
838        for (on, name) in [
839            (self.mods.ctrl, "ctrl"),
840            (self.mods.super_key, "super"),
841            (self.mods.alt, "alt"),
842            (self.mods.shift, "shift"),
843        ] {
844            if on {
845                parts.push(name.to_string());
846            }
847        }
848        parts.push(match self.code {
849            crate::input::KeyCode::Char(c) => c.to_ascii_lowercase().to_string(),
850            code => code.name(),
851        });
852        parts.join("+")
853    }
854
855    /// The key equivalent an `NSMenuItem` takes: the character, lowercased
856    /// (AppKit reads an uppercase one as Shift being held), or `None` for a
857    /// key AppKit spells with a function-key code this does not carry.
858    pub fn key_equivalent(&self) -> Option<String> {
859        match self.code {
860            crate::input::KeyCode::Char(c) => Some(c.to_lowercase().to_string()),
861            crate::input::KeyCode::Space => Some(" ".into()),
862            crate::input::KeyCode::Enter => Some("\r".into()),
863            crate::input::KeyCode::Tab => Some("\t".into()),
864            crate::input::KeyCode::Backspace => Some("\u{8}".into()),
865            crate::input::KeyCode::Delete => Some("\u{7f}".into()),
866            crate::input::KeyCode::Escape => Some("\u{1b}".into()),
867            _ => None,
868        }
869    }
870}
871
872/// What a menu writes a key as: the macOS glyph a user reads a shortcut by
873/// (`⇧`, `⌫`, `↩`), and the spelled word everywhere else. A key name is
874/// wire vocabulary (`"pageup"`); this is the label beside a row.
875fn key_label(code: crate::input::KeyCode) -> String {
876    key_label_on(code, cfg!(target_os = "macos"))
877}
878
879/// The key [`key_label`] writes as `label`, on either platform's spelling:
880/// what [`Accel::parse`] reads a displayed accelerator back through.
881fn key_from_label(label: &str) -> Option<crate::input::KeyCode> {
882    crate::input::KeyCode::named()
883        .iter()
884        .map(|(k, _)| *k)
885        .find(|&k| {
886            key_label_on(k, true) == label || key_label_on(k, false).eq_ignore_ascii_case(label)
887        })
888}
889
890fn key_label_on(code: crate::input::KeyCode, mac: bool) -> String {
891    use crate::input::KeyCode;
892    match code {
893        KeyCode::Char(c) => return c.to_uppercase().to_string(),
894        KeyCode::F(n) => return format!("F{n}"),
895        _ => {}
896    }
897    let s = match (code, mac) {
898        (KeyCode::Left, true) => "\u{2190}",
899        (KeyCode::Right, true) => "\u{2192}",
900        (KeyCode::Up, true) => "\u{2191}",
901        (KeyCode::Down, true) => "\u{2193}",
902        (KeyCode::Home, true) => "\u{2196}",
903        (KeyCode::End, true) => "\u{2198}",
904        (KeyCode::PageUp, true) => "\u{21de}",
905        (KeyCode::PageDown, true) => "\u{21df}",
906        (KeyCode::Backspace, true) => "\u{232b}",
907        (KeyCode::Delete, true) => "\u{2326}",
908        (KeyCode::Enter, true) => "\u{21a9}",
909        (KeyCode::Tab, true) => "\u{21e5}",
910        (KeyCode::Escape, true) => "\u{238b}",
911        (KeyCode::Space, true) => "\u{2423}",
912        (KeyCode::Left, _) => "Left",
913        (KeyCode::Right, _) => "Right",
914        (KeyCode::Up, _) => "Up",
915        (KeyCode::Down, _) => "Down",
916        (KeyCode::Home, _) => "Home",
917        (KeyCode::End, _) => "End",
918        (KeyCode::PageUp, _) => "Page Up",
919        (KeyCode::PageDown, _) => "Page Down",
920        (KeyCode::Backspace, _) => "Backspace",
921        (KeyCode::Delete, _) => "Delete",
922        (KeyCode::Enter, _) => "Enter",
923        (KeyCode::Tab, _) => "Tab",
924        (KeyCode::Escape, _) => "Esc",
925        (KeyCode::Space, _) => "Space",
926        (KeyCode::Insert, _) => "Insert",
927        (KeyCode::Clear, true) => "\u{2327}",
928        (KeyCode::Shift, true) => "\u{21e7}",
929        (KeyCode::Ctrl, true) => "\u{2303}",
930        (KeyCode::Alt, true) => "\u{2325}",
931        (KeyCode::Super, true) => "\u{2318}",
932        (KeyCode::CapsLock, true) => "\u{21ea}",
933        (KeyCode::PrintScreen, _) => "Print Screen",
934        (KeyCode::Pause, _) => "Pause",
935        (KeyCode::Menu, _) => "Menu",
936        (KeyCode::Clear, _) => "Clear",
937        (KeyCode::Shift, _) => "Shift",
938        (KeyCode::Ctrl, _) => "Ctrl",
939        (KeyCode::Alt, _) => "Alt",
940        (KeyCode::Super, _) => "Super",
941        (KeyCode::CapsLock, _) => "Caps Lock",
942        (KeyCode::NumLock, _) => "Num Lock",
943        (KeyCode::ScrollLock, _) => "Scroll Lock",
944        (KeyCode::MediaPlay, _) => "Play",
945        (KeyCode::MediaPause, _) => "Pause",
946        (KeyCode::MediaPlayPause, _) => "Play/Pause",
947        (KeyCode::MediaStop, _) => "Stop",
948        (KeyCode::MediaNext, _) => "Next Track",
949        (KeyCode::MediaPrev, _) => "Previous Track",
950        (KeyCode::MediaRecord, _) => "Record",
951        (KeyCode::MediaFastForward, _) => "Fast Forward",
952        (KeyCode::MediaRewind, _) => "Rewind",
953        (KeyCode::VolumeUp, _) => "Volume Up",
954        (KeyCode::VolumeDown, _) => "Volume Down",
955        (KeyCode::VolumeMute, _) => "Mute",
956        // Neither reachable from a parsed accelerator nor worth a lie.
957        (KeyCode::Unknown, _) | (KeyCode::Char(_), _) | (KeyCode::F(_), _) => "",
958    };
959    s.to_string()
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965    use crate::input::{KeyCode, KeyMods};
966
967    /// `ALL` is what C indexes, what Node's generated `MenuItemRole` is
968    /// spelled from and what `from_name` searches, so a variant it lacks
969    /// is one no binding can say. The match is exhaustive on purpose: a
970    /// variant added to the enum fails to compile here until it is placed
971    /// in `ALL` too.
972    #[test]
973    fn all_names_every_role_once() {
974        let mut seen = 0;
975        for role in MenuRole::ALL {
976            match role {
977                MenuRole::Custom
978                | MenuRole::Separator
979                | MenuRole::Cut
980                | MenuRole::Copy
981                | MenuRole::Paste
982                | MenuRole::SelectAll
983                | MenuRole::LookUp => seen += 1,
984            }
985            assert_eq!(MenuRole::from_name(role.name()), Some(role));
986            assert_eq!(
987                MenuRole::ALL.iter().filter(|r| **r == role).count(),
988                1,
989                "{role:?} is listed more than once"
990            );
991        }
992        assert_eq!(seen, MenuRole::ALL.len());
993    }
994
995    #[test]
996    fn mod_is_the_platform_primary() {
997        let a = Accel::parse("mod+s").unwrap();
998        assert_eq!(a.mods.super_key, cfg!(target_os = "macos"));
999        assert_eq!(a.mods.ctrl, !cfg!(target_os = "macos"));
1000        assert_eq!(a.code, KeyCode::Char('s'));
1001    }
1002
1003    #[test]
1004    fn the_glyph_spelling_parses_back() {
1005        let a = Accel::parse("\u{21e7}\u{2318}S").unwrap();
1006        assert_eq!(
1007            a,
1008            Accel {
1009                code: KeyCode::Char('S'),
1010                mods: KeyMods::NONE.with_shift().with_super(),
1011            }
1012        );
1013    }
1014
1015    #[test]
1016    fn a_named_key_parses_and_reads_back_capitalised() {
1017        let a = Accel::parse("ctrl+pageup").unwrap();
1018        assert_eq!(a.code, KeyCode::PageUp);
1019        let expect = if cfg!(target_os = "macos") {
1020            "\u{21de}"
1021        } else {
1022            "Page Up"
1023        };
1024        assert!(a.display().ends_with(expect), "{}", a.display());
1025    }
1026
1027    /// `spelling` is the readback a binding hands out, so it must parse
1028    /// to the chord it came from on every platform — including the one
1029    /// modifier `parse` names five ways.
1030    #[test]
1031    fn the_portable_spelling_parses_back_to_the_same_chord() {
1032        for s in [
1033            "ctrl+shift+i",
1034            "f12",
1035            "cmd+alt+d",
1036            "⌃⌥⇧⌘s",
1037            "mod+pageup",
1038            "shift+/",
1039        ] {
1040            let a = Accel::parse(s).unwrap();
1041            assert_eq!(
1042                Accel::parse(&a.spelling()),
1043                Some(a),
1044                "{s} -> {}",
1045                a.spelling()
1046            );
1047        }
1048        assert_eq!(
1049            Accel::parse("shift+ctrl+I").unwrap().spelling(),
1050            "ctrl+shift+i"
1051        );
1052        assert_eq!(Accel::parse("win+f12").unwrap().spelling(), "super+f12");
1053    }
1054
1055    /// What a menu draws parses back to the chord it was drawn from: the
1056    /// macOS bar binds its key equivalent off the normalized text, and a
1057    /// `⌘⌫` read as the character `⌫` bound a key no keyboard has.
1058    #[test]
1059    fn the_displayed_spelling_parses_back_to_the_same_chord() {
1060        for (code, _) in KeyCode::named() {
1061            let mods = KeyMods::NONE.with_shift().with_super();
1062            let a = Accel { code: *code, mods };
1063            // A modifier is no shortcut's key, and Play/Pause's pause key
1064            // is spelled as Pause's: that one reads back as Pause.
1065            if code.is_modifier() || *code == KeyCode::MediaPause {
1066                continue;
1067            }
1068            assert_eq!(Accel::parse(&a.display()), Some(a), "{}", a.display());
1069            for mac in [true, false] {
1070                let label = key_label_on(*code, mac);
1071                assert_eq!(key_from_label(&label), Some(*code), "{label}");
1072            }
1073        }
1074        let trash = Accel::parse(&Accel::label("mod+backspace")).unwrap();
1075        assert_eq!(trash.code, KeyCode::Backspace);
1076        assert_eq!(trash.key_equivalent().as_deref(), Some("\u{8}"));
1077    }
1078
1079    #[test]
1080    fn a_shortcut_kui_cannot_name_is_not_a_shortcut() {
1081        assert!(Accel::parse("mod+nope").is_none());
1082        // A lock key is no shortcut's key (backlog RG96).
1083        assert!(Accel::parse("ctrl+capslock").is_none());
1084        assert!(Accel::parse("numlock").is_none());
1085        assert!(Accel::parse("shift+scrolllock").is_none());
1086        assert!(Accel::parse("s+s").is_none());
1087        assert!(Accel::parse("").is_none());
1088    }
1089
1090    #[test]
1091    fn a_key_equivalent_is_lowercase() {
1092        // AppKit reads an uppercase key equivalent as Shift being held, so
1093        // ⌘S must be sent as "s" with the Command flag and nothing else.
1094        assert_eq!(
1095            Accel::parse("cmd+S").unwrap().key_equivalent().as_deref(),
1096            Some("s")
1097        );
1098    }
1099}