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, Signal};
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            push_observer(&mut observers, &sig, 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            push_observer(&mut observers, &sig, 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                push_observer(&mut observers, &sig, 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                push_observer(&mut observers, &sig, 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                push_observer(&mut observers, &selected, 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/// Attach one live-update observer, or leave the row at the value already
200/// baked into the native snapshot.
201///
202/// Fallible on purpose. This bridge runs inside `applicationDidFinishLaunching`
203/// on macOS, an Objective-C frame a Rust panic cannot unwind through: a panic
204/// here does not surface as an error, it aborts the process before the first
205/// window is drawn. `Signal::try_observe` covers every signal with fixed
206/// mutable roots — a plain one, and anything derived from them with `map` /
207/// `zip` / `and` / `not`, which is what a caller building
208/// `enabled(unsaved.and(&backup_mode.not()))` gets. The remaining shape is
209/// `flat_map`, whose roots are re-chosen as it is read; for that the row keeps
210/// the state it was resolved with and simply does not follow later changes,
211/// in the global bar only. The in-window menu binds it directly and stays live
212/// either way.
213fn push_observer<T: 'static>(
214    observers: &mut Vec<ObserverHandle>,
215    signal: &Signal<T>,
216    f: impl Fn(&T) + 'static,
217) {
218    if let Ok(handle) = signal.try_observe(f) {
219        observers.push(handle);
220    }
221}
222
223/// One item's reactive sources, gathered during resolution.
224struct ReactiveItem {
225    id: MenuItemId,
226    enabled: Prop<bool>,
227    state: MenuItemState,
228    /// The entry's label, kept so a title that depends on application state —
229    /// "Undo renaming «Chapter 3»" — reaches the native bar too. Almost every
230    /// title only ever changes with the locale, and a locale change rebuilds
231    /// the whole menu, so for those this observation simply never fires.
232    title: LocalizedString,
233}
234
235fn resolve_node(
236    node: &MenuNode,
237    ctx: &BuildContext,
238    activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
239    reactive: &mut Vec<ReactiveItem>,
240) -> Option<NativeMenuNode> {
241    match node {
242        MenuNode::Separator => Some(NativeMenuNode::Separator),
243        MenuNode::Standard(sm) => Some(resolve_standard(sm, activations, |id| {
244            ctx.effective_shortcut(id).and_then(|eff| eff.primary)
245        })),
246        MenuNode::Submenu {
247            title, children, ..
248        } => Some(NativeMenuNode::Submenu {
249            title: strip_title(&title.resolve_now()),
250            children: children
251                .iter()
252                .filter_map(|n| resolve_node(n, ctx, activations, reactive))
253                .collect(),
254        }),
255        // A currently-hidden item is omitted from the native snapshot. (It
256        // reappears on the next menu rebuild; for fully-dynamic native menus use
257        // `MenuModel::remove` / `push_item`.)
258        MenuNode::Item(entry) if !entry.visible.get() => None,
259        MenuNode::Item(entry) => {
260            let check = match &entry.state {
261                MenuItemState::Plain => NativeCheck::None,
262                MenuItemState::Check(s) | MenuItemState::ReflectCheck(s) => {
263                    if s.get() {
264                        NativeCheck::On
265                    } else {
266                        NativeCheck::Off
267                    }
268                }
269                MenuItemState::TriCheck(s) => tri_to_native(s.get()),
270                MenuItemState::Radio { value, selected } => {
271                    if selected.get() == *value {
272                        NativeCheck::On
273                    } else {
274                        NativeCheck::Off
275                    }
276                }
277            };
278            let key_equiv = entry
279                .shortcut_id
280                .and_then(|id| ctx.effective_shortcut(id).and_then(|eff| eff.primary))
281                .map(native_key_equiv);
282
283            activations.insert(
284                entry.id,
285                NativeMenuActivation {
286                    intent: entry.intent,
287                    action: entry.action.clone(),
288                },
289            );
290            reactive.push(ReactiveItem {
291                id: entry.id,
292                enabled: entry.enabled.clone(),
293                state: entry.state.clone(),
294                title: entry.title.clone(),
295            });
296
297            Some(NativeMenuNode::Item {
298                id: entry.id,
299                title: strip_title(&entry.title.resolve_now()),
300                key_equiv,
301                enabled: entry.enabled.get(),
302                check,
303            })
304        }
305    }
306}
307
308/// The conventional chord for a routed standard row when the app named no
309/// shortcut of its own — ⌘Q for Quit, ⌘, for Settings, which is what a Mac user
310/// reaches for whatever the app calls the command.
311///
312/// A fallback, never an override: an app that registers a quit shortcut should
313/// name it (see [`StandardMenu::quit_shortcut`]) so the row follows a rebind.
314fn conventional_chord(key: &str) -> NativeKeyEquivalent {
315    NativeKeyEquivalent {
316        key: key.to_string(),
317        command: true,
318        shift: false,
319        alt: false,
320        control: false,
321    }
322}
323
324/// Resolve one platform-standard menu into its boundary node.
325///
326/// Split out of [`resolve_node`] because it needs no [`BuildContext`], only a
327/// way to look a shortcut up: a standard menu carries labels and, optionally,
328/// routed Quit / Settings rows, and the platform fills in the rest. That makes
329/// it the one part of the native bridge a test can exercise on any OS —
330/// everything around it is behind the macOS gate in `MenuBar::build`, so a
331/// routing bug would otherwise only be observable on the platform it breaks.
332///
333/// `shortcut` is the registry lookup, threaded rather than reached for so a test
334/// can hand over a stub: the chords these rows advertise are otherwise the one
335/// thing about them nothing off macOS can check.
336fn resolve_standard(
337    sm: &StandardMenu,
338    activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
339    shortcut: impl Fn(&str) -> Option<KeyStroke>,
340) -> NativeMenuNode {
341    // A routed row is an ordinary activation under an id the model minted once,
342    // so it survives every rebuild — unlike the rest of a standard menu, which
343    // the platform fills in from labels alone.
344    let mut route = |entry: Option<(&'static str, MenuItemId)>,
345                     shortcut_id: Option<&'static str>,
346                     fallback: &str|
347     -> Option<StandardRoutedItem> {
348        let (intent, id) = entry?;
349        activations.insert(
350            id,
351            NativeMenuActivation {
352                intent: Some(intent),
353                action: None,
354            },
355        );
356        // The registry's answer, resolved through the primary-accelerator
357        // convention exactly as `MenuEntry`'s chord is — so this row cannot
358        // advertise one chord while the dispatcher fires another. An id that
359        // resolves to nothing (unregistered, or unbound by the user) leaves the
360        // row with no key equivalent rather than resurrecting the convention:
361        // the app said where the chord comes from, and it currently says none.
362        let key_equiv = match shortcut_id {
363            Some(sid) => shortcut(sid).map(native_key_equiv),
364            None => Some(conventional_chord(fallback)),
365        };
366        Some(StandardRoutedItem { id, key_equiv })
367    };
368
369    let quit_item = route(sm.quit_route(), sm.quit_shortcut_id(), "q");
370    // Settings is routed the same way, and only ever routed — the platform has
371    // no selector of its own to fall back on.
372    let settings_item = route(sm.settings_route(), sm.settings_shortcut_id(), ",");
373
374    NativeMenuNode::Standard {
375        role: sm.role(),
376        labels: sm.resolve_labels(),
377        quit_item,
378        settings_item,
379    }
380}
381
382fn check_delta(check: NativeCheck) -> MenuItemDelta {
383    MenuItemDelta {
384        check: Some(check),
385        ..Default::default()
386    }
387}
388
389fn tri_to_native(state: CheckState) -> NativeCheck {
390    match state {
391        CheckState::Checked => NativeCheck::On,
392        CheckState::Unchecked => NativeCheck::Off,
393        CheckState::Indeterminate => NativeCheck::Mixed,
394    }
395}
396
397fn strip_title(raw: &str) -> String {
398    parse_mnemonic(raw).stripped
399}
400
401/// Map a Teksilo [`KeyStroke`] to a platform key equivalent.
402///
403/// The chord arrives already resolved by the registry, which has applied the
404/// primary-accelerator convention (Qt's `Qt::CTRL` → ⌘) to the declared
405/// default: an app that writes `KeyStroke::ctrl(Key::S)` gets ⌘S here.
406///
407/// So the Command flag takes the accelerator, and any **leftover** literal
408/// `Ctrl` goes to Control rather than being folded into Command — otherwise a
409/// deliberately literal ⌃ chord (a user's own rebind, or a `literal_modifiers`
410/// Ctrl+Tab) would be advertised on the wrong key. `Super` maps to Command
411/// unconditionally: [`NativeKeyEquivalent`] has no Super flag, and on the one
412/// backend that consumes this today ⌘ *is* Super.
413fn native_key_equiv(ks: KeyStroke) -> NativeKeyEquivalent {
414    NativeKeyEquivalent {
415        key: key_to_equiv(ks.key),
416        command: ks.modifiers.command() || ks.modifiers.super_key(),
417        shift: ks.modifiers.shift(),
418        alt: ks.modifiers.alt(),
419        control: ks.modifiers.without(Modifiers::COMMAND).ctrl(),
420    }
421}
422
423fn key_to_equiv(key: Key) -> String {
424    let special = match key {
425        Key::Enter => "\r",
426        Key::Tab => "\t",
427        Key::Space => " ",
428        Key::Escape => "\u{1b}",
429        Key::Backspace => "\u{8}",
430        Key::Delete => "\u{7f}",
431        Key::ArrowUp => "\u{F700}",
432        Key::ArrowDown => "\u{F701}",
433        Key::ArrowLeft => "\u{F702}",
434        Key::ArrowRight => "\u{F703}",
435        Key::Home => "\u{F729}",
436        Key::End => "\u{F72B}",
437        Key::PageUp => "\u{F72C}",
438        Key::PageDown => "\u{F72D}",
439        Key::F1 => "\u{F704}",
440        Key::F2 => "\u{F705}",
441        Key::F3 => "\u{F706}",
442        Key::F4 => "\u{F707}",
443        Key::F5 => "\u{F708}",
444        Key::F6 => "\u{F709}",
445        Key::F7 => "\u{F70A}",
446        Key::F8 => "\u{F70B}",
447        Key::F9 => "\u{F70C}",
448        Key::F10 => "\u{F70D}",
449        Key::F11 => "\u{F70E}",
450        Key::F12 => "\u{F70F}",
451        // Letters / digits / arbitrary chars: lowercase single character.
452        other => return other.to_char().map(|c| c.to_string()).unwrap_or_default(),
453    };
454    special.to_string()
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use teksilo_i18n::LocalizedString;
461
462    fn labels_of(node: &NativeMenuNode) -> &teksilo_platform::native_menu::StandardLabels {
463        match node {
464            NativeMenuNode::Standard { labels, .. } => labels,
465            _ => panic!("expected a standard menu node"),
466        }
467    }
468
469    fn quit_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
470        match node {
471            NativeMenuNode::Standard { quit_item, .. } => quit_item.as_ref(),
472            _ => panic!("expected a standard menu node"),
473        }
474    }
475
476    fn settings_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
477        match node {
478            NativeMenuNode::Standard { settings_item, .. } => settings_item.as_ref(),
479            _ => panic!("expected a standard menu node"),
480        }
481    }
482
483    fn quit_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
484        quit_of(node).map(|r| r.id)
485    }
486
487    fn settings_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
488        settings_of(node).map(|r| r.id)
489    }
490
491    /// An app that registered no shortcuts at all.
492    fn no_shortcuts(_: &str) -> Option<KeyStroke> {
493        None
494    }
495
496    /// A registry holding exactly one chord, under `id`.
497    fn only(id: &'static str, ks: KeyStroke) -> impl Fn(&str) -> Option<KeyStroke> {
498        move |asked| (asked == id).then_some(ks)
499    }
500
501    /// A chord as the platform would advertise it: `(key, command, shift)`.
502    fn chord(item: Option<&StandardRoutedItem>) -> Option<(String, bool, bool)> {
503        item?
504            .key_equiv
505            .as_ref()
506            .map(|k| (k.key.clone(), k.command, k.shift))
507    }
508
509    /// Settings has no `terminate:`-style fallback: no platform opens an
510    /// arbitrary app's settings on its own. So an unset route must omit the row
511    /// rather than render one that does nothing when chosen.
512    #[test]
513    fn a_standard_app_menu_has_no_settings_row_by_default() {
514        let mut activations = HashMap::new();
515        let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
516        assert_eq!(settings_item_of(&node), None);
517    }
518
519    /// A settings intent mints an id and routes it, exactly like a quit intent.
520    #[test]
521    fn a_settings_intent_becomes_a_routed_item_with_an_activation() {
522        let mut activations = HashMap::new();
523        let node = resolve_standard(
524            &StandardMenu::app().settings_intent("app.settings"),
525            &mut activations,
526            no_shortcuts,
527        );
528        let id = settings_item_of(&node).expect("a routed settings carries an item id");
529        assert_eq!(
530            activations.get(&id).map(|a| a.intent),
531            Some(Some("app.settings"))
532        );
533    }
534
535    /// Both slots on one App menu must get distinct ids, or choosing Settings
536    /// would fire Quit.
537    #[test]
538    fn quit_and_settings_are_routed_under_distinct_ids() {
539        let mut activations = HashMap::new();
540        let node = resolve_standard(
541            &StandardMenu::app()
542                .quit_intent("app.quit")
543                .settings_intent("app.settings"),
544            &mut activations,
545            no_shortcuts,
546        );
547        let quit = quit_item_of(&node).expect("quit id");
548        let settings = settings_item_of(&node).expect("settings id");
549        assert_ne!(quit, settings);
550        assert_eq!(activations.len(), 2);
551        assert_eq!(activations[&quit].intent, Some("app.quit"));
552        assert_eq!(activations[&settings].intent, Some("app.settings"));
553    }
554
555    /// Same stability guarantee as the quit id: minted with the model, so a
556    /// later `update_item` delta still addresses a live menu item.
557    #[test]
558    fn the_routed_settings_id_is_stable_across_installs() {
559        let menu = StandardMenu::app().settings_intent("app.settings");
560        let mut first = HashMap::new();
561        let mut second = HashMap::new();
562        assert_eq!(
563            settings_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
564            settings_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
565        );
566    }
567
568    /// The label rides the same i18n path as the rest of the App menu chrome,
569    /// so the platform crate never sees an English literal it did not get from
570    /// the widget layer.
571    #[test]
572    fn the_settings_label_resolves_through_the_widget_layer() {
573        let mut activations = HashMap::new();
574        let node = resolve_standard(
575            &StandardMenu::app().settings(LocalizedString::literal("Réglages…")),
576            &mut activations,
577            no_shortcuts,
578        );
579        assert_eq!(labels_of(&node).settings, "Réglages…");
580    }
581
582    /// The default is the platform's own Quit. An app that declared no handler
583    /// still gets a working ⌘Q out of `terminate:`, and that guarantee is what
584    /// the auto-injected App menu rests on.
585    #[test]
586    fn a_standard_app_menu_routes_nothing_by_default() {
587        let mut activations = HashMap::new();
588        let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
589        assert_eq!(quit_item_of(&node), None);
590        assert!(
591            activations.is_empty(),
592            "an unrouted standard menu owns no activation"
593        );
594    }
595
596    /// With a quit intent the item carries an id, and that id resolves to the
597    /// intent — the whole point being that ⌘Q reaches the app instead of
598    /// terminating past it.
599    #[test]
600    fn a_quit_intent_becomes_a_routed_item_with_an_activation() {
601        let mut activations = HashMap::new();
602        let node = resolve_standard(
603            &StandardMenu::app().quit_intent("app.quit"),
604            &mut activations,
605            no_shortcuts,
606        );
607        let id = quit_item_of(&node).expect("a routed quit carries an item id");
608        let activation = activations
609            .get(&id)
610            .expect("the routed id resolves to an activation");
611        assert_eq!(activation.intent, Some("app.quit"));
612        assert!(
613            activation.action.is_none(),
614            "routing by name only — no closure to run on the side"
615        );
616    }
617
618    /// The id is minted with the model, not with the snapshot. A fresh one per
619    /// install would still route (the map is rebuilt alongside it), but any
620    /// `update_item` delta held from an earlier build would address a menu item
621    /// that no longer exists.
622    #[test]
623    fn the_routed_quit_id_is_stable_across_installs() {
624        let menu = StandardMenu::app().quit_intent("app.quit");
625        let mut first = HashMap::new();
626        let mut second = HashMap::new();
627        assert_eq!(
628            quit_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
629            quit_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
630        );
631    }
632
633    /// Two App menus — one per window, as a multi-window app builds them — must
634    /// not share an id, or the second window's activation map overwrites the
635    /// first's and closing either window unroutes both.
636    #[test]
637    fn two_app_menus_get_distinct_routed_ids() {
638        let mut activations = HashMap::new();
639        let a = resolve_standard(
640            &StandardMenu::app().quit_intent("app.quit"),
641            &mut activations,
642            no_shortcuts,
643        );
644        let b = resolve_standard(
645            &StandardMenu::app().quit_intent("app.quit"),
646            &mut activations,
647            no_shortcuts,
648        );
649        assert_ne!(quit_item_of(&a), quit_item_of(&b));
650        assert_eq!(activations.len(), 2);
651    }
652
653    /// Routing changes what Quit *does*, never what it says: the label still
654    /// comes from the app's i18n layer, as every other standard label does.
655    #[test]
656    fn routing_leaves_the_localized_labels_alone() {
657        let mut activations = HashMap::new();
658        let node = resolve_standard(
659            &StandardMenu::app()
660                .quit(LocalizedString::literal("Quitter"))
661                .quit_intent("app.quit"),
662            &mut activations,
663            no_shortcuts,
664        );
665        assert_eq!(labels_of(&node).quit, "Quitter");
666    }
667
668    // ── The chord a routed row advertises ───────────────────────────────
669
670    /// With no shortcut named, the row falls back to the chord a Mac user
671    /// reaches for. This is the case every app gets without thinking about it,
672    /// so it has to be the conventional one.
673    #[test]
674    fn an_unnamed_shortcut_falls_back_to_the_conventional_chord() {
675        let mut activations = HashMap::new();
676        let node = resolve_standard(
677            &StandardMenu::app()
678                .quit_intent("app.quit")
679                .settings_intent("app.settings"),
680            &mut activations,
681            no_shortcuts,
682        );
683        assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
684        assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
685    }
686
687    /// Named, the chord comes from the registry — which is the whole point.
688    /// A `Ctrl` declaration has already been rewritten to the primary
689    /// accelerator by the time it reaches here, so it arrives as ⌘.
690    #[test]
691    fn a_named_shortcut_supplies_the_chord() {
692        let mut activations = HashMap::new();
693        let node = resolve_standard(
694            &StandardMenu::app()
695                .quit_intent("app.quit")
696                .quit_shortcut("app.quit"),
697            &mut activations,
698            only("app.quit", KeyStroke::command(Key::Q)),
699        );
700        assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
701    }
702
703    /// The case the fallback cannot serve: a user who rebound Quit. The row
704    /// must advertise — and therefore fire — the new chord, not the old one.
705    /// Left hardcoded, ⌘Q stays live after the user moved the command away from
706    /// it, *and* shadows wherever they moved it to, since the platform
707    /// dispatches a main-menu key equivalent before the responder chain.
708    #[test]
709    fn a_rebound_shortcut_moves_the_rows_chord_with_it() {
710        let mut activations = HashMap::new();
711        let node = resolve_standard(
712            &StandardMenu::app()
713                .quit_intent("app.quit")
714                .quit_shortcut("app.quit"),
715            &mut activations,
716            only("app.quit", KeyStroke::command_shift(Key::Q)),
717        );
718        assert_eq!(
719            chord(quit_of(&node)),
720            Some(("q".into(), true, true)),
721            "the row follows the rebind rather than keeping the convention"
722        );
723    }
724
725    /// Naming a shortcut that resolves to nothing — unregistered, or unbound by
726    /// the user — leaves the row with no key equivalent. Falling back to the
727    /// convention here would resurrect a chord the user deliberately cleared,
728    /// which is the same defect as never having read the registry.
729    #[test]
730    fn a_named_but_unbound_shortcut_leaves_the_row_chordless() {
731        let mut activations = HashMap::new();
732        let node = resolve_standard(
733            &StandardMenu::app()
734                .quit_intent("app.quit")
735                .quit_shortcut("app.quit"),
736            &mut activations,
737            no_shortcuts,
738        );
739        assert!(quit_of(&node).is_some(), "the row is still there");
740        assert_eq!(chord(quit_of(&node)), None, "it just has no chord");
741    }
742
743    /// The two rows read their own ids, not each other's.
744    #[test]
745    fn each_row_reads_its_own_shortcut() {
746        let mut activations = HashMap::new();
747        let node = resolve_standard(
748            &StandardMenu::app()
749                .quit_intent("app.quit")
750                .quit_shortcut("app.quit")
751                .settings_intent("app.settings")
752                .settings_shortcut("app.settings"),
753            &mut activations,
754            only("app.settings", KeyStroke::command(Key::Character(','))),
755        );
756        assert_eq!(chord(quit_of(&node)), None, "quit's id resolves to nothing");
757        assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
758    }
759}