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(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(Modifiers::empty(), 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(Modifiers::empty(), Code::F3)),
308                    MenuAction::ToggleFpsOverlay,
309                ),
310                item(
311                    "settings",
312                    "Settings...",
313                    Some(Accelerator::new(Modifiers::empty(), 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(Modifiers::empty(), 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    // muda 0.20 stores META verbatim (the old META→SUPER normalisation is gone;
412    // SUPER is a separate legacy bit nothing produces any more), so the table
413    // keys on META. macOS renders modifiers as adjacent symbols; everywhere
414    // else they are spelled out and joined with '+'.
415    let named: [(Modifiers, &str, &str); 4] = [
416        (Modifiers::CONTROL, "⌃", "Ctrl"),
417        (Modifiers::ALT, "⌥", "Alt"),
418        (Modifiers::SHIFT, "⇧", "Shift"),
419        (Modifiers::META, "⌘", "Super"),
420    ];
421    let mods = accelerator.modifiers();
422    for (flag, symbol, word) in named {
423        if mods.contains(flag) {
424            if cfg!(target_os = "macos") {
425                label.push_str(symbol);
426            } else {
427                label.push_str(word);
428                label.push('+');
429            }
430        }
431    }
432    label.push_str(&code_label(accelerator.key()));
433    label
434}
435
436/// Human-readable name for a key code (`KeyN` → `N`, `BracketLeft` → `[`).
437fn code_label(code: Code) -> String {
438    let raw = format!("{code:?}");
439    match raw.as_str() {
440        "Comma" => ",".to_string(),
441        "Period" => ".".to_string(),
442        "Equal" => "+".to_string(),
443        "Minus" => "-".to_string(),
444        "BracketLeft" => "[".to_string(),
445        "BracketRight" => "]".to_string(),
446        "Space" => "Space".to_string(),
447        // Without these the in-app menu would advertise "ArrowLeft"; "Left" is
448        // also what the settings window's keybinding table prints.
449        "ArrowLeft" => "Left".to_string(),
450        "ArrowRight" => "Right".to_string(),
451        other => other
452            .strip_prefix("Key")
453            .or_else(|| other.strip_prefix("Digit"))
454            .unwrap_or(other)
455            .to_string(),
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use std::collections::HashSet;
463
464    fn items(model: &[MenuSection]) -> Vec<&MenuItemSpec> {
465        model
466            .iter()
467            .flat_map(|section| &section.entries)
468            .filter_map(|entry| match entry {
469                MenuEntry::Item(spec) => Some(spec),
470                _ => None,
471            })
472            .collect()
473    }
474
475    #[test]
476    fn menu_ids_are_unique() {
477        for has_native_app_menu in [false, true] {
478            let model = menu_model(has_native_app_menu);
479            let mut seen = HashSet::new();
480            for spec in items(&model) {
481                assert!(
482                    seen.insert(spec.id),
483                    "duplicate menu id {:?} (has_native_app_menu={has_native_app_menu})",
484                    spec.id
485                );
486            }
487        }
488    }
489
490    /// Without a native application menu the model must carry Quit and
491    /// Preferences itself — this is exactly what Linux was missing.
492    #[test]
493    fn quit_and_preferences_present_without_native_app_menu() {
494        let model = menu_model(false);
495        let actions: Vec<MenuAction> = items(&model).iter().map(|spec| spec.action).collect();
496        assert!(actions.contains(&MenuAction::Quit));
497        assert!(actions.contains(&MenuAction::OpenSettings));
498        assert!(actions.contains(&MenuAction::NewWindow));
499        assert!(actions.contains(&MenuAction::CloseWindow));
500        assert!(actions.contains(&MenuAction::SelectAll));
501        assert!(actions.contains(&MenuAction::MaximizeVertically));
502    }
503
504    /// `MenuAction::DuplicateTab` was declared and handled but emitted by no
505    /// menu item, which left `duplicate_tab` with a handler nothing could reach.
506    #[test]
507    fn duplicate_tab_is_reachable_from_the_menu() {
508        for has_native_app_menu in [false, true] {
509            let model = menu_model(has_native_app_menu);
510            let actions: Vec<MenuAction> = items(&model).iter().map(|spec| spec.action).collect();
511            assert!(
512                actions.contains(&MenuAction::DuplicateTab),
513                "no menu item emits DuplicateTab (has_native_app_menu={has_native_app_menu})"
514            );
515        }
516    }
517
518    /// `MoveTabLeft`/`MoveTabRight` were declared and handled but emitted by no
519    /// menu item, so tab reordering existed only as an unadvertised chord.
520    #[test]
521    fn tab_reordering_is_reachable_from_the_menu() {
522        for has_native_app_menu in [false, true] {
523            let model = menu_model(has_native_app_menu);
524            let actions: Vec<MenuAction> = items(&model).iter().map(|spec| spec.action).collect();
525            for expected in [MenuAction::MoveTabLeft, MenuAction::MoveTabRight] {
526                assert!(
527                    actions.contains(&expected),
528                    "no menu item emits {expected:?} (has_native_app_menu={has_native_app_menu})"
529                );
530            }
531        }
532    }
533
534    /// macOS keeps Quit in the application menu, so File must not duplicate it.
535    #[test]
536    fn quit_absent_when_native_app_menu_owns_it() {
537        let model = menu_model(true);
538        let actions: Vec<MenuAction> = items(&model).iter().map(|spec| spec.action).collect();
539        assert!(!actions.contains(&MenuAction::Quit));
540    }
541
542    /// The two variants must offer the same commands apart from the ones the
543    /// native application menu owns.
544    #[test]
545    fn variants_differ_only_by_app_menu_items() {
546        let with_app_menu: HashSet<&str> = items(&menu_model(true))
547            .iter()
548            .map(|spec| spec.id)
549            .collect();
550        let without: HashSet<&str> = items(&menu_model(false))
551            .iter()
552            .map(|spec| spec.id)
553            .collect();
554        let extra: Vec<&&str> = without.difference(&with_app_menu).collect();
555        assert_eq!(extra.len(), 2, "unexpected difference: {extra:?}");
556        assert!(with_app_menu.difference(&without).next().is_none());
557    }
558
559    #[test]
560    fn every_section_has_entries() {
561        for section in menu_model(false) {
562            assert!(
563                !section.entries.is_empty(),
564                "section {:?} is empty",
565                section.title
566            );
567        }
568    }
569
570    /// The Profiles insertion point must exist exactly once.
571    #[test]
572    fn profiles_placeholder_appears_once() {
573        let count = menu_model(false)
574            .iter()
575            .flat_map(|section| &section.entries)
576            .filter(|entry| matches!(entry, MenuEntry::Profiles))
577            .count();
578        assert_eq!(count, 1);
579    }
580
581    /// Flatten the model to `Section/entry` lines, in order.
582    fn outline(model: &[MenuSection]) -> Vec<String> {
583        model
584            .iter()
585            .flat_map(|section| {
586                section.entries.iter().map(move |entry| match entry {
587                    MenuEntry::Item(spec) => {
588                        format!("{}/{} = {:?}", section.title, spec.id, spec.label)
589                    }
590                    MenuEntry::Separator => format!("{}/---", section.title),
591                    MenuEntry::Profiles => format!("{}/<profiles>", section.title),
592                })
593            })
594            .collect()
595    }
596
597    /// The order-sensitive snapshot.
598    ///
599    /// The macOS and Windows menus are built by walking this model, and neither
600    /// can be exercised from the other's CI. A reordered section, a dropped
601    /// separator or a renamed label is invisible to every other test here, so
602    /// this freezes the structure that shipped before the model existed. Update
603    /// it deliberately when the menu changes.
604    #[test]
605    fn model_matches_the_shipped_menu_structure() {
606        let expected_common = [
607            "File/new_window = \"New Window\"",
608            "File/close_window = \"Close\"",
609            "File/---",
610            "Tab/new_tab = \"New Tab\"",
611            "Tab/duplicate_tab = \"Duplicate Tab\"",
612            "Tab/close_tab = \"Close Tab\"",
613            "Tab/---",
614            "Tab/next_tab = \"Next Tab\"",
615            "Tab/prev_tab = \"Previous Tab\"",
616            "Tab/move_tab_left = \"Move Tab Left\"",
617            "Tab/move_tab_right = \"Move Tab Right\"",
618            "Tab/---",
619            "Tab/tab_1 = \"Tab 1\"",
620            "Tab/tab_2 = \"Tab 2\"",
621            "Tab/tab_3 = \"Tab 3\"",
622            "Tab/tab_4 = \"Tab 4\"",
623            "Tab/tab_5 = \"Tab 5\"",
624            "Tab/tab_6 = \"Tab 6\"",
625            "Tab/tab_7 = \"Tab 7\"",
626            "Tab/tab_8 = \"Tab 8\"",
627            "Tab/tab_9 = \"Tab 9\"",
628            "Profiles/manage_profiles = \"Manage Profiles...\"",
629            "Profiles/toggle_profile_drawer = \"Toggle Profile Drawer\"",
630            "Profiles/---",
631            "Profiles/<profiles>",
632            "Edit/copy = \"Copy\"",
633            "Edit/paste = \"Paste\"",
634            "Edit/select_all = \"Select All\"",
635            "Edit/---",
636            "Edit/clear_scrollback = \"Clear Scrollback\"",
637            "Edit/clipboard_history = \"Clipboard History\"",
638        ];
639        let expected_tail = [
640            "View/toggle_fullscreen = \"Toggle Fullscreen\"",
641            "View/maximize_vertically = \"Maximize Vertically\"",
642            "View/---",
643            "View/increase_font = \"Increase Font Size\"",
644            "View/decrease_font = \"Decrease Font Size\"",
645            "View/reset_font = \"Reset Font Size\"",
646            "View/---",
647            "View/fps_overlay = \"FPS Overlay\"",
648            "View/settings = \"Settings...\"",
649            "View/---",
650            "View/save_arrangement = \"Save Window Arrangement...\"",
651            "Shell/install_remote_shell_integration = \
652             \"Install Shell Integration on Remote Host...\"",
653            "Help/keyboard_shortcuts = \"Keyboard Shortcuts\"",
654            "Help/---",
655            "Help/about = \"About par-term\"",
656        ];
657
658        // macOS: Quit and Preferences belong to the native application menu.
659        let mut with_app_menu: Vec<&str> = expected_common.to_vec();
660        with_app_menu.extend(expected_tail);
661        assert_eq!(outline(&menu_model(true)), with_app_menu);
662
663        // Everywhere else they are folded into File and Edit.
664        let mut without: Vec<&str> = expected_common.to_vec();
665        without.insert(3, "File/quit = \"Quit\"");
666        without.push("Edit/---");
667        without.push("Edit/preferences = \"Preferences...\"");
668        without.extend(expected_tail);
669        assert_eq!(outline(&menu_model(false)), without);
670    }
671
672    /// Which commands carry a keyboard accelerator is part of the contract the
673    /// in-app menu advertises, and is platform-independent even though the
674    /// modifiers are not.
675    #[test]
676    fn the_same_commands_carry_accelerators() {
677        let accelerated: Vec<&str> = items(&menu_model(false))
678            .iter()
679            .filter(|spec| spec.accelerator.is_some())
680            .map(|spec| spec.id)
681            .collect();
682        assert_eq!(
683            accelerated,
684            vec![
685                "new_window",
686                "close_window",
687                "quit",
688                "new_tab",
689                "duplicate_tab",
690                "next_tab",
691                "prev_tab",
692                "move_tab_left",
693                "move_tab_right",
694                "tab_1",
695                "tab_2",
696                "tab_3",
697                "tab_4",
698                "tab_5",
699                "tab_6",
700                "tab_7",
701                "tab_8",
702                "tab_9",
703                "toggle_profile_drawer",
704                "copy",
705                "paste",
706                "select_all",
707                "clear_scrollback",
708                "clipboard_history",
709                "preferences",
710                "toggle_fullscreen",
711                "maximize_vertically",
712                "increase_font",
713                "decrease_font",
714                "reset_font",
715                "fps_overlay",
716                "settings",
717                "keyboard_shortcuts",
718            ]
719        );
720    }
721
722    #[test]
723    fn accelerator_labels_are_readable() {
724        let plain = Accelerator::new(Modifiers::empty(), Code::F11);
725        assert_eq!(accelerator_label(&plain), "F11");
726
727        let bracket = Accelerator::new(Modifiers::SHIFT, Code::BracketRight);
728        let label = accelerator_label(&bracket);
729        assert!(label.ends_with(']'), "unexpected label {label:?}");
730
731        let digit = Accelerator::new(Modifiers::ALT, Code::Digit1);
732        assert!(accelerator_label(&digit).ends_with('1'));
733
734        // The arrow keys reach the label through `code_label`'s fallback unless
735        // they are named, which would print "ArrowLeft" in the in-app menu.
736        let arrow = Accelerator::new(Modifiers::SHIFT, Code::ArrowLeft);
737        assert!(
738            accelerator_label(&arrow).ends_with("Left"),
739            "unexpected label {:?}",
740            accelerator_label(&arrow)
741        );
742        assert!(!accelerator_label(&arrow).contains("Arrow"));
743    }
744}