Skip to main content

kui_core/
menu.rs

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