par_term/menu/mod.rs
1//! Menu support for par-term
2//!
3//! The menu's contents live in one place — [`model`] — and are rendered two
4//! ways:
5//!
6//! - Natively with the `muda` crate, by [`MenuManager`]: a global application
7//! menu bar on macOS, a per-window Win32 menu bar on Windows.
8//! - In-app with egui, by [`egui_menu::AppMenuUi`], on Linux/BSD, where muda
9//! cannot attach anything because it needs a `gtk::Window` that winit never
10//! creates (see `linux`).
11//!
12//! Both renderers walk the same model, so neither platform can end up with
13//! commands the other lacks. Activations from either arrive as [`MenuAction`]s
14//! at `WindowManager::process_menu_events`.
15
16mod actions;
17mod bridge;
18pub mod egui_menu;
19pub mod model;
20
21/// macOS-specific menu building and NSApp initialization.
22#[cfg(target_os = "macos")]
23pub(super) mod macos;
24
25/// Linux menu initialization — a no-op that explains itself.
26#[cfg(any(
27 target_os = "linux",
28 target_os = "dragonfly",
29 target_os = "freebsd",
30 target_os = "netbsd",
31 target_os = "openbsd"
32))]
33pub(super) mod linux;
34
35pub use actions::MenuAction;
36pub use bridge::{dispatch, drain_pending_actions, request_toggle};
37pub use egui_menu::AppMenuUi;
38
39use crate::profile::Profile;
40use anyhow::{Result, anyhow};
41use model::MenuEntry;
42use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu};
43use std::collections::HashMap;
44use std::sync::Arc;
45use winit::window::Window;
46
47/// Manages the native menu system
48pub struct MenuManager {
49 /// The root menu
50 ///
51 /// Only attached on macOS and Windows. Linux never reads it: muda needs a
52 /// `gtk::Window` to attach a menubar and winit's X11/Wayland backends do
53 /// not create one. Linux gets [`egui_menu::AppMenuUi`] instead; see
54 /// `linux.rs`.
55 #[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
56 menu: Menu,
57 /// Mapping from menu item IDs to actions
58 action_map: HashMap<MenuId, MenuAction>,
59 /// Profiles submenu for dynamic profile items
60 profiles_submenu: Submenu,
61 /// Track profile menu items for cleanup
62 profile_menu_items: Vec<MenuItem>,
63}
64
65impl MenuManager {
66 /// Create a new menu manager with the default menu structure
67 ///
68 /// The structure comes from [`model::platform_menu_model`]; this function
69 /// only turns it into muda objects and records the id → action mapping.
70 pub fn new() -> Result<Self> {
71 let menu = Menu::new();
72 let mut action_map = HashMap::new();
73 let mut profiles_submenu = None;
74
75 // macOS: Application menu (must be first submenu — becomes the macOS app menu).
76 // It is built separately because it uses predefined items (Services,
77 // Hide, Show All) that the cross-platform model cannot express.
78 #[cfg(target_os = "macos")]
79 macos::build_app_menu(&menu, &mut action_map)?;
80
81 for section in model::platform_menu_model() {
82 // macOS convention: the native Window menu sits just before Help.
83 #[cfg(target_os = "macos")]
84 if section.title == model::HELP_SECTION_TITLE {
85 macos::build_window_menu(&menu, &mut action_map)?;
86 }
87
88 let submenu = Submenu::new(section.title, true);
89 for entry in §ion.entries {
90 match entry {
91 MenuEntry::Separator => {
92 submenu.append(&PredefinedMenuItem::separator())?;
93 }
94 MenuEntry::Item(spec) => {
95 let item = MenuItem::with_id(spec.id, spec.label, true, spec.accelerator);
96 action_map.insert(item.id().clone(), spec.action);
97 submenu.append(&item)?;
98 }
99 MenuEntry::Profiles => {
100 // Filled in by `update_profiles`, which appends to the
101 // end of this submenu — so the model must not place
102 // entries after the insertion point.
103 profiles_submenu = Some(submenu.clone());
104 }
105 }
106 }
107 menu.append(&submenu)?;
108 }
109
110 let profiles_submenu = profiles_submenu
111 .ok_or_else(|| anyhow!("menu model has no profiles insertion point"))?;
112
113 Ok(Self {
114 menu,
115 action_map,
116 profiles_submenu,
117 profile_menu_items: Vec::new(),
118 })
119 }
120
121 /// Initialize the global menu system (macOS only).
122 ///
123 /// On macOS this attaches the menu to NSApp (the global application object),
124 /// replacing winit's default menu. This should be called as early as possible
125 /// — before any blocking GPU initialization — so that our custom accelerators
126 /// (Cmd+, for Settings, Cmd+Q for graceful Quit) are active immediately.
127 ///
128 /// On other platforms this is a no-op; use [`Self::init_for_window`] to attach
129 /// per-window menu bars.
130 pub fn init_global(&self) -> Result<()> {
131 #[cfg(target_os = "macos")]
132 {
133 macos::init_for_nsapp(&self.menu)
134 }
135 #[cfg(not(target_os = "macos"))]
136 {
137 Ok(())
138 }
139 }
140
141 /// Initialize the menu for a window
142 ///
143 /// On macOS, this initializes the global application menu (only needs to be called once).
144 /// On Windows/Linux, this attaches a menu bar to the specific window.
145 #[allow(unused_variables)] // window is only used on Windows/Linux
146 pub fn init_for_window(&self, window: &Arc<Window>) -> Result<()> {
147 #[cfg(target_os = "macos")]
148 {
149 macos::init_for_nsapp(&self.menu)
150 }
151
152 #[cfg(target_os = "windows")]
153 {
154 use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
155 if let Ok(handle) = window.window_handle()
156 && let RawWindowHandle::Win32(win32_handle) = handle.as_raw()
157 {
158 // SAFETY: We have a valid Win32 window handle from winit
159 unsafe {
160 self.menu.init_for_hwnd(win32_handle.hwnd.get() as _)?;
161 }
162 log::info!("Initialized Windows menu bar for window");
163 }
164 return Ok(());
165 }
166
167 #[cfg(any(
168 target_os = "linux",
169 target_os = "dragonfly",
170 target_os = "freebsd",
171 target_os = "netbsd",
172 target_os = "openbsd"
173 ))]
174 {
175 linux::init_for_window(window)
176 }
177
178 #[cfg(not(any(
179 target_os = "macos",
180 target_os = "windows",
181 target_os = "linux",
182 target_os = "dragonfly",
183 target_os = "freebsd",
184 target_os = "netbsd",
185 target_os = "openbsd"
186 )))]
187 {
188 log::warn!("Menu bar not supported on this platform");
189 Ok(())
190 }
191 }
192
193 /// Poll for menu events and return any triggered actions
194 pub fn poll_events(&self) -> impl Iterator<Item = MenuAction> + '_ {
195 std::iter::from_fn(|| {
196 // Use try_recv to get events without blocking
197 match MenuEvent::receiver().try_recv() {
198 Ok(event) => self.action_map.get(&event.id).copied(),
199 Err(_) => None,
200 }
201 })
202 }
203
204 /// Update the profiles submenu with the current list of profiles
205 ///
206 /// This should be called whenever profiles are loaded or modified.
207 pub fn update_profiles(&mut self, profiles: &[&Profile]) {
208 // Remove existing profile menu items
209 for item in self.profile_menu_items.drain(..) {
210 // Remove from action_map
211 self.action_map.remove(item.id());
212 // Remove from submenu
213 let _ = self.profiles_submenu.remove(&item);
214 }
215
216 // Add new profile menu items in order. The entries come from the shared
217 // model so the in-app menu lists the same profiles under the same labels.
218 for entry in model::profile_entries(profiles.iter().copied()) {
219 let item = MenuItem::with_id(entry.menu_id, &entry.label, true, None);
220
221 self.action_map.insert(item.id().clone(), entry.action);
222
223 // Add to submenu
224 if let Err(e) = self.profiles_submenu.append(&item) {
225 log::warn!("Failed to add profile menu item '{}': {}", entry.label, e);
226 continue;
227 }
228
229 // Track for later removal
230 self.profile_menu_items.push(item);
231 }
232
233 log::info!("Updated profiles menu with {} items", profiles.len());
234 }
235
236 /// Update profiles from a ProfileManager (convenience method)
237 pub fn update_profiles_from_manager(&mut self, manager: &crate::profile::ProfileManager) {
238 let profiles: Vec<&Profile> = manager.profiles_ordered();
239 self.update_profiles(&profiles);
240 }
241}