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(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 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(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
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] = [
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
435fn 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 "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| §ion.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 #[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 #[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 #[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 #[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 #[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 #[test]
571 fn profiles_placeholder_appears_once() {
572 let count = menu_model(false)
573 .iter()
574 .flat_map(|section| §ion.entries)
575 .filter(|entry| matches!(entry, MenuEntry::Profiles))
576 .count();
577 assert_eq!(count, 1);
578 }
579
580 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 #[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 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 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 #[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 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}