1mod 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
21pub struct MenuManager {
23 #[allow(dead_code)]
25 menu: Menu,
26 action_map: HashMap<MenuId, MenuAction>,
28 profiles_submenu: Submenu,
30 profile_menu_items: Vec<MenuItem>,
32}
33
34impl MenuManager {
35 pub fn new() -> Result<Self> {
37 let menu = Menu::new();
38 let mut action_map = HashMap::new();
39
40 #[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 #[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 #[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 #[cfg(target_os = "macos")]
63 {
64 let app_menu = Submenu::new("par-term", true);
65
66 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 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 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", 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 #[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 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, );
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 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 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, );
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 menu.append(&profiles_menu)?;
237
238 let profiles_submenu = profiles_menu;
240
241 let edit_menu = Submenu::new("Edit", true);
243
244 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(©)?;
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 #[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 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 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 #[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 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 #[allow(unused_variables)] pub fn init_for_window(&self, window: &Arc<Window>) -> Result<()> {
466 #[cfg(target_os = "macos")]
467 {
468 self.menu.init_for_nsapp();
470 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 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 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 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 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 pub fn poll_events(&self) -> impl Iterator<Item = MenuAction> + '_ {
534 std::iter::from_fn(|| {
535 match MenuEvent::receiver().try_recv() {
537 Ok(event) => self.action_map.get(&event.id).copied(),
538 Err(_) => None,
539 }
540 })
541 }
542
543 pub fn update_profiles(&mut self, profiles: &[&Profile]) {
547 for item in self.profile_menu_items.drain(..) {
549 self.action_map.remove(item.id());
551 let _ = self.profiles_submenu.remove(&item);
553 }
554
555 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 self.action_map
564 .insert(item.id().clone(), MenuAction::OpenProfile(profile.id));
565
566 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 self.profile_menu_items.push(item);
574 }
575
576 log::info!("Updated profiles menu with {} items", profiles.len());
577 }
578
579 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}