Skip to main content

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 &section.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}