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}