Skip to main content

par_term/menu/
mod.rs

1//! Native menu support for par-term
2//!
3//! This module provides cross-platform native menu support using the `muda` crate.
4//! - macOS: Global application menu bar
5//! - Windows/Linux: Per-window menu bar
6
7mod actions;
8
9pub use actions::MenuAction;
10
11use crate::profile::Profile;
12use anyhow::Result;
13use muda::{
14    Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu,
15    accelerator::{Accelerator, Code, Modifiers},
16};
17use std::collections::HashMap;
18use std::sync::Arc;
19use winit::window::Window;
20
21/// Manages the native menu system
22pub struct MenuManager {
23    /// The root menu
24    #[allow(dead_code)]
25    menu: Menu,
26    /// Mapping from menu item IDs to actions
27    action_map: HashMap<MenuId, MenuAction>,
28    /// Profiles submenu for dynamic profile items
29    profiles_submenu: Submenu,
30    /// Track profile menu items for cleanup
31    profile_menu_items: Vec<MenuItem>,
32}
33
34impl MenuManager {
35    /// Create a new menu manager with the default menu structure
36    pub fn new() -> Result<Self> {
37        let menu = Menu::new();
38        let mut action_map = HashMap::new();
39
40        // Platform-specific modifier keys
41        // macOS: Cmd (META) is safe — it's separate from Ctrl used by terminal control codes
42        // Windows/Linux: Use Ctrl+Shift to avoid conflicts with terminal control codes
43        // (Ctrl+C=SIGINT, Ctrl+D=EOF, Ctrl+W=delete-word, Ctrl+V=literal-next, etc.)
44        #[cfg(target_os = "macos")]
45        let cmd_or_ctrl = Modifiers::META;
46        #[cfg(not(target_os = "macos"))]
47        let cmd_or_ctrl = Modifiers::CONTROL | Modifiers::SHIFT;
48
49        // For items that already include Shift (same on all platforms)
50        #[cfg(target_os = "macos")]
51        let cmd_or_ctrl_shift = Modifiers::META | Modifiers::SHIFT;
52        #[cfg(not(target_os = "macos"))]
53        let cmd_or_ctrl_shift = Modifiers::CONTROL | Modifiers::SHIFT;
54
55        // Tab number switching: Cmd+N (macOS) / Alt+N (Windows/Linux)
56        #[cfg(target_os = "macos")]
57        let tab_switch_mod = Modifiers::META;
58        #[cfg(not(target_os = "macos"))]
59        let tab_switch_mod = Modifiers::ALT;
60
61        // macOS: Application menu (must be first submenu — becomes the macOS app menu)
62        #[cfg(target_os = "macos")]
63        {
64            let app_menu = Submenu::new("par-term", true);
65
66            // About par-term
67            let about_app = MenuItem::with_id("about_app", "About par-term", true, None);
68            action_map.insert(about_app.id().clone(), MenuAction::About);
69            app_menu.append(&about_app)?;
70
71            app_menu.append(&PredefinedMenuItem::separator())?;
72
73            // Settings... (Cmd+,) — standard macOS settings shortcut
74            let settings_app = MenuItem::with_id(
75                "settings_app",
76                "Settings...",
77                true,
78                Some(Accelerator::new(Some(Modifiers::META), Code::Comma)),
79            );
80            action_map.insert(settings_app.id().clone(), MenuAction::OpenSettings);
81            app_menu.append(&settings_app)?;
82
83            app_menu.append(&PredefinedMenuItem::separator())?;
84
85            app_menu.append(&PredefinedMenuItem::services(None))?;
86
87            app_menu.append(&PredefinedMenuItem::separator())?;
88
89            app_menu.append(&PredefinedMenuItem::hide(None))?;
90            app_menu.append(&PredefinedMenuItem::hide_others(None))?;
91            app_menu.append(&PredefinedMenuItem::show_all(None))?;
92
93            app_menu.append(&PredefinedMenuItem::separator())?;
94
95            app_menu.append(&PredefinedMenuItem::quit(None))?;
96
97            menu.append(&app_menu)?;
98        }
99
100        // File menu
101        let file_menu = Submenu::new("File", true);
102
103        let new_window = MenuItem::with_id(
104            "new_window",
105            "New Window",
106            true,
107            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyN)),
108        );
109        action_map.insert(new_window.id().clone(), MenuAction::NewWindow);
110        file_menu.append(&new_window)?;
111
112        let close_window = MenuItem::with_id(
113            "close_window",
114            "Close", // Smart close: closes tab if multiple, window if single
115            true,
116            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyW)),
117        );
118        action_map.insert(close_window.id().clone(), MenuAction::CloseWindow);
119        file_menu.append(&close_window)?;
120
121        file_menu.append(&PredefinedMenuItem::separator())?;
122
123        // On macOS, Quit is in the app menu (handled automatically)
124        // On Windows/Linux, add Quit to File menu
125        #[cfg(not(target_os = "macos"))]
126        {
127            let quit = MenuItem::with_id(
128                "quit",
129                "Quit",
130                true,
131                Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyQ)),
132            );
133            action_map.insert(quit.id().clone(), MenuAction::Quit);
134            file_menu.append(&quit)?;
135        }
136
137        menu.append(&file_menu)?;
138
139        // Tab menu
140        let tab_menu = Submenu::new("Tab", true);
141
142        let new_tab = MenuItem::with_id(
143            "new_tab",
144            "New Tab",
145            true,
146            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyT)),
147        );
148        action_map.insert(new_tab.id().clone(), MenuAction::NewTab);
149        tab_menu.append(&new_tab)?;
150
151        let close_tab = MenuItem::with_id(
152            "close_tab",
153            "Close Tab",
154            true,
155            None, // Same as Close in File menu (smart close)
156        );
157        action_map.insert(close_tab.id().clone(), MenuAction::CloseTab);
158        tab_menu.append(&close_tab)?;
159
160        tab_menu.append(&PredefinedMenuItem::separator())?;
161
162        let next_tab = MenuItem::with_id(
163            "next_tab",
164            "Next Tab",
165            true,
166            Some(Accelerator::new(
167                Some(cmd_or_ctrl_shift),
168                Code::BracketRight,
169            )),
170        );
171        action_map.insert(next_tab.id().clone(), MenuAction::NextTab);
172        tab_menu.append(&next_tab)?;
173
174        let prev_tab = MenuItem::with_id(
175            "prev_tab",
176            "Previous Tab",
177            true,
178            Some(Accelerator::new(Some(cmd_or_ctrl_shift), Code::BracketLeft)),
179        );
180        action_map.insert(prev_tab.id().clone(), MenuAction::PreviousTab);
181        tab_menu.append(&prev_tab)?;
182
183        tab_menu.append(&PredefinedMenuItem::separator())?;
184
185        // Tab 1-9 shortcuts: Cmd+N (macOS) / Alt+N (Windows/Linux)
186        for i in 1..=9 {
187            let code = match i {
188                1 => Code::Digit1,
189                2 => Code::Digit2,
190                3 => Code::Digit3,
191                4 => Code::Digit4,
192                5 => Code::Digit5,
193                6 => Code::Digit6,
194                7 => Code::Digit7,
195                8 => Code::Digit8,
196                9 => Code::Digit9,
197                _ => unreachable!(),
198            };
199            let tab_item = MenuItem::with_id(
200                format!("tab_{}", i),
201                format!("Tab {}", i),
202                true,
203                Some(Accelerator::new(Some(tab_switch_mod), code)),
204            );
205            action_map.insert(tab_item.id().clone(), MenuAction::SwitchToTab(i));
206            tab_menu.append(&tab_item)?;
207        }
208
209        menu.append(&tab_menu)?;
210
211        // Profiles menu
212        let profiles_menu = Submenu::new("Profiles", true);
213
214        let manage_profiles = MenuItem::with_id(
215            "manage_profiles",
216            "Manage Profiles...",
217            true,
218            Some(Accelerator::new(Some(cmd_or_ctrl_shift), Code::KeyP)),
219        );
220        action_map.insert(manage_profiles.id().clone(), MenuAction::ManageProfiles);
221        profiles_menu.append(&manage_profiles)?;
222
223        let toggle_drawer = MenuItem::with_id(
224            "toggle_profile_drawer",
225            "Toggle Profile Drawer",
226            true,
227            None, // Same shortcut as manage for now, or use different
228        );
229        action_map.insert(toggle_drawer.id().clone(), MenuAction::ToggleProfileDrawer);
230        profiles_menu.append(&toggle_drawer)?;
231
232        profiles_menu.append(&PredefinedMenuItem::separator())?;
233
234        // Dynamic profile menu items will be added via update_profiles()
235
236        menu.append(&profiles_menu)?;
237
238        // Store reference to profiles submenu for dynamic updates
239        let profiles_submenu = profiles_menu;
240
241        // Edit menu
242        let edit_menu = Submenu::new("Edit", true);
243
244        // Copy/Paste/Select All: Cmd+C/V/A (macOS) / Ctrl+Shift+C/V/A (other)
245        let copy = MenuItem::with_id(
246            "copy",
247            "Copy",
248            true,
249            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyC)),
250        );
251        action_map.insert(copy.id().clone(), MenuAction::Copy);
252        edit_menu.append(&copy)?;
253
254        let paste = MenuItem::with_id(
255            "paste",
256            "Paste",
257            true,
258            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyV)),
259        );
260        action_map.insert(paste.id().clone(), MenuAction::Paste);
261        edit_menu.append(&paste)?;
262
263        let select_all = MenuItem::with_id(
264            "select_all",
265            "Select All",
266            true,
267            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyA)),
268        );
269        action_map.insert(select_all.id().clone(), MenuAction::SelectAll);
270        edit_menu.append(&select_all)?;
271
272        edit_menu.append(&PredefinedMenuItem::separator())?;
273
274        let clear_scrollback = MenuItem::with_id(
275            "clear_scrollback",
276            "Clear Scrollback",
277            true,
278            Some(Accelerator::new(Some(cmd_or_ctrl_shift), Code::KeyK)),
279        );
280        action_map.insert(clear_scrollback.id().clone(), MenuAction::ClearScrollback);
281        edit_menu.append(&clear_scrollback)?;
282
283        let clipboard_history = MenuItem::with_id(
284            "clipboard_history",
285            "Clipboard History",
286            true,
287            Some(Accelerator::new(Some(cmd_or_ctrl_shift), Code::KeyH)),
288        );
289        action_map.insert(clipboard_history.id().clone(), MenuAction::ClipboardHistory);
290        edit_menu.append(&clipboard_history)?;
291
292        // Windows/Linux: Add Preferences to Edit menu (standard location on these platforms)
293        #[cfg(not(target_os = "macos"))]
294        {
295            edit_menu.append(&PredefinedMenuItem::separator())?;
296
297            let preferences = MenuItem::with_id(
298                "preferences",
299                "Preferences...",
300                true,
301                Some(Accelerator::new(
302                    Some(Modifiers::CONTROL | Modifiers::SHIFT),
303                    Code::Comma,
304                )),
305            );
306            action_map.insert(preferences.id().clone(), MenuAction::OpenSettings);
307            edit_menu.append(&preferences)?;
308        }
309
310        menu.append(&edit_menu)?;
311
312        // View menu
313        let view_menu = Submenu::new("View", true);
314
315        let toggle_fullscreen = MenuItem::with_id(
316            "toggle_fullscreen",
317            "Toggle Fullscreen",
318            true,
319            Some(Accelerator::new(None, Code::F11)),
320        );
321        action_map.insert(toggle_fullscreen.id().clone(), MenuAction::ToggleFullscreen);
322        view_menu.append(&toggle_fullscreen)?;
323
324        let maximize_vertically = MenuItem::with_id(
325            "maximize_vertically",
326            "Maximize Vertically",
327            true,
328            Some(Accelerator::new(Some(Modifiers::SHIFT), Code::F11)),
329        );
330        action_map.insert(
331            maximize_vertically.id().clone(),
332            MenuAction::MaximizeVertically,
333        );
334        view_menu.append(&maximize_vertically)?;
335
336        view_menu.append(&PredefinedMenuItem::separator())?;
337
338        let increase_font = MenuItem::with_id(
339            "increase_font",
340            "Increase Font Size",
341            true,
342            Some(Accelerator::new(Some(cmd_or_ctrl), Code::Equal)),
343        );
344        action_map.insert(increase_font.id().clone(), MenuAction::IncreaseFontSize);
345        view_menu.append(&increase_font)?;
346
347        let decrease_font = MenuItem::with_id(
348            "decrease_font",
349            "Decrease Font Size",
350            true,
351            Some(Accelerator::new(Some(cmd_or_ctrl), Code::Minus)),
352        );
353        action_map.insert(decrease_font.id().clone(), MenuAction::DecreaseFontSize);
354        view_menu.append(&decrease_font)?;
355
356        let reset_font = MenuItem::with_id(
357            "reset_font",
358            "Reset Font Size",
359            true,
360            Some(Accelerator::new(Some(cmd_or_ctrl), Code::Digit0)),
361        );
362        action_map.insert(reset_font.id().clone(), MenuAction::ResetFontSize);
363        view_menu.append(&reset_font)?;
364
365        view_menu.append(&PredefinedMenuItem::separator())?;
366
367        let fps_overlay = MenuItem::with_id(
368            "fps_overlay",
369            "FPS Overlay",
370            true,
371            Some(Accelerator::new(None, Code::F3)),
372        );
373        action_map.insert(fps_overlay.id().clone(), MenuAction::ToggleFpsOverlay);
374        view_menu.append(&fps_overlay)?;
375
376        let settings = MenuItem::with_id(
377            "settings",
378            "Settings...",
379            true,
380            Some(Accelerator::new(None, Code::F12)),
381        );
382        action_map.insert(settings.id().clone(), MenuAction::OpenSettings);
383        view_menu.append(&settings)?;
384
385        view_menu.append(&PredefinedMenuItem::separator())?;
386
387        let save_arrangement =
388            MenuItem::with_id("save_arrangement", "Save Window Arrangement...", true, None);
389        action_map.insert(save_arrangement.id().clone(), MenuAction::SaveArrangement);
390        view_menu.append(&save_arrangement)?;
391
392        menu.append(&view_menu)?;
393
394        // Shell menu
395        let shell_menu = Submenu::new("Shell", true);
396
397        let install_remote_integration = MenuItem::with_id(
398            "install_remote_shell_integration",
399            "Install Shell Integration on Remote Host...",
400            true,
401            None,
402        );
403        action_map.insert(
404            install_remote_integration.id().clone(),
405            MenuAction::InstallShellIntegrationRemote,
406        );
407        shell_menu.append(&install_remote_integration)?;
408
409        menu.append(&shell_menu)?;
410
411        // Window menu (primarily for macOS)
412        #[cfg(target_os = "macos")]
413        {
414            let window_menu = Submenu::new("Window", true);
415
416            let minimize = MenuItem::with_id(
417                "minimize",
418                "Minimize",
419                true,
420                Some(Accelerator::new(Some(Modifiers::META), Code::KeyM)),
421            );
422            action_map.insert(minimize.id().clone(), MenuAction::Minimize);
423            window_menu.append(&minimize)?;
424
425            let zoom = MenuItem::with_id("zoom", "Zoom", true, None);
426            action_map.insert(zoom.id().clone(), MenuAction::Zoom);
427            window_menu.append(&zoom)?;
428
429            menu.append(&window_menu)?;
430        }
431
432        // Help menu
433        let help_menu = Submenu::new("Help", true);
434
435        let keyboard_shortcuts = MenuItem::with_id(
436            "keyboard_shortcuts",
437            "Keyboard Shortcuts",
438            true,
439            Some(Accelerator::new(None, Code::F1)),
440        );
441        action_map.insert(keyboard_shortcuts.id().clone(), MenuAction::ShowHelp);
442        help_menu.append(&keyboard_shortcuts)?;
443
444        help_menu.append(&PredefinedMenuItem::separator())?;
445
446        let about = MenuItem::with_id("about", "About par-term", true, None);
447        action_map.insert(about.id().clone(), MenuAction::About);
448        help_menu.append(&about)?;
449
450        menu.append(&help_menu)?;
451
452        Ok(Self {
453            menu,
454            action_map,
455            profiles_submenu,
456            profile_menu_items: Vec::new(),
457        })
458    }
459
460    /// Initialize the menu for a window
461    ///
462    /// On macOS, this initializes the global application menu (only needs to be called once).
463    /// On Windows/Linux, this attaches a menu bar to the specific window.
464    #[allow(unused_variables)] // window is only used on Windows/Linux
465    pub fn init_for_window(&self, window: &Arc<Window>) -> Result<()> {
466        #[cfg(target_os = "macos")]
467        {
468            // On macOS, init for NSApp (global menu bar)
469            self.menu.init_for_nsapp();
470            // Also set the app name in the menu
471            // This is typically done automatically but we ensure it's set
472            log::info!("Initialized macOS global menu bar");
473            Ok(())
474        }
475
476        #[cfg(target_os = "windows")]
477        {
478            use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
479            if let Ok(handle) = window.window_handle()
480                && let RawWindowHandle::Win32(win32_handle) = handle.as_raw()
481            {
482                // SAFETY: We have a valid Win32 window handle from winit
483                unsafe {
484                    self.menu.init_for_hwnd(win32_handle.hwnd.get() as _)?;
485                }
486                log::info!("Initialized Windows menu bar for window");
487            }
488            Ok(())
489        }
490
491        #[cfg(any(
492            target_os = "linux",
493            target_os = "dragonfly",
494            target_os = "freebsd",
495            target_os = "netbsd",
496            target_os = "openbsd"
497        ))]
498        {
499            // On Linux with GTK, we need to initialize for GTK window
500            // This requires the gtk feature to be enabled
501            use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
502            if let Ok(handle) = window.window_handle() {
503                if let RawWindowHandle::Xlib(xlib_handle) = handle.as_raw() {
504                    // For X11, we'd need to use the GTK integration
505                    // This is handled by muda's gtk feature
506                    log::info!("Linux X11 menu support (using GTK integration)");
507                } else if let RawWindowHandle::Wayland(_wayland_handle) = handle.as_raw() {
508                    log::info!("Linux Wayland menu support (using GTK integration)");
509                }
510            }
511            // GTK menu initialization is more complex and depends on the display server
512            // For now, we'll just log that we're on Linux
513            log::info!("Linux menu bar initialized (GTK-based)");
514            Ok(())
515        }
516
517        #[cfg(not(any(
518            target_os = "macos",
519            target_os = "windows",
520            target_os = "linux",
521            target_os = "dragonfly",
522            target_os = "freebsd",
523            target_os = "netbsd",
524            target_os = "openbsd"
525        )))]
526        {
527            log::warn!("Menu bar not supported on this platform");
528            Ok(())
529        }
530    }
531
532    /// Poll for menu events and return any triggered actions
533    pub fn poll_events(&self) -> impl Iterator<Item = MenuAction> + '_ {
534        std::iter::from_fn(|| {
535            // Use try_recv to get events without blocking
536            match MenuEvent::receiver().try_recv() {
537                Ok(event) => self.action_map.get(&event.id).copied(),
538                Err(_) => None,
539            }
540        })
541    }
542
543    /// Update the profiles submenu with the current list of profiles
544    ///
545    /// This should be called whenever profiles are loaded or modified.
546    pub fn update_profiles(&mut self, profiles: &[&Profile]) {
547        // Remove existing profile menu items
548        for item in self.profile_menu_items.drain(..) {
549            // Remove from action_map
550            self.action_map.remove(item.id());
551            // Remove from submenu
552            let _ = self.profiles_submenu.remove(&item);
553        }
554
555        // Add new profile menu items in order
556        for profile in profiles {
557            let menu_id = format!("profile_{}", profile.id);
558            let label = profile.display_label();
559
560            let item = MenuItem::with_id(menu_id, &label, true, None);
561
562            // Map to OpenProfile action
563            self.action_map
564                .insert(item.id().clone(), MenuAction::OpenProfile(profile.id));
565
566            // Add to submenu
567            if let Err(e) = self.profiles_submenu.append(&item) {
568                log::warn!("Failed to add profile menu item '{}': {}", label, e);
569                continue;
570            }
571
572            // Track for later removal
573            self.profile_menu_items.push(item);
574        }
575
576        log::info!("Updated profiles menu with {} items", profiles.len());
577    }
578
579    /// Update profiles from a ProfileManager (convenience method)
580    pub fn update_profiles_from_manager(&mut self, manager: &crate::profile::ProfileManager) {
581        let profiles: Vec<&Profile> = manager.profiles_ordered();
582        self.update_profiles(&profiles);
583    }
584}