1use super::actions::MenuAction;
15use muda::accelerator::{Accelerator, Code, Modifiers};
16
17pub const HELP_SECTION_TITLE: &str = "Help";
22
23pub struct MenuItemSpec {
25 pub id: &'static str,
27 pub label: &'static str,
29 pub accelerator: Option<Accelerator>,
31 pub action: MenuAction,
33}
34
35pub enum MenuEntry {
37 Item(MenuItemSpec),
39 Separator,
41 Profiles,
47}
48
49pub struct MenuSection {
51 pub title: &'static str,
53 pub entries: Vec<MenuEntry>,
55}
56
57pub fn platform_menu_model() -> Vec<MenuSection> {
62 menu_model(cfg!(target_os = "macos"))
63}
64
65pub fn menu_model(has_native_app_menu: bool) -> Vec<MenuSection> {
73 #[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 #[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 #[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 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 item(
132 "duplicate_tab",
133 "Duplicate Tab",
134 accel(cmd_or_ctrl_shift, Code::KeyJ),
135 MenuAction::DuplicateTab,
136 ),
137 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 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 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 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 item(
242 "manage_profiles",
243 "Manage Profiles...",
244 None,
245 MenuAction::ManageProfiles,
246 ),
247 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
350const 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
363fn 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
378pub struct ProfileEntry {
380 pub menu_id: String,
382 pub label: String,
384 pub action: MenuAction,
386}
387
388pub 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
405pub fn accelerator_label(accelerator: &Accelerator) -> String {
410 let mut label = String::new();
411 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
436fn 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 "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| §ion.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 #[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 #[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 #[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 #[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 #[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 #[test]
572 fn profiles_placeholder_appears_once() {
573 let count = menu_model(false)
574 .iter()
575 .flat_map(|section| §ion.entries)
576 .filter(|entry| matches!(entry, MenuEntry::Profiles))
577 .count();
578 assert_eq!(count, 1);
579 }
580
581 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 #[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 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 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 #[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 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}