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}