par_term/menu/
egui_menu.rs1use super::bridge;
20use super::model::{self, MenuEntry, MenuSection};
21use crate::profile::ProfileManager;
22use egui::containers::menu::{MenuButton, SubMenuButton};
23
24const TRIGGER_GLYPH: &str = "\u{2630}";
26
27const TRIGGER_GLYPH_SIZE: f32 = 13.0;
29
30const MENU_MIN_WIDTH: f32 = 120.0;
32
33const SUBMENU_MIN_WIDTH: f32 = 210.0;
35
36pub const ENABLE_ENV_VAR: &str = "PAR_TERM_IN_APP_MENU";
42
43const HAS_NATIVE_MENU_BAR: bool = cfg!(any(target_os = "macos", target_os = "windows"));
45
46pub struct AppMenuUi {
48 sections: Vec<MenuSection>,
51 open: bool,
53}
54
55impl Default for AppMenuUi {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61impl AppMenuUi {
62 pub const BUTTON_WIDTH: f32 = 24.0;
64
65 pub fn new() -> Self {
67 Self {
68 sections: model::menu_model(false),
71 open: false,
72 }
73 }
74
75 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 pub fn is_open(&self) -> bool {
87 self.open
88 }
89
90 pub fn hide(&mut self, ctx: &egui::Context) {
98 let _ = bridge::take_toggle_request();
101
102 if self.open {
105 egui::Popup::close_all(ctx);
106 self.open = false;
107 }
108 }
109
110 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 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
147fn section_entries(ui: &mut egui::Ui, section: &MenuSection, profiles: &ProfileManager) {
149 for entry in §ion.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
174fn 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 _ => !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 #[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 #[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 #[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| §ion.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 #[test]
252 fn menu_starts_closed() {
253 assert!(!AppMenuUi::new().is_open());
254 }
255
256 #[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 #[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 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 assert!(bridge::drain_pending_actions().is_empty());
312 }
313
314 #[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 menu.hide(&ctx);
343 assert!(!menu.is_open());
344
345 bridge::request_toggle();
347 menu.hide(&ctx);
348 assert!(!bridge::take_toggle_request(), "toggle request latched");
349
350 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}