Skip to main content

par_term/menu/
model.rs

1//! Declarative description of par-term's menu.
2//!
3//! This is the single source of truth for the menu's contents. Two renderers
4//! consume it:
5//!
6//! - [`super::MenuManager::new`] walks it to build the [`muda::Menu`] that macOS
7//!   and Windows attach natively.
8//! - [`super::egui_menu::AppMenuUi`] walks the same model to draw the in-app
9//!   menu on platforms that cannot attach a native menu bar (Linux/BSD, where
10//!   muda needs a `gtk::Window` that winit never creates — see `super::linux`).
11//!
12//! Neither renderer owns a list of commands, so the two cannot drift apart.
13
14use super::actions::MenuAction;
15use muda::accelerator::{Accelerator, Code, Modifiers};
16
17/// Title of the Help section.
18///
19/// `MenuManager` inserts the macOS-only native Window menu immediately before
20/// this section, following the platform convention of Window preceding Help.
21pub const HELP_SECTION_TITLE: &str = "Help";
22
23/// A single activatable menu command.
24pub struct MenuItemSpec {
25    /// Stable muda menu id. Also used as the egui widget id salt.
26    pub id: &'static str,
27    /// Human-readable label.
28    pub label: &'static str,
29    /// Keyboard accelerator, if the command has one.
30    pub accelerator: Option<Accelerator>,
31    /// Action dispatched when the item is activated.
32    pub action: MenuAction,
33}
34
35/// One entry inside a menu section.
36pub enum MenuEntry {
37    /// A command.
38    Item(MenuItemSpec),
39    /// A horizontal rule.
40    Separator,
41    /// Insertion point for one entry per configured profile.
42    ///
43    /// The entries are generated at render time from the live
44    /// [`ProfileManager`] by [`profile_entries`], so both renderers stay in
45    /// sync with profile edits without duplicating the mapping.
46    Profiles,
47}
48
49/// A top-level menu (File, Tab, Edit, …).
50pub struct MenuSection {
51    /// Title shown in the menu bar.
52    pub title: &'static str,
53    /// Entries in display order.
54    pub entries: Vec<MenuEntry>,
55}
56
57/// Build the menu model for the current platform's native menu.
58///
59/// macOS carries Quit and Preferences in the separate application menu built by
60/// [`super::macos::build_app_menu`], so they are omitted from File/Edit there.
61pub fn platform_menu_model() -> Vec<MenuSection> {
62    menu_model(cfg!(target_os = "macos"))
63}
64
65/// Build the menu model.
66///
67/// `has_native_app_menu` is true when the platform provides a separate
68/// application menu that already carries Quit and Preferences (macOS). When it
69/// is false those two commands are folded into File and Edit, which is what
70/// Windows and Linux expect — and what the in-app egui menu always needs, since
71/// it is the only menu wherever it is drawn.
72pub fn menu_model(has_native_app_menu: bool) -> Vec<MenuSection> {
73    // Platform-specific modifier keys
74    // macOS: Cmd (META) is safe — it's separate from Ctrl used by terminal control codes
75    // Windows/Linux: Use Ctrl+Shift to avoid conflicts with terminal control codes
76    // (Ctrl+C=SIGINT, Ctrl+D=EOF, Ctrl+W=delete-word, Ctrl+V=literal-next, etc.)
77    #[cfg(target_os = "macos")]
78    let cmd_or_ctrl = Modifiers::META;
79    #[cfg(not(target_os = "macos"))]
80    let cmd_or_ctrl = Modifiers::CONTROL | Modifiers::SHIFT;
81
82    // For items that already include Shift (same on all platforms)
83    #[cfg(target_os = "macos")]
84    let cmd_or_ctrl_shift = Modifiers::META | Modifiers::SHIFT;
85    #[cfg(not(target_os = "macos"))]
86    let cmd_or_ctrl_shift = Modifiers::CONTROL | Modifiers::SHIFT;
87
88    // Tab number switching: Cmd+N (macOS) / Alt+N (Windows/Linux)
89    #[cfg(target_os = "macos")]
90    let tab_switch_mod = Modifiers::META;
91    #[cfg(not(target_os = "macos"))]
92    let tab_switch_mod = Modifiers::ALT;
93
94    let accel = |mods: Modifiers, code: Code| Some(Accelerator::new(Some(mods), code));
95
96    let mut file = vec![
97        item(
98            "new_window",
99            "New Window",
100            accel(cmd_or_ctrl, Code::KeyN),
101            MenuAction::NewWindow,
102        ),
103        // Smart close: closes tab if multiple, window if single
104        item(
105            "close_window",
106            "Close",
107            accel(cmd_or_ctrl, Code::KeyW),
108            MenuAction::CloseWindow,
109        ),
110        MenuEntry::Separator,
111    ];
112    if !has_native_app_menu {
113        file.push(item(
114            "quit",
115            "Quit",
116            accel(cmd_or_ctrl, Code::KeyQ),
117            MenuAction::Quit,
118        ));
119    }
120
121    let mut tab = vec![
122        item(
123            "new_tab",
124            "New Tab",
125            accel(cmd_or_ctrl, Code::KeyT),
126            MenuAction::NewTab,
127        ),
128        // Matches the `duplicate_tab` default in `Config::default().keybindings`,
129        // which is what dispatches this on Linux (no native menu there, so the
130        // in-app menu only advertises the chord — it does not register it).
131        item(
132            "duplicate_tab",
133            "Duplicate Tab",
134            accel(cmd_or_ctrl_shift, Code::KeyJ),
135            MenuAction::DuplicateTab,
136        ),
137        // No accelerator: same as Close in the File menu (smart close)
138        item("close_tab", "Close Tab", None, MenuAction::CloseTab),
139        MenuEntry::Separator,
140        item(
141            "next_tab",
142            "Next Tab",
143            accel(cmd_or_ctrl_shift, Code::BracketRight),
144            MenuAction::NextTab,
145        ),
146        item(
147            "prev_tab",
148            "Previous Tab",
149            accel(cmd_or_ctrl_shift, Code::BracketLeft),
150            MenuAction::PreviousTab,
151        ),
152        // Reordering is otherwise dispatched by the hardcoded
153        // Cmd/Ctrl+Shift+Arrow layer in `key_handler::tabs`. The accelerators
154        // here name that same chord, and the settings window's
155        // `AVAILABLE_ACTIONS` advertises it — `key_handler::chord_tests` checks
156        // all three agree.
157        item(
158            "move_tab_left",
159            "Move Tab Left",
160            accel(cmd_or_ctrl_shift, Code::ArrowLeft),
161            MenuAction::MoveTabLeft,
162        ),
163        item(
164            "move_tab_right",
165            "Move Tab Right",
166            accel(cmd_or_ctrl_shift, Code::ArrowRight),
167            MenuAction::MoveTabRight,
168        ),
169        MenuEntry::Separator,
170    ];
171    for (index, (id, label, code)) in TAB_SWITCH_ITEMS.iter().enumerate() {
172        tab.push(item(
173            id,
174            label,
175            accel(tab_switch_mod, *code),
176            MenuAction::SwitchToTab(index + 1),
177        ));
178    }
179
180    let mut edit = vec![
181        // Copy/Paste/Select All: Cmd+C/V/A (macOS) / Ctrl+Shift+C/V/A (other)
182        item(
183            "copy",
184            "Copy",
185            accel(cmd_or_ctrl, Code::KeyC),
186            MenuAction::Copy,
187        ),
188        item(
189            "paste",
190            "Paste",
191            accel(cmd_or_ctrl, Code::KeyV),
192            MenuAction::Paste,
193        ),
194        item(
195            "select_all",
196            "Select All",
197            accel(cmd_or_ctrl, Code::KeyA),
198            MenuAction::SelectAll,
199        ),
200        MenuEntry::Separator,
201        item(
202            "clear_scrollback",
203            "Clear Scrollback",
204            accel(cmd_or_ctrl_shift, Code::KeyK),
205            MenuAction::ClearScrollback,
206        ),
207        item(
208            "clipboard_history",
209            "Clipboard History",
210            accel(cmd_or_ctrl_shift, Code::KeyH),
211            MenuAction::ClipboardHistory,
212        ),
213    ];
214    if !has_native_app_menu {
215        // Preferences belongs in Edit on Windows and Linux.
216        edit.push(MenuEntry::Separator);
217        edit.push(item(
218            "preferences",
219            "Preferences...",
220            accel(Modifiers::CONTROL | Modifiers::SHIFT, Code::Comma),
221            MenuAction::OpenSettings,
222        ));
223    }
224
225    vec![
226        MenuSection {
227            title: "File",
228            entries: file,
229        },
230        MenuSection {
231            title: "Tab",
232            entries: tab,
233        },
234        MenuSection {
235            title: "Profiles",
236            entries: vec![
237                // `Manage Profiles...` is a configuration dialog, also reachable
238                // from Settings, and it has no `AVAILABLE_ACTIONS` row, so a
239                // native accelerator on it burned Cmd/Ctrl+Shift+P for a chord
240                // no user could rebind.
241                item(
242                    "manage_profiles",
243                    "Manage Profiles...",
244                    None,
245                    MenuAction::ManageProfiles,
246                ),
247                // The drawer owns Cmd/Ctrl+Shift+P: the settings table
248                // advertises it, `Config::default().keybindings` binds it, and
249                // the hardcoded `profile_drawer_toggle` layer dispatches it.
250                // While `Manage Profiles...` held the accelerator the native
251                // menu bar ate the chord on macOS and Windows, and on Linux —
252                // where the in-app menu only *labels* accelerators — the same
253                // chord already reached the drawer while the menu named the
254                // manager. See `key_handler::chord_tests`.
255                item(
256                    "toggle_profile_drawer",
257                    "Toggle Profile Drawer",
258                    accel(cmd_or_ctrl_shift, Code::KeyP),
259                    MenuAction::ToggleProfileDrawer,
260                ),
261                MenuEntry::Separator,
262                MenuEntry::Profiles,
263            ],
264        },
265        MenuSection {
266            title: "Edit",
267            entries: edit,
268        },
269        MenuSection {
270            title: "View",
271            entries: vec![
272                item(
273                    "toggle_fullscreen",
274                    "Toggle Fullscreen",
275                    Some(Accelerator::new(None, Code::F11)),
276                    MenuAction::ToggleFullscreen,
277                ),
278                item(
279                    "maximize_vertically",
280                    "Maximize Vertically",
281                    accel(Modifiers::SHIFT, Code::F11),
282                    MenuAction::MaximizeVertically,
283                ),
284                MenuEntry::Separator,
285                item(
286                    "increase_font",
287                    "Increase Font Size",
288                    accel(cmd_or_ctrl, Code::Equal),
289                    MenuAction::IncreaseFontSize,
290                ),
291                item(
292                    "decrease_font",
293                    "Decrease Font Size",
294                    accel(cmd_or_ctrl, Code::Minus),
295                    MenuAction::DecreaseFontSize,
296                ),
297                item(
298                    "reset_font",
299                    "Reset Font Size",
300                    accel(cmd_or_ctrl, Code::Digit0),
301                    MenuAction::ResetFontSize,
302                ),
303                MenuEntry::Separator,
304                item(
305                    "fps_overlay",
306                    "FPS Overlay",
307                    Some(Accelerator::new(None, Code::F3)),
308                    MenuAction::ToggleFpsOverlay,
309                ),
310                item(
311                    "settings",
312                    "Settings...",
313                    Some(Accelerator::new(None, Code::F12)),
314                    MenuAction::OpenSettings,
315                ),
316                MenuEntry::Separator,
317                item(
318                    "save_arrangement",
319                    "Save Window Arrangement...",
320                    None,
321                    MenuAction::SaveArrangement,
322                ),
323            ],
324        },
325        MenuSection {
326            title: "Shell",
327            entries: vec![item(
328                "install_remote_shell_integration",
329                "Install Shell Integration on Remote Host...",
330                None,
331                MenuAction::InstallShellIntegrationRemote,
332            )],
333        },
334        MenuSection {
335            title: HELP_SECTION_TITLE,
336            entries: vec![
337                item(
338                    "keyboard_shortcuts",
339                    "Keyboard Shortcuts",
340                    Some(Accelerator::new(None, Code::F1)),
341                    MenuAction::ShowHelp,
342                ),
343                MenuEntry::Separator,
344                item("about", "About par-term", None, MenuAction::About),
345            ],
346        },
347    ]
348}
349
350/// Menu ids, labels and key codes for the Tab 1-9 switch items.
351const TAB_SWITCH_ITEMS: [(&str, &str, Code); 9] = [
352    ("tab_1", "Tab 1", Code::Digit1),
353    ("tab_2", "Tab 2", Code::Digit2),
354    ("tab_3", "Tab 3", Code::Digit3),
355    ("tab_4", "Tab 4", Code::Digit4),
356    ("tab_5", "Tab 5", Code::Digit5),
357    ("tab_6", "Tab 6", Code::Digit6),
358    ("tab_7", "Tab 7", Code::Digit7),
359    ("tab_8", "Tab 8", Code::Digit8),
360    ("tab_9", "Tab 9", Code::Digit9),
361];
362
363/// Shorthand for a command entry.
364fn item(
365    id: &'static str,
366    label: &'static str,
367    accelerator: Option<Accelerator>,
368    action: MenuAction,
369) -> MenuEntry {
370    MenuEntry::Item(MenuItemSpec {
371        id,
372        label,
373        accelerator,
374        action,
375    })
376}
377
378/// One dynamically generated profile entry.
379pub struct ProfileEntry {
380    /// Stable muda menu id.
381    pub menu_id: String,
382    /// Label as shown in the menu.
383    pub label: String,
384    /// Action dispatched when the entry is activated.
385    pub action: MenuAction,
386}
387
388/// Expand [`MenuEntry::Profiles`] into one entry per configured profile.
389///
390/// Shared by the muda and egui renderers so both show the same profiles, with
391/// the same labels, in the same order.
392pub fn profile_entries<'a>(
393    profiles: impl IntoIterator<Item = &'a crate::profile::Profile>,
394) -> Vec<ProfileEntry> {
395    profiles
396        .into_iter()
397        .map(|profile| ProfileEntry {
398            menu_id: format!("profile_{}", profile.id),
399            label: profile.display_label(),
400            action: MenuAction::OpenProfile(profile.id),
401        })
402        .collect()
403}
404
405/// Render an accelerator the way a menu displays it, e.g. `⌘N` or `Ctrl+Shift+N`.
406///
407/// Derived from the same [`Accelerator`] the native menu registers, so the
408/// in-app menu cannot advertise a shortcut the native menu does not have.
409pub fn accelerator_label(accelerator: &Accelerator) -> String {
410    let mut label = String::new();
411    // `Accelerator::new` normalises META to SUPER, so only SUPER is ever set.
412    // macOS renders modifiers as adjacent symbols; everywhere else they are
413    // spelled out and joined with '+'.
414    let named: [(Modifiers, &str, &str); 4] = [
415        (Modifiers::CONTROL, "⌃", "Ctrl"),
416        (Modifiers::ALT, "⌥", "Alt"),
417        (Modifiers::SHIFT, "⇧", "Shift"),
418        (Modifiers::SUPER, "⌘", "Super"),
419    ];
420    let mods = accelerator.modifiers();
421    for (flag, symbol, word) in named {
422        if mods.contains(flag) {
423            if cfg!(target_os = "macos") {
424                label.push_str(symbol);
425            } else {
426                label.push_str(word);
427                label.push('+');
428            }
429        }
430    }
431    label.push_str(&code_label(accelerator.key()));
432    label
433}
434
435/// Human-readable name for a key code (`KeyN` → `N`, `BracketLeft` → `[`).
436fn code_label(code: Code) -> String {
437    let raw = format!("{code:?}");
438    match raw.as_str() {
439        "Comma" => ",".to_string(),
440        "Period" => ".".to_string(),
441        "Equal" => "+".to_string(),
442        "Minus" => "-".to_string(),
443        "BracketLeft" => "[".to_string(),
444        "BracketRight" => "]".to_string(),
445        "Space" => "Space".to_string(),
446        // Without these the in-app menu would advertise "ArrowLeft"; "Left" is
447        // also what the settings window's keybinding table prints.
448        "ArrowLeft" => "Left".to_string(),
449        "ArrowRight" => "Right".to_string(),
450        other => other
451            .strip_prefix("Key")
452            .or_else(|| other.strip_prefix("Digit"))
453            .unwrap_or(other)
454            .to_string(),
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use std::collections::HashSet;
462
463    fn items(model: &[MenuSection]) -> Vec<&MenuItemSpec> {
464        model
465            .iter()
466            .flat_map(|section| &section.entries)
467            .filter_map(|entry| match entry {
468                MenuEntry::Item(spec) => Some(spec),
469                _ => None,
470            })
471            .collect()
472    }
473
474    #[test]
475    fn menu_ids_are_unique() {
476        for has_native_app_menu in [false, true] {
477            let model = menu_model(has_native_app_menu);
478            let mut seen = HashSet::new();
479            for spec in items(&model) {
480                assert!(
481                    seen.insert(spec.id),
482                    "duplicate menu id {:?} (has_native_app_menu={has_native_app_menu})",
483                    spec.id
484                );
485            }
486        }
487    }
488
489    /// Without a native application menu the model must carry Quit and
490    /// Preferences itself — this is exactly what Linux was missing.
491    #[test]
492    fn quit_and_preferences_present_without_native_app_menu() {
493        let model = menu_model(false);
494        let actions: Vec<MenuAction> = items(&model).iter().map(|spec| spec.action).collect();
495        assert!(actions.contains(&MenuAction::Quit));
496        assert!(actions.contains(&MenuAction::OpenSettings));
497        assert!(actions.contains(&MenuAction::NewWindow));
498        assert!(actions.contains(&MenuAction::CloseWindow));
499        assert!(actions.contains(&MenuAction::SelectAll));
500        assert!(actions.contains(&MenuAction::MaximizeVertically));
501    }
502
503    /// `MenuAction::DuplicateTab` was declared and handled but emitted by no
504    /// menu item, which left `duplicate_tab` with a handler nothing could reach.
505    #[test]
506    fn duplicate_tab_is_reachable_from_the_menu() {
507        for has_native_app_menu in [false, true] {
508            let model = menu_model(has_native_app_menu);
509            let actions: Vec<MenuAction> = items(&model).iter().map(|spec| spec.action).collect();
510            assert!(
511                actions.contains(&MenuAction::DuplicateTab),
512                "no menu item emits DuplicateTab (has_native_app_menu={has_native_app_menu})"
513            );
514        }
515    }
516
517    /// `MoveTabLeft`/`MoveTabRight` were declared and handled but emitted by no
518    /// menu item, so tab reordering existed only as an unadvertised chord.
519    #[test]
520    fn tab_reordering_is_reachable_from_the_menu() {
521        for has_native_app_menu in [false, true] {
522            let model = menu_model(has_native_app_menu);
523            let actions: Vec<MenuAction> = items(&model).iter().map(|spec| spec.action).collect();
524            for expected in [MenuAction::MoveTabLeft, MenuAction::MoveTabRight] {
525                assert!(
526                    actions.contains(&expected),
527                    "no menu item emits {expected:?} (has_native_app_menu={has_native_app_menu})"
528                );
529            }
530        }
531    }
532
533    /// macOS keeps Quit in the application menu, so File must not duplicate it.
534    #[test]
535    fn quit_absent_when_native_app_menu_owns_it() {
536        let model = menu_model(true);
537        let actions: Vec<MenuAction> = items(&model).iter().map(|spec| spec.action).collect();
538        assert!(!actions.contains(&MenuAction::Quit));
539    }
540
541    /// The two variants must offer the same commands apart from the ones the
542    /// native application menu owns.
543    #[test]
544    fn variants_differ_only_by_app_menu_items() {
545        let with_app_menu: HashSet<&str> = items(&menu_model(true))
546            .iter()
547            .map(|spec| spec.id)
548            .collect();
549        let without: HashSet<&str> = items(&menu_model(false))
550            .iter()
551            .map(|spec| spec.id)
552            .collect();
553        let extra: Vec<&&str> = without.difference(&with_app_menu).collect();
554        assert_eq!(extra.len(), 2, "unexpected difference: {extra:?}");
555        assert!(with_app_menu.difference(&without).next().is_none());
556    }
557
558    #[test]
559    fn every_section_has_entries() {
560        for section in menu_model(false) {
561            assert!(
562                !section.entries.is_empty(),
563                "section {:?} is empty",
564                section.title
565            );
566        }
567    }
568
569    /// The Profiles insertion point must exist exactly once.
570    #[test]
571    fn profiles_placeholder_appears_once() {
572        let count = menu_model(false)
573            .iter()
574            .flat_map(|section| &section.entries)
575            .filter(|entry| matches!(entry, MenuEntry::Profiles))
576            .count();
577        assert_eq!(count, 1);
578    }
579
580    /// Flatten the model to `Section/entry` lines, in order.
581    fn outline(model: &[MenuSection]) -> Vec<String> {
582        model
583            .iter()
584            .flat_map(|section| {
585                section.entries.iter().map(move |entry| match entry {
586                    MenuEntry::Item(spec) => {
587                        format!("{}/{} = {:?}", section.title, spec.id, spec.label)
588                    }
589                    MenuEntry::Separator => format!("{}/---", section.title),
590                    MenuEntry::Profiles => format!("{}/<profiles>", section.title),
591                })
592            })
593            .collect()
594    }
595
596    /// The order-sensitive snapshot.
597    ///
598    /// The macOS and Windows menus are built by walking this model, and neither
599    /// can be exercised from the other's CI. A reordered section, a dropped
600    /// separator or a renamed label is invisible to every other test here, so
601    /// this freezes the structure that shipped before the model existed. Update
602    /// it deliberately when the menu changes.
603    #[test]
604    fn model_matches_the_shipped_menu_structure() {
605        let expected_common = [
606            "File/new_window = \"New Window\"",
607            "File/close_window = \"Close\"",
608            "File/---",
609            "Tab/new_tab = \"New Tab\"",
610            "Tab/duplicate_tab = \"Duplicate Tab\"",
611            "Tab/close_tab = \"Close Tab\"",
612            "Tab/---",
613            "Tab/next_tab = \"Next Tab\"",
614            "Tab/prev_tab = \"Previous Tab\"",
615            "Tab/move_tab_left = \"Move Tab Left\"",
616            "Tab/move_tab_right = \"Move Tab Right\"",
617            "Tab/---",
618            "Tab/tab_1 = \"Tab 1\"",
619            "Tab/tab_2 = \"Tab 2\"",
620            "Tab/tab_3 = \"Tab 3\"",
621            "Tab/tab_4 = \"Tab 4\"",
622            "Tab/tab_5 = \"Tab 5\"",
623            "Tab/tab_6 = \"Tab 6\"",
624            "Tab/tab_7 = \"Tab 7\"",
625            "Tab/tab_8 = \"Tab 8\"",
626            "Tab/tab_9 = \"Tab 9\"",
627            "Profiles/manage_profiles = \"Manage Profiles...\"",
628            "Profiles/toggle_profile_drawer = \"Toggle Profile Drawer\"",
629            "Profiles/---",
630            "Profiles/<profiles>",
631            "Edit/copy = \"Copy\"",
632            "Edit/paste = \"Paste\"",
633            "Edit/select_all = \"Select All\"",
634            "Edit/---",
635            "Edit/clear_scrollback = \"Clear Scrollback\"",
636            "Edit/clipboard_history = \"Clipboard History\"",
637        ];
638        let expected_tail = [
639            "View/toggle_fullscreen = \"Toggle Fullscreen\"",
640            "View/maximize_vertically = \"Maximize Vertically\"",
641            "View/---",
642            "View/increase_font = \"Increase Font Size\"",
643            "View/decrease_font = \"Decrease Font Size\"",
644            "View/reset_font = \"Reset Font Size\"",
645            "View/---",
646            "View/fps_overlay = \"FPS Overlay\"",
647            "View/settings = \"Settings...\"",
648            "View/---",
649            "View/save_arrangement = \"Save Window Arrangement...\"",
650            "Shell/install_remote_shell_integration = \
651             \"Install Shell Integration on Remote Host...\"",
652            "Help/keyboard_shortcuts = \"Keyboard Shortcuts\"",
653            "Help/---",
654            "Help/about = \"About par-term\"",
655        ];
656
657        // macOS: Quit and Preferences belong to the native application menu.
658        let mut with_app_menu: Vec<&str> = expected_common.to_vec();
659        with_app_menu.extend(expected_tail);
660        assert_eq!(outline(&menu_model(true)), with_app_menu);
661
662        // Everywhere else they are folded into File and Edit.
663        let mut without: Vec<&str> = expected_common.to_vec();
664        without.insert(3, "File/quit = \"Quit\"");
665        without.push("Edit/---");
666        without.push("Edit/preferences = \"Preferences...\"");
667        without.extend(expected_tail);
668        assert_eq!(outline(&menu_model(false)), without);
669    }
670
671    /// Which commands carry a keyboard accelerator is part of the contract the
672    /// in-app menu advertises, and is platform-independent even though the
673    /// modifiers are not.
674    #[test]
675    fn the_same_commands_carry_accelerators() {
676        let accelerated: Vec<&str> = items(&menu_model(false))
677            .iter()
678            .filter(|spec| spec.accelerator.is_some())
679            .map(|spec| spec.id)
680            .collect();
681        assert_eq!(
682            accelerated,
683            vec![
684                "new_window",
685                "close_window",
686                "quit",
687                "new_tab",
688                "duplicate_tab",
689                "next_tab",
690                "prev_tab",
691                "move_tab_left",
692                "move_tab_right",
693                "tab_1",
694                "tab_2",
695                "tab_3",
696                "tab_4",
697                "tab_5",
698                "tab_6",
699                "tab_7",
700                "tab_8",
701                "tab_9",
702                "toggle_profile_drawer",
703                "copy",
704                "paste",
705                "select_all",
706                "clear_scrollback",
707                "clipboard_history",
708                "preferences",
709                "toggle_fullscreen",
710                "maximize_vertically",
711                "increase_font",
712                "decrease_font",
713                "reset_font",
714                "fps_overlay",
715                "settings",
716                "keyboard_shortcuts",
717            ]
718        );
719    }
720
721    #[test]
722    fn accelerator_labels_are_readable() {
723        let plain = Accelerator::new(None, Code::F11);
724        assert_eq!(accelerator_label(&plain), "F11");
725
726        let bracket = Accelerator::new(Some(Modifiers::SHIFT), Code::BracketRight);
727        let label = accelerator_label(&bracket);
728        assert!(label.ends_with(']'), "unexpected label {label:?}");
729
730        let digit = Accelerator::new(Some(Modifiers::ALT), Code::Digit1);
731        assert!(accelerator_label(&digit).ends_with('1'));
732
733        // The arrow keys reach the label through `code_label`'s fallback unless
734        // they are named, which would print "ArrowLeft" in the in-app menu.
735        let arrow = Accelerator::new(Some(Modifiers::SHIFT), Code::ArrowLeft);
736        assert!(
737            accelerator_label(&arrow).ends_with("Left"),
738            "unexpected label {:?}",
739            accelerator_label(&arrow)
740        );
741        assert!(!accelerator_label(&arrow).contains("Arrow"));
742    }
743}