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