Skip to main content

teksilo_widgets/menu/
native.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Bridge from a [`MenuModel`] to the platform native menu (`teksilo-platform`'s
5//! [`NativeMenuHandle`]).
6//!
7//! Resolves the model into a plain [`NativeMenuSnapshot`] (titles localized +
8//! mnemonic-stripped, shortcuts resolved to key equivalents), installs it for
9//! the current window, and wires reactive `Signal`s so a toggled check or a
10//! disabled item updates the native item in place.
11
12use std::collections::HashMap;
13
14use teksilo_core::MenuItemId;
15use teksilo_core::ObserverHandle;
16use teksilo_core::build_context::BuildContext;
17use teksilo_core::event::{Key, Modifiers};
18use teksilo_core::shortcut::KeyStroke;
19use teksilo_core::signal::Prop;
20use teksilo_data::CheckState;
21use teksilo_i18n::LocalizedString;
22use teksilo_platform::native_menu::{
23    MenuItemDelta, NativeCheck, NativeKeyEquivalent, NativeMenuActivation, NativeMenuHandle,
24    NativeMenuNode, NativeMenuSnapshot, StandardMenuRole, StandardRoutedItem,
25};
26
27use crate::menu_item::parse_mnemonic;
28
29use super::model::{MenuItemState, MenuModel, MenuNode, StandardMenu};
30
31/// How a [`MenuBar`](crate::menu_bar::MenuBar) built from a [`MenuModel`]
32/// behaves on macOS, where the convention is a global menu bar at the top of the
33/// screen rather than an in-window strip.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum NativeMenuMode {
36    /// Don't touch the native menu bar; render the in-window bar only. The
37    /// default — opt in with [`MenuBar::native_on_macos`](crate::menu_bar::MenuBar::native_on_macos).
38    #[default]
39    Off,
40    /// Mirror the model into the OS menu bar AND suppress the in-window bar on
41    /// macOS (the native-looking choice). On other platforms the in-window bar
42    /// still renders (the native backend is a no-op there).
43    Suppress,
44    /// Mirror into the OS menu bar AND keep the in-window bar visible too.
45    Coexist,
46}
47
48impl NativeMenuMode {
49    /// Whether the in-window bar should be suppressed for the current target.
50    pub(crate) fn suppresses_in_window(self) -> bool {
51        cfg!(target_os = "macos") && matches!(self, NativeMenuMode::Suppress)
52    }
53
54    /// Whether the native menu should be installed at all.
55    pub(crate) fn installs_native(self) -> bool {
56        !matches!(self, NativeMenuMode::Off)
57    }
58}
59
60/// RAII binding that keeps the model's reactive observers alive for as long as
61/// the [`MenuBar`](crate::menu_bar::MenuBar) is mounted. Dropping it stops the
62/// per-item updates (the native menu itself is torn down when the window closes
63/// or its menu is replaced).
64pub(crate) struct NativeMenuBinding {
65    _observers: Vec<ObserverHandle>,
66}
67
68/// Resolve `model` into a native menu, install it for the current window, and
69/// wire reactive updates. Returns `None` (no-op) when there is no
70/// [`NativeMenuHandle`] in app-state, or no window / poster — e.g. in headless
71/// tests, or when the app did not call `install_native_menu()`.
72pub(crate) fn install(model: &MenuModel, ctx: &BuildContext) -> Option<NativeMenuBinding> {
73    let handle = ctx.app_state::<NativeMenuHandle>()?.clone();
74    let window_id = ctx.window()?.id();
75    let poster = ctx.poster()?.clone();
76
77    let mut activations = HashMap::new();
78    let mut reactive = Vec::new();
79    let mut roots: Vec<NativeMenuNode> = {
80        let nodes = model.nodes();
81        nodes
82            .iter()
83            .filter_map(|n| resolve_node(n, ctx, &mut activations, &mut reactive))
84            .collect()
85    };
86    // macOS requires a leading application menu. If the model didn't declare one,
87    // inject a default (English `lit!` labels; the app overrides via
88    // `MenuModel::standard_menu(StandardMenu::app()...)`). Resolving here keeps
89    // every user-visible string in the i18n layer.
90    let has_app = roots.iter().any(|n| {
91        matches!(
92            n,
93            NativeMenuNode::Standard {
94                role: StandardMenuRole::App,
95                ..
96            }
97        )
98    });
99    if !has_app {
100        roots.insert(
101            0,
102            NativeMenuNode::Standard {
103                role: StandardMenuRole::App,
104                labels: StandardMenu::app().resolve_labels(),
105                // Deliberately unrouted: a model that declares no App menu has
106                // declared no quit handler either, so `terminate:` is the only
107                // thing that can still make ⌘Q work here.
108                quit_item: None,
109                // Likewise no Settings row: with no App menu declared there is
110                // no intent to route it to, and an unrouted one would do
111                // nothing.
112                settings_item: None,
113            },
114        );
115    }
116    let snapshot = NativeMenuSnapshot { roots };
117
118    handle.set_window_menu(window_id, snapshot, activations, poster);
119
120    // Wire reactive per-item updates (title / enabled / check / radio).
121    let mut observers = Vec::new();
122    for item in reactive {
123        // The title first, and by the same delta mechanism as the rest: a menu
124        // whose Undo row names its target has to say the same thing in the
125        // global bar as in the window, and re-installing the whole native menu
126        // to change one string would be both heavy and visibly flickery.
127        {
128            let sig = item.title.to_signal();
129            let h = handle.clone();
130            let id = item.id;
131            observers.push(sig.observe(move |v| {
132                h.update_item(
133                    id,
134                    MenuItemDelta {
135                        title: Some(strip_title(v)),
136                        ..Default::default()
137                    },
138                );
139            }));
140        }
141        if let Prop::Bound(sig) = item.enabled {
142            let h = handle.clone();
143            let id = item.id;
144            observers.push(sig.observe(move |v| {
145                h.update_item(
146                    id,
147                    MenuItemDelta {
148                        enabled: Some(*v),
149                        ..Default::default()
150                    },
151                );
152            }));
153        }
154        match item.state {
155            MenuItemState::Plain => {}
156            // Two-way and reflect-only both mirror the signal into the native
157            // checkmark; they differ only in the in-window click behavior.
158            MenuItemState::Check(sig) | MenuItemState::ReflectCheck(sig) => {
159                let h = handle.clone();
160                let id = item.id;
161                observers.push(sig.observe(move |v| {
162                    h.update_item(
163                        id,
164                        check_delta(if *v {
165                            NativeCheck::On
166                        } else {
167                            NativeCheck::Off
168                        }),
169                    );
170                }));
171            }
172            MenuItemState::TriCheck(sig) => {
173                let h = handle.clone();
174                let id = item.id;
175                observers.push(sig.observe(move |v| {
176                    h.update_item(id, check_delta(tri_to_native(*v)));
177                }));
178            }
179            MenuItemState::Radio { value, selected } => {
180                let h = handle.clone();
181                let id = item.id;
182                observers.push(selected.observe(move |sel| {
183                    let check = if *sel == value {
184                        NativeCheck::On
185                    } else {
186                        NativeCheck::Off
187                    };
188                    h.update_item(id, check_delta(check));
189                }));
190            }
191        }
192    }
193
194    Some(NativeMenuBinding {
195        _observers: observers,
196    })
197}
198
199/// One item's reactive sources, gathered during resolution.
200struct ReactiveItem {
201    id: MenuItemId,
202    enabled: Prop<bool>,
203    state: MenuItemState,
204    /// The entry's label, kept so a title that depends on application state —
205    /// "Undo renaming «Chapter 3»" — reaches the native bar too. Almost every
206    /// title only ever changes with the locale, and a locale change rebuilds
207    /// the whole menu, so for those this observation simply never fires.
208    title: LocalizedString,
209}
210
211fn resolve_node(
212    node: &MenuNode,
213    ctx: &BuildContext,
214    activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
215    reactive: &mut Vec<ReactiveItem>,
216) -> Option<NativeMenuNode> {
217    match node {
218        MenuNode::Separator => Some(NativeMenuNode::Separator),
219        MenuNode::Standard(sm) => Some(resolve_standard(sm, activations, |id| {
220            ctx.effective_shortcut(id).and_then(|eff| eff.primary)
221        })),
222        MenuNode::Submenu {
223            title, children, ..
224        } => Some(NativeMenuNode::Submenu {
225            title: strip_title(&title.resolve_now()),
226            children: children
227                .iter()
228                .filter_map(|n| resolve_node(n, ctx, activations, reactive))
229                .collect(),
230        }),
231        // A currently-hidden item is omitted from the native snapshot. (It
232        // reappears on the next menu rebuild; for fully-dynamic native menus use
233        // `MenuModel::remove` / `push_item`.)
234        MenuNode::Item(entry) if !entry.visible.get() => None,
235        MenuNode::Item(entry) => {
236            let check = match &entry.state {
237                MenuItemState::Plain => NativeCheck::None,
238                MenuItemState::Check(s) | MenuItemState::ReflectCheck(s) => {
239                    if s.get() {
240                        NativeCheck::On
241                    } else {
242                        NativeCheck::Off
243                    }
244                }
245                MenuItemState::TriCheck(s) => tri_to_native(s.get()),
246                MenuItemState::Radio { value, selected } => {
247                    if selected.get() == *value {
248                        NativeCheck::On
249                    } else {
250                        NativeCheck::Off
251                    }
252                }
253            };
254            let key_equiv = entry
255                .shortcut_id
256                .and_then(|id| ctx.effective_shortcut(id).and_then(|eff| eff.primary))
257                .map(native_key_equiv);
258
259            activations.insert(
260                entry.id,
261                NativeMenuActivation {
262                    intent: entry.intent,
263                    action: entry.action.clone(),
264                },
265            );
266            reactive.push(ReactiveItem {
267                id: entry.id,
268                enabled: entry.enabled.clone(),
269                state: entry.state.clone(),
270                title: entry.title.clone(),
271            });
272
273            Some(NativeMenuNode::Item {
274                id: entry.id,
275                title: strip_title(&entry.title.resolve_now()),
276                key_equiv,
277                enabled: entry.enabled.get(),
278                check,
279            })
280        }
281    }
282}
283
284/// The conventional chord for a routed standard row when the app named no
285/// shortcut of its own — ⌘Q for Quit, ⌘, for Settings, which is what a Mac user
286/// reaches for whatever the app calls the command.
287///
288/// A fallback, never an override: an app that registers a quit shortcut should
289/// name it (see [`StandardMenu::quit_shortcut`]) so the row follows a rebind.
290fn conventional_chord(key: &str) -> NativeKeyEquivalent {
291    NativeKeyEquivalent {
292        key: key.to_string(),
293        command: true,
294        shift: false,
295        alt: false,
296        control: false,
297    }
298}
299
300/// Resolve one platform-standard menu into its boundary node.
301///
302/// Split out of [`resolve_node`] because it needs no [`BuildContext`], only a
303/// way to look a shortcut up: a standard menu carries labels and, optionally,
304/// routed Quit / Settings rows, and the platform fills in the rest. That makes
305/// it the one part of the native bridge a test can exercise on any OS —
306/// everything around it is behind the macOS gate in `MenuBar::build`, so a
307/// routing bug would otherwise only be observable on the platform it breaks.
308///
309/// `shortcut` is the registry lookup, threaded rather than reached for so a test
310/// can hand over a stub: the chords these rows advertise are otherwise the one
311/// thing about them nothing off macOS can check.
312fn resolve_standard(
313    sm: &StandardMenu,
314    activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
315    shortcut: impl Fn(&str) -> Option<KeyStroke>,
316) -> NativeMenuNode {
317    // A routed row is an ordinary activation under an id the model minted once,
318    // so it survives every rebuild — unlike the rest of a standard menu, which
319    // the platform fills in from labels alone.
320    let mut route = |entry: Option<(&'static str, MenuItemId)>,
321                     shortcut_id: Option<&'static str>,
322                     fallback: &str|
323     -> Option<StandardRoutedItem> {
324        let (intent, id) = entry?;
325        activations.insert(
326            id,
327            NativeMenuActivation {
328                intent: Some(intent),
329                action: None,
330            },
331        );
332        // The registry's answer, resolved through the primary-accelerator
333        // convention exactly as `MenuEntry`'s chord is — so this row cannot
334        // advertise one chord while the dispatcher fires another. An id that
335        // resolves to nothing (unregistered, or unbound by the user) leaves the
336        // row with no key equivalent rather than resurrecting the convention:
337        // the app said where the chord comes from, and it currently says none.
338        let key_equiv = match shortcut_id {
339            Some(sid) => shortcut(sid).map(native_key_equiv),
340            None => Some(conventional_chord(fallback)),
341        };
342        Some(StandardRoutedItem { id, key_equiv })
343    };
344
345    let quit_item = route(sm.quit_route(), sm.quit_shortcut_id(), "q");
346    // Settings is routed the same way, and only ever routed — the platform has
347    // no selector of its own to fall back on.
348    let settings_item = route(sm.settings_route(), sm.settings_shortcut_id(), ",");
349
350    NativeMenuNode::Standard {
351        role: sm.role(),
352        labels: sm.resolve_labels(),
353        quit_item,
354        settings_item,
355    }
356}
357
358fn check_delta(check: NativeCheck) -> MenuItemDelta {
359    MenuItemDelta {
360        check: Some(check),
361        ..Default::default()
362    }
363}
364
365fn tri_to_native(state: CheckState) -> NativeCheck {
366    match state {
367        CheckState::Checked => NativeCheck::On,
368        CheckState::Unchecked => NativeCheck::Off,
369        CheckState::Indeterminate => NativeCheck::Mixed,
370    }
371}
372
373fn strip_title(raw: &str) -> String {
374    parse_mnemonic(raw).stripped
375}
376
377/// Map a Teksilo [`KeyStroke`] to a platform key equivalent.
378///
379/// The chord arrives already resolved by the registry, which has applied the
380/// primary-accelerator convention (Qt's `Qt::CTRL` → ⌘) to the declared
381/// default: an app that writes `KeyStroke::ctrl(Key::S)` gets ⌘S here.
382///
383/// So the Command flag takes the accelerator, and any **leftover** literal
384/// `Ctrl` goes to Control rather than being folded into Command — otherwise a
385/// deliberately literal ⌃ chord (a user's own rebind, or a `literal_modifiers`
386/// Ctrl+Tab) would be advertised on the wrong key. `Super` maps to Command
387/// unconditionally: [`NativeKeyEquivalent`] has no Super flag, and on the one
388/// backend that consumes this today ⌘ *is* Super.
389fn native_key_equiv(ks: KeyStroke) -> NativeKeyEquivalent {
390    NativeKeyEquivalent {
391        key: key_to_equiv(ks.key),
392        command: ks.modifiers.command() || ks.modifiers.super_key(),
393        shift: ks.modifiers.shift(),
394        alt: ks.modifiers.alt(),
395        control: ks.modifiers.without(Modifiers::COMMAND).ctrl(),
396    }
397}
398
399fn key_to_equiv(key: Key) -> String {
400    let special = match key {
401        Key::Enter => "\r",
402        Key::Tab => "\t",
403        Key::Space => " ",
404        Key::Escape => "\u{1b}",
405        Key::Backspace => "\u{8}",
406        Key::Delete => "\u{7f}",
407        Key::ArrowUp => "\u{F700}",
408        Key::ArrowDown => "\u{F701}",
409        Key::ArrowLeft => "\u{F702}",
410        Key::ArrowRight => "\u{F703}",
411        Key::Home => "\u{F729}",
412        Key::End => "\u{F72B}",
413        Key::PageUp => "\u{F72C}",
414        Key::PageDown => "\u{F72D}",
415        Key::F1 => "\u{F704}",
416        Key::F2 => "\u{F705}",
417        Key::F3 => "\u{F706}",
418        Key::F4 => "\u{F707}",
419        Key::F5 => "\u{F708}",
420        Key::F6 => "\u{F709}",
421        Key::F7 => "\u{F70A}",
422        Key::F8 => "\u{F70B}",
423        Key::F9 => "\u{F70C}",
424        Key::F10 => "\u{F70D}",
425        Key::F11 => "\u{F70E}",
426        Key::F12 => "\u{F70F}",
427        // Letters / digits / arbitrary chars: lowercase single character.
428        other => return other.to_char().map(|c| c.to_string()).unwrap_or_default(),
429    };
430    special.to_string()
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use teksilo_i18n::LocalizedString;
437
438    fn labels_of(node: &NativeMenuNode) -> &teksilo_platform::native_menu::StandardLabels {
439        match node {
440            NativeMenuNode::Standard { labels, .. } => labels,
441            _ => panic!("expected a standard menu node"),
442        }
443    }
444
445    fn quit_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
446        match node {
447            NativeMenuNode::Standard { quit_item, .. } => quit_item.as_ref(),
448            _ => panic!("expected a standard menu node"),
449        }
450    }
451
452    fn settings_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
453        match node {
454            NativeMenuNode::Standard { settings_item, .. } => settings_item.as_ref(),
455            _ => panic!("expected a standard menu node"),
456        }
457    }
458
459    fn quit_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
460        quit_of(node).map(|r| r.id)
461    }
462
463    fn settings_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
464        settings_of(node).map(|r| r.id)
465    }
466
467    /// An app that registered no shortcuts at all.
468    fn no_shortcuts(_: &str) -> Option<KeyStroke> {
469        None
470    }
471
472    /// A registry holding exactly one chord, under `id`.
473    fn only(id: &'static str, ks: KeyStroke) -> impl Fn(&str) -> Option<KeyStroke> {
474        move |asked| (asked == id).then_some(ks)
475    }
476
477    /// A chord as the platform would advertise it: `(key, command, shift)`.
478    fn chord(item: Option<&StandardRoutedItem>) -> Option<(String, bool, bool)> {
479        item?
480            .key_equiv
481            .as_ref()
482            .map(|k| (k.key.clone(), k.command, k.shift))
483    }
484
485    /// Settings has no `terminate:`-style fallback: no platform opens an
486    /// arbitrary app's settings on its own. So an unset route must omit the row
487    /// rather than render one that does nothing when chosen.
488    #[test]
489    fn a_standard_app_menu_has_no_settings_row_by_default() {
490        let mut activations = HashMap::new();
491        let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
492        assert_eq!(settings_item_of(&node), None);
493    }
494
495    /// A settings intent mints an id and routes it, exactly like a quit intent.
496    #[test]
497    fn a_settings_intent_becomes_a_routed_item_with_an_activation() {
498        let mut activations = HashMap::new();
499        let node = resolve_standard(
500            &StandardMenu::app().settings_intent("app.settings"),
501            &mut activations,
502            no_shortcuts,
503        );
504        let id = settings_item_of(&node).expect("a routed settings carries an item id");
505        assert_eq!(
506            activations.get(&id).map(|a| a.intent),
507            Some(Some("app.settings"))
508        );
509    }
510
511    /// Both slots on one App menu must get distinct ids, or choosing Settings
512    /// would fire Quit.
513    #[test]
514    fn quit_and_settings_are_routed_under_distinct_ids() {
515        let mut activations = HashMap::new();
516        let node = resolve_standard(
517            &StandardMenu::app()
518                .quit_intent("app.quit")
519                .settings_intent("app.settings"),
520            &mut activations,
521            no_shortcuts,
522        );
523        let quit = quit_item_of(&node).expect("quit id");
524        let settings = settings_item_of(&node).expect("settings id");
525        assert_ne!(quit, settings);
526        assert_eq!(activations.len(), 2);
527        assert_eq!(activations[&quit].intent, Some("app.quit"));
528        assert_eq!(activations[&settings].intent, Some("app.settings"));
529    }
530
531    /// Same stability guarantee as the quit id: minted with the model, so a
532    /// later `update_item` delta still addresses a live menu item.
533    #[test]
534    fn the_routed_settings_id_is_stable_across_installs() {
535        let menu = StandardMenu::app().settings_intent("app.settings");
536        let mut first = HashMap::new();
537        let mut second = HashMap::new();
538        assert_eq!(
539            settings_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
540            settings_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
541        );
542    }
543
544    /// The label rides the same i18n path as the rest of the App menu chrome,
545    /// so the platform crate never sees an English literal it did not get from
546    /// the widget layer.
547    #[test]
548    fn the_settings_label_resolves_through_the_widget_layer() {
549        let mut activations = HashMap::new();
550        let node = resolve_standard(
551            &StandardMenu::app().settings(LocalizedString::literal("Réglages…")),
552            &mut activations,
553            no_shortcuts,
554        );
555        assert_eq!(labels_of(&node).settings, "Réglages…");
556    }
557
558    /// The default is the platform's own Quit. An app that declared no handler
559    /// still gets a working ⌘Q out of `terminate:`, and that guarantee is what
560    /// the auto-injected App menu rests on.
561    #[test]
562    fn a_standard_app_menu_routes_nothing_by_default() {
563        let mut activations = HashMap::new();
564        let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
565        assert_eq!(quit_item_of(&node), None);
566        assert!(
567            activations.is_empty(),
568            "an unrouted standard menu owns no activation"
569        );
570    }
571
572    /// With a quit intent the item carries an id, and that id resolves to the
573    /// intent — the whole point being that ⌘Q reaches the app instead of
574    /// terminating past it.
575    #[test]
576    fn a_quit_intent_becomes_a_routed_item_with_an_activation() {
577        let mut activations = HashMap::new();
578        let node = resolve_standard(
579            &StandardMenu::app().quit_intent("app.quit"),
580            &mut activations,
581            no_shortcuts,
582        );
583        let id = quit_item_of(&node).expect("a routed quit carries an item id");
584        let activation = activations
585            .get(&id)
586            .expect("the routed id resolves to an activation");
587        assert_eq!(activation.intent, Some("app.quit"));
588        assert!(
589            activation.action.is_none(),
590            "routing by name only — no closure to run on the side"
591        );
592    }
593
594    /// The id is minted with the model, not with the snapshot. A fresh one per
595    /// install would still route (the map is rebuilt alongside it), but any
596    /// `update_item` delta held from an earlier build would address a menu item
597    /// that no longer exists.
598    #[test]
599    fn the_routed_quit_id_is_stable_across_installs() {
600        let menu = StandardMenu::app().quit_intent("app.quit");
601        let mut first = HashMap::new();
602        let mut second = HashMap::new();
603        assert_eq!(
604            quit_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
605            quit_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
606        );
607    }
608
609    /// Two App menus — one per window, as a multi-window app builds them — must
610    /// not share an id, or the second window's activation map overwrites the
611    /// first's and closing either window unroutes both.
612    #[test]
613    fn two_app_menus_get_distinct_routed_ids() {
614        let mut activations = HashMap::new();
615        let a = resolve_standard(
616            &StandardMenu::app().quit_intent("app.quit"),
617            &mut activations,
618            no_shortcuts,
619        );
620        let b = resolve_standard(
621            &StandardMenu::app().quit_intent("app.quit"),
622            &mut activations,
623            no_shortcuts,
624        );
625        assert_ne!(quit_item_of(&a), quit_item_of(&b));
626        assert_eq!(activations.len(), 2);
627    }
628
629    /// Routing changes what Quit *does*, never what it says: the label still
630    /// comes from the app's i18n layer, as every other standard label does.
631    #[test]
632    fn routing_leaves_the_localized_labels_alone() {
633        let mut activations = HashMap::new();
634        let node = resolve_standard(
635            &StandardMenu::app()
636                .quit(LocalizedString::literal("Quitter"))
637                .quit_intent("app.quit"),
638            &mut activations,
639            no_shortcuts,
640        );
641        assert_eq!(labels_of(&node).quit, "Quitter");
642    }
643
644    // ── The chord a routed row advertises ───────────────────────────────
645
646    /// With no shortcut named, the row falls back to the chord a Mac user
647    /// reaches for. This is the case every app gets without thinking about it,
648    /// so it has to be the conventional one.
649    #[test]
650    fn an_unnamed_shortcut_falls_back_to_the_conventional_chord() {
651        let mut activations = HashMap::new();
652        let node = resolve_standard(
653            &StandardMenu::app()
654                .quit_intent("app.quit")
655                .settings_intent("app.settings"),
656            &mut activations,
657            no_shortcuts,
658        );
659        assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
660        assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
661    }
662
663    /// Named, the chord comes from the registry — which is the whole point.
664    /// A `Ctrl` declaration has already been rewritten to the primary
665    /// accelerator by the time it reaches here, so it arrives as ⌘.
666    #[test]
667    fn a_named_shortcut_supplies_the_chord() {
668        let mut activations = HashMap::new();
669        let node = resolve_standard(
670            &StandardMenu::app()
671                .quit_intent("app.quit")
672                .quit_shortcut("app.quit"),
673            &mut activations,
674            only("app.quit", KeyStroke::command(Key::Q)),
675        );
676        assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
677    }
678
679    /// The case the fallback cannot serve: a user who rebound Quit. The row
680    /// must advertise — and therefore fire — the new chord, not the old one.
681    /// Left hardcoded, ⌘Q stays live after the user moved the command away from
682    /// it, *and* shadows wherever they moved it to, since the platform
683    /// dispatches a main-menu key equivalent before the responder chain.
684    #[test]
685    fn a_rebound_shortcut_moves_the_rows_chord_with_it() {
686        let mut activations = HashMap::new();
687        let node = resolve_standard(
688            &StandardMenu::app()
689                .quit_intent("app.quit")
690                .quit_shortcut("app.quit"),
691            &mut activations,
692            only("app.quit", KeyStroke::command_shift(Key::Q)),
693        );
694        assert_eq!(
695            chord(quit_of(&node)),
696            Some(("q".into(), true, true)),
697            "the row follows the rebind rather than keeping the convention"
698        );
699    }
700
701    /// Naming a shortcut that resolves to nothing — unregistered, or unbound by
702    /// the user — leaves the row with no key equivalent. Falling back to the
703    /// convention here would resurrect a chord the user deliberately cleared,
704    /// which is the same defect as never having read the registry.
705    #[test]
706    fn a_named_but_unbound_shortcut_leaves_the_row_chordless() {
707        let mut activations = HashMap::new();
708        let node = resolve_standard(
709            &StandardMenu::app()
710                .quit_intent("app.quit")
711                .quit_shortcut("app.quit"),
712            &mut activations,
713            no_shortcuts,
714        );
715        assert!(quit_of(&node).is_some(), "the row is still there");
716        assert_eq!(chord(quit_of(&node)), None, "it just has no chord");
717    }
718
719    /// The two rows read their own ids, not each other's.
720    #[test]
721    fn each_row_reads_its_own_shortcut() {
722        let mut activations = HashMap::new();
723        let node = resolve_standard(
724            &StandardMenu::app()
725                .quit_intent("app.quit")
726                .quit_shortcut("app.quit")
727                .settings_intent("app.settings")
728                .settings_shortcut("app.settings"),
729            &mut activations,
730            only("app.settings", KeyStroke::command(Key::Character(','))),
731        );
732        assert_eq!(chord(quit_of(&node)), None, "quit's id resolves to nothing");
733        assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
734    }
735}