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 anyhow::Result;
12use muda::{
13    Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu,
14    accelerator::{Accelerator, Code, Modifiers},
15};
16use std::collections::HashMap;
17use std::sync::Arc;
18use winit::window::Window;
19
20/// Manages the native menu system
21pub struct MenuManager {
22    /// The root menu
23    #[allow(dead_code)]
24    menu: Menu,
25    /// Mapping from menu item IDs to actions
26    action_map: HashMap<MenuId, MenuAction>,
27}
28
29impl MenuManager {
30    /// Create a new menu manager with the default menu structure
31    pub fn new() -> Result<Self> {
32        let menu = Menu::new();
33        let mut action_map = HashMap::new();
34
35        // Platform-specific modifier key
36        #[cfg(target_os = "macos")]
37        let cmd_or_ctrl = Modifiers::META;
38        #[cfg(not(target_os = "macos"))]
39        let cmd_or_ctrl = Modifiers::CONTROL;
40
41        // File menu
42        let file_menu = Submenu::new("File", true);
43
44        let new_window = MenuItem::with_id(
45            "new_window",
46            "New Window",
47            true,
48            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyN)),
49        );
50        action_map.insert(new_window.id().clone(), MenuAction::NewWindow);
51        file_menu.append(&new_window)?;
52
53        let close_window = MenuItem::with_id(
54            "close_window",
55            "Close Window",
56            true,
57            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyW)),
58        );
59        action_map.insert(close_window.id().clone(), MenuAction::CloseWindow);
60        file_menu.append(&close_window)?;
61
62        file_menu.append(&PredefinedMenuItem::separator())?;
63
64        // On macOS, Quit is in the app menu (handled automatically)
65        // On Windows/Linux, add Quit to File menu
66        #[cfg(not(target_os = "macos"))]
67        {
68            let quit = MenuItem::with_id(
69                "quit",
70                "Quit",
71                true,
72                Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyQ)),
73            );
74            action_map.insert(quit.id().clone(), MenuAction::Quit);
75            file_menu.append(&quit)?;
76        }
77
78        menu.append(&file_menu)?;
79
80        // Tab menu
81        let tab_menu = Submenu::new("Tab", true);
82
83        let new_tab = MenuItem::with_id(
84            "new_tab",
85            "New Tab",
86            true,
87            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyT)),
88        );
89        action_map.insert(new_tab.id().clone(), MenuAction::NewTab);
90        tab_menu.append(&new_tab)?;
91
92        let close_tab = MenuItem::with_id(
93            "close_tab",
94            "Close Tab",
95            true,
96            None, // Cmd+W handled in File menu (smart close)
97        );
98        action_map.insert(close_tab.id().clone(), MenuAction::CloseTab);
99        tab_menu.append(&close_tab)?;
100
101        tab_menu.append(&PredefinedMenuItem::separator())?;
102
103        let next_tab = MenuItem::with_id(
104            "next_tab",
105            "Next Tab",
106            true,
107            Some(Accelerator::new(
108                Some(cmd_or_ctrl | Modifiers::SHIFT),
109                Code::BracketRight,
110            )),
111        );
112        action_map.insert(next_tab.id().clone(), MenuAction::NextTab);
113        tab_menu.append(&next_tab)?;
114
115        let prev_tab = MenuItem::with_id(
116            "prev_tab",
117            "Previous Tab",
118            true,
119            Some(Accelerator::new(
120                Some(cmd_or_ctrl | Modifiers::SHIFT),
121                Code::BracketLeft,
122            )),
123        );
124        action_map.insert(prev_tab.id().clone(), MenuAction::PreviousTab);
125        tab_menu.append(&prev_tab)?;
126
127        tab_menu.append(&PredefinedMenuItem::separator())?;
128
129        // Tab 1-9 shortcuts
130        for i in 1..=9 {
131            let code = match i {
132                1 => Code::Digit1,
133                2 => Code::Digit2,
134                3 => Code::Digit3,
135                4 => Code::Digit4,
136                5 => Code::Digit5,
137                6 => Code::Digit6,
138                7 => Code::Digit7,
139                8 => Code::Digit8,
140                9 => Code::Digit9,
141                _ => unreachable!(),
142            };
143            let tab_item = MenuItem::with_id(
144                format!("tab_{}", i),
145                format!("Tab {}", i),
146                true,
147                Some(Accelerator::new(Some(cmd_or_ctrl), code)),
148            );
149            action_map.insert(tab_item.id().clone(), MenuAction::SwitchToTab(i));
150            tab_menu.append(&tab_item)?;
151        }
152
153        menu.append(&tab_menu)?;
154
155        // Edit menu
156        let edit_menu = Submenu::new("Edit", true);
157
158        let copy = MenuItem::with_id(
159            "copy",
160            "Copy",
161            true,
162            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyC)),
163        );
164        action_map.insert(copy.id().clone(), MenuAction::Copy);
165        edit_menu.append(&copy)?;
166
167        let paste = MenuItem::with_id(
168            "paste",
169            "Paste",
170            true,
171            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyV)),
172        );
173        action_map.insert(paste.id().clone(), MenuAction::Paste);
174        edit_menu.append(&paste)?;
175
176        let select_all = MenuItem::with_id(
177            "select_all",
178            "Select All",
179            true,
180            Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyA)),
181        );
182        action_map.insert(select_all.id().clone(), MenuAction::SelectAll);
183        edit_menu.append(&select_all)?;
184
185        edit_menu.append(&PredefinedMenuItem::separator())?;
186
187        let clear_scrollback = MenuItem::with_id(
188            "clear_scrollback",
189            "Clear Scrollback",
190            true,
191            Some(Accelerator::new(
192                Some(cmd_or_ctrl | Modifiers::SHIFT),
193                Code::KeyK,
194            )),
195        );
196        action_map.insert(clear_scrollback.id().clone(), MenuAction::ClearScrollback);
197        edit_menu.append(&clear_scrollback)?;
198
199        let clipboard_history = MenuItem::with_id(
200            "clipboard_history",
201            "Clipboard History",
202            true,
203            Some(Accelerator::new(
204                Some(cmd_or_ctrl | Modifiers::SHIFT),
205                Code::KeyH,
206            )),
207        );
208        action_map.insert(clipboard_history.id().clone(), MenuAction::ClipboardHistory);
209        edit_menu.append(&clipboard_history)?;
210
211        menu.append(&edit_menu)?;
212
213        // View menu
214        let view_menu = Submenu::new("View", true);
215
216        let toggle_fullscreen = MenuItem::with_id(
217            "toggle_fullscreen",
218            "Toggle Fullscreen",
219            true,
220            Some(Accelerator::new(None, Code::F11)),
221        );
222        action_map.insert(toggle_fullscreen.id().clone(), MenuAction::ToggleFullscreen);
223        view_menu.append(&toggle_fullscreen)?;
224
225        view_menu.append(&PredefinedMenuItem::separator())?;
226
227        let increase_font = MenuItem::with_id(
228            "increase_font",
229            "Increase Font Size",
230            true,
231            Some(Accelerator::new(Some(cmd_or_ctrl), Code::Equal)),
232        );
233        action_map.insert(increase_font.id().clone(), MenuAction::IncreaseFontSize);
234        view_menu.append(&increase_font)?;
235
236        let decrease_font = MenuItem::with_id(
237            "decrease_font",
238            "Decrease Font Size",
239            true,
240            Some(Accelerator::new(Some(cmd_or_ctrl), Code::Minus)),
241        );
242        action_map.insert(decrease_font.id().clone(), MenuAction::DecreaseFontSize);
243        view_menu.append(&decrease_font)?;
244
245        let reset_font = MenuItem::with_id(
246            "reset_font",
247            "Reset Font Size",
248            true,
249            Some(Accelerator::new(Some(cmd_or_ctrl), Code::Digit0)),
250        );
251        action_map.insert(reset_font.id().clone(), MenuAction::ResetFontSize);
252        view_menu.append(&reset_font)?;
253
254        view_menu.append(&PredefinedMenuItem::separator())?;
255
256        let fps_overlay = MenuItem::with_id(
257            "fps_overlay",
258            "FPS Overlay",
259            true,
260            Some(Accelerator::new(None, Code::F3)),
261        );
262        action_map.insert(fps_overlay.id().clone(), MenuAction::ToggleFpsOverlay);
263        view_menu.append(&fps_overlay)?;
264
265        let settings = MenuItem::with_id(
266            "settings",
267            "Settings",
268            true,
269            Some(Accelerator::new(None, Code::F12)),
270        );
271        action_map.insert(settings.id().clone(), MenuAction::OpenSettings);
272        view_menu.append(&settings)?;
273
274        menu.append(&view_menu)?;
275
276        // Window menu (primarily for macOS)
277        #[cfg(target_os = "macos")]
278        {
279            let window_menu = Submenu::new("Window", true);
280
281            let minimize = MenuItem::with_id(
282                "minimize",
283                "Minimize",
284                true,
285                Some(Accelerator::new(Some(Modifiers::META), Code::KeyM)),
286            );
287            action_map.insert(minimize.id().clone(), MenuAction::Minimize);
288            window_menu.append(&minimize)?;
289
290            let zoom = MenuItem::with_id("zoom", "Zoom", true, None);
291            action_map.insert(zoom.id().clone(), MenuAction::Zoom);
292            window_menu.append(&zoom)?;
293
294            menu.append(&window_menu)?;
295        }
296
297        // Help menu
298        let help_menu = Submenu::new("Help", true);
299
300        let keyboard_shortcuts = MenuItem::with_id(
301            "keyboard_shortcuts",
302            "Keyboard Shortcuts",
303            true,
304            Some(Accelerator::new(None, Code::F1)),
305        );
306        action_map.insert(keyboard_shortcuts.id().clone(), MenuAction::ShowHelp);
307        help_menu.append(&keyboard_shortcuts)?;
308
309        help_menu.append(&PredefinedMenuItem::separator())?;
310
311        let about = MenuItem::with_id("about", "About par-term", true, None);
312        action_map.insert(about.id().clone(), MenuAction::About);
313        help_menu.append(&about)?;
314
315        menu.append(&help_menu)?;
316
317        Ok(Self { menu, action_map })
318    }
319
320    /// Initialize the menu for a window
321    ///
322    /// On macOS, this initializes the global application menu (only needs to be called once).
323    /// On Windows/Linux, this attaches a menu bar to the specific window.
324    #[allow(unused_variables)] // window is only used on Windows/Linux
325    pub fn init_for_window(&self, window: &Arc<Window>) -> Result<()> {
326        #[cfg(target_os = "macos")]
327        {
328            // On macOS, init for NSApp (global menu bar)
329            self.menu.init_for_nsapp();
330            // Also set the app name in the menu
331            // This is typically done automatically but we ensure it's set
332            log::info!("Initialized macOS global menu bar");
333            Ok(())
334        }
335
336        #[cfg(target_os = "windows")]
337        {
338            use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
339            if let Ok(handle) = window.window_handle() {
340                if let RawWindowHandle::Win32(win32_handle) = handle.as_raw() {
341                    // SAFETY: We have a valid Win32 window handle from winit
342                    unsafe {
343                        self.menu.init_for_hwnd(win32_handle.hwnd.get() as _)?;
344                    }
345                    log::info!("Initialized Windows menu bar for window");
346                }
347            }
348            Ok(())
349        }
350
351        #[cfg(any(
352            target_os = "linux",
353            target_os = "dragonfly",
354            target_os = "freebsd",
355            target_os = "netbsd",
356            target_os = "openbsd"
357        ))]
358        {
359            // On Linux with GTK, we need to initialize for GTK window
360            // This requires the gtk feature to be enabled
361            use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
362            if let Ok(handle) = window.window_handle() {
363                if let RawWindowHandle::Xlib(xlib_handle) = handle.as_raw() {
364                    // For X11, we'd need to use the GTK integration
365                    // This is handled by muda's gtk feature
366                    log::info!("Linux X11 menu support (using GTK integration)");
367                } else if let RawWindowHandle::Wayland(_wayland_handle) = handle.as_raw() {
368                    log::info!("Linux Wayland menu support (using GTK integration)");
369                }
370            }
371            // GTK menu initialization is more complex and depends on the display server
372            // For now, we'll just log that we're on Linux
373            log::info!("Linux menu bar initialized (GTK-based)");
374            Ok(())
375        }
376
377        #[cfg(not(any(
378            target_os = "macos",
379            target_os = "windows",
380            target_os = "linux",
381            target_os = "dragonfly",
382            target_os = "freebsd",
383            target_os = "netbsd",
384            target_os = "openbsd"
385        )))]
386        {
387            log::warn!("Menu bar not supported on this platform");
388            Ok(())
389        }
390    }
391
392    /// Poll for menu events and return any triggered actions
393    pub fn poll_events(&self) -> impl Iterator<Item = MenuAction> + '_ {
394        std::iter::from_fn(|| {
395            // Use try_recv to get events without blocking
396            match MenuEvent::receiver().try_recv() {
397                Ok(event) => self.action_map.get(&event.id).copied(),
398                Err(_) => None,
399            }
400        })
401    }
402}