Skip to main content

par_term/menu/
egui_menu.rs

1//! The in-app menu, drawn with egui.
2//!
3//! par-term cannot attach a native menu bar on Linux/BSD: muda attaches one
4//! through `Menu::init_for_gtk_window`, and winit's X11 and Wayland backends
5//! create no `gtk::Window` to hand it (see [`super::linux`]). Without a menu,
6//! `new_window`, `close_window`, `quit` and `select_all` have no route at all —
7//! they are menu-only commands. This module gives those platforms a menu drawn
8//! the same way par-term already draws its tab bar and settings window.
9//!
10//! It renders [`super::model`] — the same description the native menu is built
11//! from — so the two cannot offer different commands. Activations go to
12//! [`super::bridge`], which `WindowManager::process_menu_events` drains
13//! alongside muda's own event channel.
14//!
15//! The trigger button lives inside the tab bar strip, whose height is already
16//! reserved in the terminal grid layout, so the menu costs the terminal no
17//! rows. The drop-down itself is an egui popup floating above everything.
18
19use super::bridge;
20use super::model::{self, MenuEntry, MenuSection};
21use crate::profile::ProfileManager;
22use egui::containers::menu::{MenuButton, SubMenuButton};
23
24/// Glyph on the trigger button.
25const TRIGGER_GLYPH: &str = "\u{2630}";
26
27/// Font size of the trigger glyph, in logical pixels.
28const TRIGGER_GLYPH_SIZE: f32 = 13.0;
29
30/// Minimum width of the top-level drop-down, in logical pixels.
31const MENU_MIN_WIDTH: f32 = 120.0;
32
33/// Minimum width of a section's submenu, in logical pixels.
34const SUBMENU_MIN_WIDTH: f32 = 210.0;
35
36/// Environment variable that overrides whether the in-app menu is drawn.
37///
38/// `1`/`true`/`on` force it on, `0`/`false`/`off` force it off. Unset, it is
39/// drawn exactly where no native menu bar can be attached. Forcing it on is how
40/// the menu is inspected from macOS or Windows, where it is normally redundant.
41pub const ENABLE_ENV_VAR: &str = "PAR_TERM_IN_APP_MENU";
42
43/// True on the platforms whose native menu bar par-term actually attaches.
44const HAS_NATIVE_MENU_BAR: bool = cfg!(any(target_os = "macos", target_os = "windows"));
45
46/// The in-app menu's per-window state.
47pub struct AppMenuUi {
48    /// The menu to draw. Built once per window; the contents are static apart
49    /// from the profile list, which is read from the live `ProfileManager`.
50    sections: Vec<MenuSection>,
51    /// Whether the drop-down was open during the last frame that drew it.
52    open: bool,
53}
54
55impl Default for AppMenuUi {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl AppMenuUi {
62    /// Width the trigger button occupies in a horizontal bar, in logical pixels.
63    pub const BUTTON_WIDTH: f32 = 24.0;
64
65    /// Build the menu for one window.
66    pub fn new() -> Self {
67        Self {
68            // The in-app menu is the only menu wherever it is drawn, so it must
69            // carry the commands a native application menu would otherwise own.
70            sections: model::menu_model(false),
71            open: false,
72        }
73    }
74
75    /// Whether the in-app menu should be drawn in this process.
76    pub fn enabled() -> bool {
77        use std::sync::OnceLock;
78        static ENABLED: OnceLock<bool> = OnceLock::new();
79        *ENABLED
80            .get_or_init(|| enabled_with_override(std::env::var(ENABLE_ENV_VAR).ok().as_deref()))
81    }
82
83    /// Whether the drop-down is currently open.
84    ///
85    /// While it is, keyboard input should not reach the terminal.
86    pub fn is_open(&self) -> bool {
87        self.open
88    }
89
90    /// Tear the menu down for a frame that will not draw it.
91    ///
92    /// The bar the menu lives in can be hidden while the drop-down is open, and
93    /// two pieces of state outlive that: egui's popup memory, which would
94    /// re-open the drop-down the moment the bar returns, and a toggle request
95    /// that no [`Self::show`] will ever consume while the bar is hidden, which
96    /// would do the same. Both are discarded here.
97    pub fn hide(&mut self, ctx: &egui::Context) {
98        // A `toggle_menu` keybinding cannot reach a menu that is not drawn.
99        // Dropping the request is what stops it from latching.
100        let _ = bridge::take_toggle_request();
101
102        // Guarded: `Popup::close_all` closes every popup in the application,
103        // and this runs on every frame the bar is hidden.
104        if self.open {
105            egui::Popup::close_all(ctx);
106            self.open = false;
107        }
108    }
109
110    /// Draw the trigger button, and the drop-down if it is open.
111    ///
112    /// `height` is the height of the bar the button sits in.
113    pub fn show(&mut self, ui: &mut egui::Ui, profiles: &ProfileManager, height: f32) {
114        let button = egui::Button::new(
115            egui::RichText::new(TRIGGER_GLYPH)
116                .size(TRIGGER_GLYPH_SIZE)
117                .color(ui.visuals().weak_text_color()),
118        )
119        .min_size(egui::vec2(Self::BUTTON_WIDTH, height))
120        .fill(egui::Color32::TRANSPARENT);
121
122        let (response, inner) = MenuButton::from_button(button).ui(ui, |ui| {
123            ui.set_min_width(MENU_MIN_WIDTH);
124            for section in &self.sections {
125                SubMenuButton::new(section.title).ui(ui, |ui| {
126                    ui.set_min_width(SUBMENU_MIN_WIDTH);
127                    section_entries(ui, section, profiles);
128                });
129            }
130        });
131        self.open = inner.is_some();
132
133        // A `toggle_menu` keybinding cannot reach egui's popup memory directly,
134        // so it leaves a request behind for this pass to apply. The popup state
135        // was already read above, hence the repaint: the change lands next frame.
136        if bridge::take_toggle_request() {
137            egui::Popup::toggle_id(ui.ctx(), egui::Popup::default_response_id(&response));
138            ui.ctx().request_repaint();
139        }
140
141        if response.hovered() {
142            response.on_hover_text("Menu");
143        }
144    }
145}
146
147/// Draw one section's entries into an open submenu.
148fn section_entries(ui: &mut egui::Ui, section: &MenuSection, profiles: &ProfileManager) {
149    for entry in &section.entries {
150        match entry {
151            MenuEntry::Separator => {
152                ui.separator();
153            }
154            MenuEntry::Item(spec) => {
155                let mut button = egui::Button::new(spec.label);
156                if let Some(accelerator) = &spec.accelerator {
157                    button = button.right_text(model::accelerator_label(accelerator));
158                }
159                if ui.add(button).clicked() {
160                    bridge::dispatch(spec.action);
161                }
162            }
163            MenuEntry::Profiles => {
164                for entry in model::profile_entries(profiles.profiles_ordered()) {
165                    if ui.button(entry.label).clicked() {
166                        bridge::dispatch(entry.action);
167                    }
168                }
169            }
170        }
171    }
172}
173
174/// Resolve [`AppMenuUi::enabled`] from the raw environment variable value.
175///
176/// Split out so the precedence is testable on every platform.
177fn enabled_with_override(value: Option<&str>) -> bool {
178    match value.map(str::trim) {
179        Some("1" | "true" | "on" | "yes") => true,
180        Some("0" | "false" | "off" | "no") => false,
181        // An unrecognised value is not a reason to change the platform default.
182        _ => !HAS_NATIVE_MENU_BAR,
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn env_override_forces_the_menu_on_or_off() {
192        assert!(enabled_with_override(Some("1")));
193        assert!(enabled_with_override(Some("true")));
194        assert!(!enabled_with_override(Some("0")));
195        assert!(!enabled_with_override(Some("off")));
196    }
197
198    /// Unset, or set to nonsense, the platform decides.
199    #[test]
200    fn default_follows_the_platform() {
201        let expected = !HAS_NATIVE_MENU_BAR;
202        assert_eq!(enabled_with_override(None), expected);
203        assert_eq!(enabled_with_override(Some("maybe")), expected);
204        assert_eq!(enabled_with_override(Some("")), expected);
205    }
206
207    /// The menu is drawn exactly where par-term cannot attach a native one.
208    #[test]
209    fn platform_default_matches_native_menu_availability() {
210        if cfg!(any(target_os = "macos", target_os = "windows")) {
211            assert!(!enabled_with_override(None));
212        } else {
213            assert!(enabled_with_override(None));
214        }
215    }
216
217    /// Every section of the model the in-app menu draws must be reachable as a
218    /// submenu, and the model must be the no-native-app-menu variant.
219    #[test]
220    fn menu_carries_the_full_command_set() {
221        let menu = AppMenuUi::new();
222        let titles: Vec<&str> = menu.sections.iter().map(|s| s.title).collect();
223        assert!(titles.contains(&"File"));
224        assert!(titles.contains(&"Edit"));
225        assert!(titles.contains(&model::HELP_SECTION_TITLE));
226
227        let actions: Vec<crate::menu::MenuAction> = menu
228            .sections
229            .iter()
230            .flat_map(|section| &section.entries)
231            .filter_map(|entry| match entry {
232                MenuEntry::Item(spec) => Some(spec.action),
233                _ => None,
234            })
235            .collect();
236        for required in [
237            crate::menu::MenuAction::NewWindow,
238            crate::menu::MenuAction::CloseWindow,
239            crate::menu::MenuAction::Quit,
240            crate::menu::MenuAction::SelectAll,
241            crate::menu::MenuAction::MaximizeVertically,
242        ] {
243            assert!(
244                actions.contains(&required),
245                "in-app menu is missing {required:?}, which has no keybinding on Linux"
246            );
247        }
248    }
249
250    /// A freshly built menu must not claim to be capturing input.
251    #[test]
252    fn menu_starts_closed() {
253        assert!(!AppMenuUi::new().is_open());
254    }
255
256    /// Every section's entries must render — separators, items with and
257    /// without an accelerator, and the profiles insertion point.
258    ///
259    /// Submenu bodies only open on hover, which
260    /// `opens_and_closes_through_the_toggle_request` cannot drive, so they are
261    /// exercised directly here.
262    #[test]
263    fn every_section_renders_its_entries() {
264        let menu = AppMenuUi::new();
265        let profiles = crate::profile::ProfileManager::new();
266        egui::__run_test_ui(|ui| {
267            for section in &menu.sections {
268                section_entries(ui, section, &profiles);
269            }
270        });
271    }
272
273    /// Drive the real egui code path headlessly: closed, then opened through
274    /// the same toggle request a `toggle_menu` keybinding would leave behind,
275    /// then closed again. Covers the trigger button and the drop-down's
276    /// top level.
277    #[test]
278    fn opens_and_closes_through_the_toggle_request() {
279        let _guard = super::bridge::TEST_LOCK.lock();
280        let _ = bridge::take_toggle_request();
281        let _ = bridge::drain_pending_actions();
282
283        let ctx = egui::Context::default();
284        let profiles = crate::profile::ProfileManager::new();
285        let mut menu = AppMenuUi::new();
286
287        let frame = |menu: &mut AppMenuUi| {
288            let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
289                egui::Panel::top("test_bar").show(ui, |ui| {
290                    menu.show(ui, &profiles, 24.0);
291                });
292            });
293        };
294
295        frame(&mut menu);
296        assert!(!menu.is_open(), "the menu must start closed");
297
298        // The request is consumed after this frame reads the popup state, so
299        // the drop-down appears on the frame after that.
300        bridge::request_toggle();
301        frame(&mut menu);
302        frame(&mut menu);
303        assert!(menu.is_open(), "toggle request did not open the menu");
304
305        bridge::request_toggle();
306        frame(&mut menu);
307        frame(&mut menu);
308        assert!(!menu.is_open(), "toggle request did not close the menu");
309
310        // Drawing the menu must not dispatch anything on its own.
311        assert!(bridge::drain_pending_actions().is_empty());
312    }
313
314    /// Hiding the bar must leave nothing behind that re-opens the menu later.
315    ///
316    /// Both halves of this regressed once: egui's popup memory kept the
317    /// drop-down open across the hidden frames, and a toggle request raised
318    /// while the bar was hidden latched until the bar came back.
319    #[test]
320    fn hiding_the_bar_discards_open_state_and_toggle_requests() {
321        let _guard = super::bridge::TEST_LOCK.lock();
322        let _ = bridge::take_toggle_request();
323
324        let ctx = egui::Context::default();
325        let profiles = crate::profile::ProfileManager::new();
326        let mut menu = AppMenuUi::new();
327
328        let frame = |menu: &mut AppMenuUi| {
329            let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
330                egui::Panel::top("test_bar").show(ui, |ui| {
331                    menu.show(ui, &profiles, 24.0);
332                });
333            });
334        };
335
336        bridge::request_toggle();
337        frame(&mut menu);
338        frame(&mut menu);
339        assert!(menu.is_open());
340
341        // The bar is hidden while the drop-down is open.
342        menu.hide(&ctx);
343        assert!(!menu.is_open());
344
345        // And a keybinding fires while there is no menu to toggle.
346        bridge::request_toggle();
347        menu.hide(&ctx);
348        assert!(!bridge::take_toggle_request(), "toggle request latched");
349
350        // The bar comes back: the menu must still be closed.
351        frame(&mut menu);
352        frame(&mut menu);
353        assert!(
354            !menu.is_open(),
355            "the menu re-opened itself after being hidden"
356        );
357    }
358}