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}
201
202impl MenuItem {
203    /// A row from plain data: a map with `label`, `role` (a wire name;
204    /// absent is `custom`), `enabled` (default true), `checked` (default
205    /// false), `id` and `accel`. A custom row needs a label, since the
206    /// label is what it posts when it has no `id`. Every binding funnels
207    /// its rows through here — `openMenu`'s list and a menu bar's alike —
208    /// so a row can never mean two things.
209    pub fn from_value(v: &Value) -> Result<Self, String> {
210        let Value::Map(_) = v else {
211            return Err("each menu item is an object".into());
212        };
213        let role = match v.get_str("role") {
214            None => MenuRole::Custom,
215            Some(name) => MenuRole::from_name(name)
216                .ok_or_else(|| format!("unknown menu item role {name:?}"))?,
217        };
218        let label = v.get_str("label").unwrap_or_default().to_string();
219        if label.is_empty() && role == MenuRole::Custom {
220            return Err("a custom menu item needs a label".into());
221        }
222        Ok(MenuItem {
223            label,
224            role,
225            enabled: v.get_bool("enabled").unwrap_or(true),
226            checked: v.get_bool("checked").unwrap_or(false),
227            id: v.get("id").filter(|id| **id != Value::Null).cloned(),
228            accel: v.get_str("accel").map(str::to_string),
229        })
230    }
231
232    /// A menu's rows from plain data: a list of [`Self::from_value`] maps.
233    pub fn list_from_value(v: &Value) -> Result<Vec<Self>, String> {
234        let Value::List(rows) = v else {
235            return Err("a menu's items are an array".into());
236        };
237        rows.iter().map(Self::from_value).collect()
238    }
239
240    /// The keys a row map may carry — everything [`Self::from_value`]
241    /// reads. A binding that drops the rest of a map on the floor checks
242    /// against this first and raises [`crate::diag::unknown_menu_item_key`]
243    /// for what it dropped, so `{label, disabled: true}` is not silently a
244    /// row that is enabled.
245    pub const KEYS: [&'static str; 6] = ["label", "role", "enabled", "checked", "id", "accel"];
246
247    /// The name a binding reports a row's dropped keys under
248    /// (`diag::unknown_prop` routes it to `diag::unknown_menu_item_key`):
249    /// a row is not an element, so it is not in `schema::ELEMENTS`, and
250    /// the spelling is the type's in JSX (`MenuItemInput`).
251    pub const NAME: &'static str = "menuItem";
252
253    /// A select's options from plain data (`widgets::select_items` in the
254    /// bindings): a list whose entries are strings — an option by its
255    /// label, posting it — or [`Self::from_value`] maps, for an option
256    /// that posts an `id` of its own or is disabled. An empty list is
257    /// refused: a select with nothing to choose from is a field that opens
258    /// a menu of no rows, which only Escape leaves.
259    pub fn options_from_value(v: &Value) -> Result<Vec<Self>, String> {
260        let Value::List(rows) = v else {
261            return Err("a select's options are an array".into());
262        };
263        if rows.is_empty() {
264            return Err("a select needs at least one option".into());
265        }
266        rows.iter()
267            .map(|row| match row {
268                Value::Str(label) if !label.is_empty() => Ok(Self::new(label.as_str())),
269                Value::Str(_) => Err("an option needs a label".into()),
270                other => Self::from_value(other),
271            })
272            .collect()
273    }
274
275    /// An item of the app's own, by label.
276    pub fn new(label: impl Into<String>) -> Self {
277        Self {
278            label: label.into(),
279            role: MenuRole::Custom,
280            enabled: true,
281            checked: false,
282            id: None,
283            accel: None,
284        }
285    }
286
287    /// One of the standard items, with the role's own wording.
288    pub fn role(role: MenuRole) -> Self {
289        Self {
290            label: String::new(),
291            role,
292            enabled: true,
293            checked: false,
294            id: None,
295            accel: None,
296        }
297    }
298
299    pub fn separator() -> Self {
300        Self::role(MenuRole::Separator)
301    }
302
303    pub fn enabled(mut self, on: bool) -> Self {
304        self.enabled = on;
305        self
306    }
307
308    /// Draws a checkmark beside the row (and sets the platform's check
309    /// state where a host renders the menu).
310    pub fn checked(mut self, on: bool) -> Self {
311        self.checked = on;
312        self
313    }
314
315    pub fn id(mut self, id: impl Into<Value>) -> Self {
316        self.id = Some(id.into());
317        self
318    }
319
320    pub fn accel(mut self, a: impl Into<String>) -> Self {
321        self.accel = Some(a.into());
322        self
323    }
324
325    /// What the row reads: its own label, or the role's.
326    pub fn text(&self) -> &str {
327        if self.label.is_empty() {
328            self.role.default_label()
329        } else {
330            &self.label
331        }
332    }
333
334    /// What the row draws on its right: its own accelerator, or the
335    /// role's. `None` is a row with neither, which is every `Custom` one
336    /// the app did not spell a shortcut for.
337    pub fn accel_text(&self) -> Option<&str> {
338        match &self.accel {
339            Some(accel) => Some(accel),
340            None => Some(self.role.default_accel()).filter(|a| !a.is_empty()),
341        }
342    }
343
344    /// Whether the row takes focus and can be chosen.
345    pub fn selectable(&self) -> bool {
346        self.enabled && self.role != MenuRole::Separator
347    }
348}
349
350/// The menu a window has open: its items, where it opened, and what it is
351/// about. One per window — opening a second closes the first, the way one
352/// selection closes the last.
353#[derive(Clone, Debug, PartialEq)]
354pub struct Menu {
355    /// The node the menu was opened over. Chosen items post their event
356    /// on it, so an app reads a menu the way it reads a click.
357    pub target: Key,
358    /// Where it opens, logical viewport px — the press point, which is
359    /// where every platform puts a context menu.
360    pub at: Vec2,
361    /// Who asked for it: the host, or the extension whose node it is
362    /// about. Carried onto the events the items post.
363    pub origin: OriginId,
364    pub items: Vec<MenuItem>,
365}
366
367impl Menu {
368    pub fn new(target: Key, at: Vec2, items: Vec<MenuItem>) -> Self {
369        Self {
370            target,
371            at,
372            origin: OriginId::HOST,
373            items,
374        }
375    }
376
377    pub fn origin(mut self, origin: OriginId) -> Self {
378        self.origin = origin;
379        self
380    }
381}
382
383/// What choosing an item leaves for the host to do, drained with
384/// [`crate::runtime::Core::take_menu_actions`] the way window and audio
385/// commands are.
386///
387/// The clipboard is the host's in this library — the runner already reads
388/// and writes it for Cmd-C/X/V — and nothing here changes that. The core
389/// works out *what* to copy, which is the half it is uniquely able to do,
390/// and hands over a string.
391#[derive(Clone, Debug, PartialEq)]
392pub enum MenuAction {
393    /// Put this on the system clipboard. Both Copy and Cut produce one;
394    /// Cut has already removed the text by the time it arrives.
395    ///
396    /// `html` is the same selection with the formatting the core knows
397    /// about (bold, italic, a span's declared colour), for a host that can
398    /// offer a second flavour. It is an addition to `text`, never a
399    /// replacement: a clipboard whose only flavour is HTML pastes markup
400    /// into every plain-text field on the machine.
401    SetClipboard { text: String, html: Option<String> },
402    /// Put this secret on the system clipboard the way a password manager
403    /// does: the text, marked concealed and transient —
404    /// `org.nspasteboard.ConcealedType` and `TransientType` on macOS,
405    /// excluded from monitoring, history and the cloud clipboard on
406    /// Windows, `x-kde-passwordManagerHint: secret` on Linux — so a
407    /// clipboard manager neither shows nor keeps it. Queued by
408    /// `Core::set_clipboard_secret`, never by a menu row. Plain text only:
409    /// a secret has no formatting to offer.
410    SetClipboardSecret { text: String },
411    /// Read the clipboard and deliver it as `InputEvent::Paste`: a
412    /// focused editor takes it as typing, the way it takes Cmd-V, and a
413    /// focused key sink hears it as `{kind:"text"}` — which is how an
414    /// app that owns its text gets a paste it asked for with
415    /// `Core::request_paste`. The core cannot read a clipboard, so Paste
416    /// is the one standard item it can only ask for. The answer carries
417    /// the pasteboard's markers ([`crate::input::ClipboardMarks`]), and an
418    /// answer that is a bare `InputEvent::Commit` is one that marked
419    /// nothing.
420    Paste,
421    /// Show the platform's definition panel for `text`, anchored at
422    /// `rect` (logical viewport px — the word's own box, which is what
423    /// macOS's `showDefinitionForAttributedString:atPoint:` wants). Both
424    /// the Look Up row and a force click over text produce one; a host
425    /// that cannot show a panel drops it, and is never offered the row in
426    /// the first place (`Core::set_lookup_available`).
427    LookUp {
428        text: String,
429        /// The **baseline origin of the selection's first line**, logical
430        /// viewport px — the point
431        /// `showDefinitionForAttributedString:atPoint:` takes and draws
432        /// the term back over. Not a box's corner: a box's bottom puts the
433        /// term a line low, and a multi-run selection's union puts it
434        /// under the last line while the panel shows the first.
435        at: crate::geom::Vec2,
436    },
437}
438
439// -- The application menu bar -----------------------------------------------
440// The bar is the same rows one level up: a list of menus, each a label and
441// the `MenuItem`s above, so an Edit menu's Copy is the *same item* the
442// context menu's Copy is and the core performs it the same way.
443
444/// One menu of the bar: what the bar reads, and what drops out of it.
445#[derive(Clone, Debug, Default, PartialEq)]
446pub struct BarMenu {
447    /// What the bar shows. On macOS the first menu is the application menu
448    /// and the platform titles that one with the app's own name, whatever
449    /// this says.
450    pub label: String,
451    pub items: Vec<MenuItem>,
452    /// A disabled menu is dimmed and opens nothing.
453    pub enabled: bool,
454}
455
456impl BarMenu {
457    pub fn new(label: impl Into<String>, items: Vec<MenuItem>) -> Self {
458        Self {
459            label: label.into(),
460            items,
461            enabled: true,
462        }
463    }
464
465    pub fn enabled(mut self, on: bool) -> Self {
466        self.enabled = on;
467        self
468    }
469}
470
471/// The application menu: what a frame declares, in order
472/// (`Core::declare_menu_bar`).
473///
474/// Declared and not commanded, like the window title: a frame that declares
475/// none leaves the last one in force, and a frame that declares an empty
476/// bar takes it away. Where the platform owns a menu bar the driver hands
477/// this over (macOS: `NSApp.mainMenu`); everywhere else
478/// [`crate::widgets::menu_bar`] draws it, and each of its titles opens the
479/// ordinary [`Menu`] machinery — so the dropdown, its keyboard, its
480/// dismissal and its access tree are the ones already built for the context
481/// menu.
482#[derive(Clone, Debug, Default, PartialEq)]
483pub struct MenuBar {
484    pub menus: Vec<BarMenu>,
485}
486
487impl MenuBar {
488    pub fn new(menus: Vec<BarMenu>) -> Self {
489        Self { menus }
490    }
491
492    /// A bar from plain data: a list of `{ label, items, enabled? }`,
493    /// whose `items` are the rows `openMenu` takes. A menu with no `items`
494    /// is a shape error and not an empty menu: the two read the same on
495    /// screen and only one of them was meant.
496    pub fn from_value(v: &Value) -> Result<Self, String> {
497        let Value::List(menus) = v else {
498            return Err("menu is an array of menus".into());
499        };
500        let mut out = Vec::with_capacity(menus.len());
501        for entry in menus {
502            let Value::Map(_) = entry else {
503                return Err("each menu is an object { label, items }".into());
504            };
505            let label = entry
506                .get_str("label")
507                .ok_or("each menu needs a label")?
508                .to_string();
509            let items = entry
510                .get("items")
511                .ok_or_else(|| format!("menu entry `{label}` needs `items` (a list of rows)"))?;
512            out.push(BarMenu {
513                label,
514                items: MenuItem::list_from_value(items)?,
515                enabled: entry.get_bool("enabled").unwrap_or(true),
516            });
517        }
518        Ok(Self::new(out))
519    }
520
521    /// Nothing declared: the bar the platform is asked to take away.
522    pub fn is_empty(&self) -> bool {
523        self.menus.is_empty()
524    }
525
526    /// The item at `(menu, item)`, if it is there.
527    pub fn item(&self, menu: usize, item: usize) -> Option<&MenuItem> {
528        self.menus.get(menu)?.items.get(item)
529    }
530}
531
532/// A keyboard shortcut, parsed out of the string an item declares.
533///
534/// The core binds nothing to it and never has ([`MenuItem::accel`] is
535/// display); this exists for the one consumer that needs the parts rather
536/// than the words — a platform menu bar, which sets a real key equivalent
537/// and then matches it before the window ever sees the key.
538///
539/// Both spellings parse, because both are written in the field: the
540/// portable one (`"mod+shift+s"`, where `mod` is Command on macOS and
541/// Control elsewhere) and the platform one a menu is read in (`"⇧⌘S"`,
542/// `"Ctrl+Shift+S"`). [`Accel::display`] is the second, which is what
543/// `declare_menu_bar` normalizes a declaration into so the drawn bar and
544/// the platform's read the same.
545#[derive(Clone, Copy, Debug, PartialEq, Eq)]
546pub struct Accel {
547    pub code: crate::input::KeyCode,
548    pub mods: crate::input::KeyMods,
549}
550
551impl Accel {
552    /// Parses `"mod+s"`, `"ctrl+shift+p"`, `"f5"`, `"⇧⌘S"`. `None` for
553    /// anything this vocabulary cannot name — the caller then leaves the
554    /// string alone and draws it as written, since a shortcut kui cannot
555    /// parse is still a shortcut the app's own keymap runs.
556    pub fn parse(s: &str) -> Option<Accel> {
557        use crate::input::{KeyCode, KeyMods};
558        let mut mods = KeyMods::default();
559        let mut rest = s.trim();
560        // The glyph spelling has no separators: ⌃⌥⇧⌘ in that order, then
561        // the key. Stripped first so `"⌘S"` and `"cmd+s"` land together.
562        loop {
563            let mut chars = rest.chars();
564            match chars.next() {
565                Some('\u{2303}') => mods.ctrl = true,
566                Some('\u{2325}') => mods.alt = true,
567                Some('\u{21e7}') => mods.shift = true,
568                Some('\u{2318}') => mods.super_key = true,
569                _ => break,
570            }
571            rest = chars.as_str();
572        }
573        let mut code = None;
574        for part in rest.split('+') {
575            let part = part.trim();
576            if part.is_empty() {
577                return None;
578            }
579            let lower = part.to_ascii_lowercase();
580            match lower.as_str() {
581                // The one token that is not a key on any keyboard:
582                // whichever modifier this platform puts shortcuts behind.
583                "mod" | "cmdorctrl" => {
584                    if cfg!(target_os = "macos") {
585                        mods.super_key = true;
586                    } else {
587                        mods.ctrl = true;
588                    }
589                }
590                "cmd" | "command" | "super" | "meta" | "win" => mods.super_key = true,
591                "ctrl" | "control" => mods.ctrl = true,
592                "alt" | "option" | "opt" => mods.alt = true,
593                "shift" => mods.shift = true,
594                // The key, and only one of them: `"s+s"` is a typo.
595                _ if code.is_some() => return None,
596                _ => {
597                    code = Some(if part.chars().count() == 1 {
598                        KeyCode::Char(part.chars().next().unwrap())
599                    } else {
600                        KeyCode::from_name(&lower)?
601                    });
602                }
603            }
604        }
605        let code = code?;
606        // A lock key turns a state rather than being a key a shortcut is
607        // held against: no menu bar takes `ctrl+capslock`.
608        let lock = matches!(
609            code,
610            KeyCode::CapsLock | KeyCode::NumLock | KeyCode::ScrollLock
611        );
612        (code != KeyCode::Unknown && !lock).then_some(Accel { code, mods })
613    }
614
615    /// How the platform writes it: the macOS glyph run (`⇧⌘S`, in AppKit's
616    /// order, no separators) or the spelled form (`Ctrl+Shift+S`).
617    pub fn display(&self) -> String {
618        let key = key_label(self.code);
619        if cfg!(target_os = "macos") {
620            let mut out = String::new();
621            for (on, glyph) in [
622                (self.mods.ctrl, '\u{2303}'),
623                (self.mods.alt, '\u{2325}'),
624                (self.mods.shift, '\u{21e7}'),
625                (self.mods.super_key, '\u{2318}'),
626            ] {
627                if on {
628                    out.push(glyph);
629                }
630            }
631            out.push_str(&key);
632            out
633        } else {
634            let mut parts = Vec::new();
635            for (on, name) in [
636                (self.mods.ctrl, "Ctrl"),
637                (self.mods.super_key, "Super"),
638                (self.mods.alt, "Alt"),
639                (self.mods.shift, "Shift"),
640            ] {
641                if on {
642                    parts.push(name);
643                }
644            }
645            parts.push(&key);
646            parts.join("+")
647        }
648    }
649
650    /// The portable spelling, the one [`Accel::parse`] reads back to the
651    /// same chord on every platform: the modifiers held as `ctrl`,
652    /// `super`, `alt`, `shift` in that order, then the key by its wire
653    /// name (`"ctrl+shift+i"`, `"super+alt+f12"`). What a binding hands
654    /// out when it reads a chord back; [`Accel::display`] is what a
655    /// person reads.
656    pub fn spelling(&self) -> String {
657        let mut parts = Vec::new();
658        for (on, name) in [
659            (self.mods.ctrl, "ctrl"),
660            (self.mods.super_key, "super"),
661            (self.mods.alt, "alt"),
662            (self.mods.shift, "shift"),
663        ] {
664            if on {
665                parts.push(name.to_string());
666            }
667        }
668        parts.push(match self.code {
669            crate::input::KeyCode::Char(c) => c.to_ascii_lowercase().to_string(),
670            code => code.name(),
671        });
672        parts.join("+")
673    }
674
675    /// The key equivalent an `NSMenuItem` takes: the character, lowercased
676    /// (AppKit reads an uppercase one as Shift being held), or `None` for a
677    /// key AppKit spells with a function-key code this does not carry.
678    pub fn key_equivalent(&self) -> Option<String> {
679        match self.code {
680            crate::input::KeyCode::Char(c) => Some(c.to_lowercase().to_string()),
681            crate::input::KeyCode::Space => Some(" ".into()),
682            crate::input::KeyCode::Enter => Some("\r".into()),
683            crate::input::KeyCode::Tab => Some("\t".into()),
684            crate::input::KeyCode::Backspace => Some("\u{8}".into()),
685            crate::input::KeyCode::Delete => Some("\u{7f}".into()),
686            crate::input::KeyCode::Escape => Some("\u{1b}".into()),
687            _ => None,
688        }
689    }
690}
691
692/// What a menu writes a key as: the macOS glyph a user reads a shortcut by
693/// (`⇧`, `⌫`, `↩`), and the spelled word everywhere else. A key name is
694/// wire vocabulary (`"pageup"`); this is the label beside a row.
695fn key_label(code: crate::input::KeyCode) -> String {
696    use crate::input::KeyCode;
697    let mac = cfg!(target_os = "macos");
698    match code {
699        KeyCode::Char(c) => return c.to_uppercase().to_string(),
700        KeyCode::F(n) => return format!("F{n}"),
701        _ => {}
702    }
703    let s = match (code, mac) {
704        (KeyCode::Left, true) => "\u{2190}",
705        (KeyCode::Right, true) => "\u{2192}",
706        (KeyCode::Up, true) => "\u{2191}",
707        (KeyCode::Down, true) => "\u{2193}",
708        (KeyCode::Home, true) => "\u{2196}",
709        (KeyCode::End, true) => "\u{2198}",
710        (KeyCode::PageUp, true) => "\u{21de}",
711        (KeyCode::PageDown, true) => "\u{21df}",
712        (KeyCode::Backspace, true) => "\u{232b}",
713        (KeyCode::Delete, true) => "\u{2326}",
714        (KeyCode::Enter, true) => "\u{21a9}",
715        (KeyCode::Tab, true) => "\u{21e5}",
716        (KeyCode::Escape, true) => "\u{238b}",
717        (KeyCode::Space, true) => "\u{2423}",
718        (KeyCode::Left, _) => "Left",
719        (KeyCode::Right, _) => "Right",
720        (KeyCode::Up, _) => "Up",
721        (KeyCode::Down, _) => "Down",
722        (KeyCode::Home, _) => "Home",
723        (KeyCode::End, _) => "End",
724        (KeyCode::PageUp, _) => "Page Up",
725        (KeyCode::PageDown, _) => "Page Down",
726        (KeyCode::Backspace, _) => "Backspace",
727        (KeyCode::Delete, _) => "Delete",
728        (KeyCode::Enter, _) => "Enter",
729        (KeyCode::Tab, _) => "Tab",
730        (KeyCode::Escape, _) => "Esc",
731        (KeyCode::Space, _) => "Space",
732        (KeyCode::Insert, _) => "Insert",
733        (KeyCode::Clear, true) => "\u{2327}",
734        (KeyCode::Shift, true) => "\u{21e7}",
735        (KeyCode::Ctrl, true) => "\u{2303}",
736        (KeyCode::Alt, true) => "\u{2325}",
737        (KeyCode::Super, true) => "\u{2318}",
738        (KeyCode::CapsLock, true) => "\u{21ea}",
739        (KeyCode::PrintScreen, _) => "Print Screen",
740        (KeyCode::Pause, _) => "Pause",
741        (KeyCode::Menu, _) => "Menu",
742        (KeyCode::Clear, _) => "Clear",
743        (KeyCode::Shift, _) => "Shift",
744        (KeyCode::Ctrl, _) => "Ctrl",
745        (KeyCode::Alt, _) => "Alt",
746        (KeyCode::Super, _) => "Super",
747        (KeyCode::CapsLock, _) => "Caps Lock",
748        (KeyCode::NumLock, _) => "Num Lock",
749        (KeyCode::ScrollLock, _) => "Scroll Lock",
750        (KeyCode::MediaPlay, _) => "Play",
751        (KeyCode::MediaPause, _) => "Pause",
752        (KeyCode::MediaPlayPause, _) => "Play/Pause",
753        (KeyCode::MediaStop, _) => "Stop",
754        (KeyCode::MediaNext, _) => "Next Track",
755        (KeyCode::MediaPrev, _) => "Previous Track",
756        (KeyCode::MediaRecord, _) => "Record",
757        (KeyCode::MediaFastForward, _) => "Fast Forward",
758        (KeyCode::MediaRewind, _) => "Rewind",
759        (KeyCode::VolumeUp, _) => "Volume Up",
760        (KeyCode::VolumeDown, _) => "Volume Down",
761        (KeyCode::VolumeMute, _) => "Mute",
762        // Neither reachable from a parsed accelerator nor worth a lie.
763        (KeyCode::Unknown, _) | (KeyCode::Char(_), _) | (KeyCode::F(_), _) => "",
764    };
765    s.to_string()
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use crate::input::{KeyCode, KeyMods};
772
773    /// `ALL` is what C indexes, what Node's generated `MenuItemRole` is
774    /// spelled from and what `from_name` searches, so a variant it lacks
775    /// is one no binding can say. The match is exhaustive on purpose: a
776    /// variant added to the enum fails to compile here until it is placed
777    /// in `ALL` too.
778    #[test]
779    fn all_names_every_role_once() {
780        let mut seen = 0;
781        for role in MenuRole::ALL {
782            match role {
783                MenuRole::Custom
784                | MenuRole::Separator
785                | MenuRole::Cut
786                | MenuRole::Copy
787                | MenuRole::Paste
788                | MenuRole::SelectAll
789                | MenuRole::LookUp => seen += 1,
790            }
791            assert_eq!(MenuRole::from_name(role.name()), Some(role));
792            assert_eq!(
793                MenuRole::ALL.iter().filter(|r| **r == role).count(),
794                1,
795                "{role:?} is listed more than once"
796            );
797        }
798        assert_eq!(seen, MenuRole::ALL.len());
799    }
800
801    #[test]
802    fn mod_is_the_platform_primary() {
803        let a = Accel::parse("mod+s").unwrap();
804        assert_eq!(a.mods.super_key, cfg!(target_os = "macos"));
805        assert_eq!(a.mods.ctrl, !cfg!(target_os = "macos"));
806        assert_eq!(a.code, KeyCode::Char('s'));
807    }
808
809    #[test]
810    fn the_glyph_spelling_parses_back() {
811        let a = Accel::parse("\u{21e7}\u{2318}S").unwrap();
812        assert_eq!(
813            a,
814            Accel {
815                code: KeyCode::Char('S'),
816                mods: KeyMods::NONE.with_shift().with_super(),
817            }
818        );
819    }
820
821    #[test]
822    fn a_named_key_parses_and_reads_back_capitalised() {
823        let a = Accel::parse("ctrl+pageup").unwrap();
824        assert_eq!(a.code, KeyCode::PageUp);
825        let expect = if cfg!(target_os = "macos") {
826            "\u{21de}"
827        } else {
828            "Page Up"
829        };
830        assert!(a.display().ends_with(expect), "{}", a.display());
831    }
832
833    /// `spelling` is the readback a binding hands out, so it must parse
834    /// to the chord it came from on every platform — including the one
835    /// modifier `parse` names five ways.
836    #[test]
837    fn the_portable_spelling_parses_back_to_the_same_chord() {
838        for s in [
839            "ctrl+shift+i",
840            "f12",
841            "cmd+alt+d",
842            "⌃⌥⇧⌘s",
843            "mod+pageup",
844            "shift+/",
845        ] {
846            let a = Accel::parse(s).unwrap();
847            assert_eq!(
848                Accel::parse(&a.spelling()),
849                Some(a),
850                "{s} -> {}",
851                a.spelling()
852            );
853        }
854        assert_eq!(
855            Accel::parse("shift+ctrl+I").unwrap().spelling(),
856            "ctrl+shift+i"
857        );
858        assert_eq!(Accel::parse("win+f12").unwrap().spelling(), "super+f12");
859    }
860
861    #[test]
862    fn a_shortcut_kui_cannot_name_is_not_a_shortcut() {
863        assert!(Accel::parse("mod+nope").is_none());
864        // A lock key is no shortcut's key (backlog RG96).
865        assert!(Accel::parse("ctrl+capslock").is_none());
866        assert!(Accel::parse("numlock").is_none());
867        assert!(Accel::parse("shift+scrolllock").is_none());
868        assert!(Accel::parse("s+s").is_none());
869        assert!(Accel::parse("").is_none());
870    }
871
872    #[test]
873    fn a_key_equivalent_is_lowercase() {
874        // AppKit reads an uppercase key equivalent as Shift being held, so
875        // ⌘S must be sent as "s" with the Command flag and nothing else.
876        assert_eq!(
877            Accel::parse("cmd+S").unwrap().key_equivalent().as_deref(),
878            Some("s")
879        );
880    }
881}