use super::bridge;
use super::model::{self, MenuEntry, MenuSection};
use crate::profile::ProfileManager;
use egui::containers::menu::{MenuButton, SubMenuButton};
const TRIGGER_GLYPH: &str = "\u{2630}";
const TRIGGER_GLYPH_SIZE: f32 = 13.0;
const MENU_MIN_WIDTH: f32 = 120.0;
const SUBMENU_MIN_WIDTH: f32 = 210.0;
pub const ENABLE_ENV_VAR: &str = "PAR_TERM_IN_APP_MENU";
const HAS_NATIVE_MENU_BAR: bool = cfg!(any(target_os = "macos", target_os = "windows"));
pub struct AppMenuUi {
sections: Vec<MenuSection>,
open: bool,
}
impl Default for AppMenuUi {
fn default() -> Self {
Self::new()
}
}
impl AppMenuUi {
pub const BUTTON_WIDTH: f32 = 24.0;
pub fn new() -> Self {
Self {
sections: model::menu_model(false),
open: false,
}
}
pub fn enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED
.get_or_init(|| enabled_with_override(std::env::var(ENABLE_ENV_VAR).ok().as_deref()))
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn hide(&mut self, ctx: &egui::Context) {
let _ = bridge::take_toggle_request();
if self.open {
egui::Popup::close_all(ctx);
self.open = false;
}
}
pub fn show(&mut self, ui: &mut egui::Ui, profiles: &ProfileManager, height: f32) {
let button = egui::Button::new(
egui::RichText::new(TRIGGER_GLYPH)
.size(TRIGGER_GLYPH_SIZE)
.color(ui.visuals().weak_text_color()),
)
.min_size(egui::vec2(Self::BUTTON_WIDTH, height))
.fill(egui::Color32::TRANSPARENT);
let (response, inner) = MenuButton::from_button(button).ui(ui, |ui| {
ui.set_min_width(MENU_MIN_WIDTH);
for section in &self.sections {
SubMenuButton::new(section.title).ui(ui, |ui| {
ui.set_min_width(SUBMENU_MIN_WIDTH);
section_entries(ui, section, profiles);
});
}
});
self.open = inner.is_some();
if bridge::take_toggle_request() {
egui::Popup::toggle_id(ui.ctx(), egui::Popup::default_response_id(&response));
ui.ctx().request_repaint();
}
if response.hovered() {
response.on_hover_text("Menu");
}
}
}
fn section_entries(ui: &mut egui::Ui, section: &MenuSection, profiles: &ProfileManager) {
for entry in §ion.entries {
match entry {
MenuEntry::Separator => {
ui.separator();
}
MenuEntry::Item(spec) => {
let mut button = egui::Button::new(spec.label);
if let Some(accelerator) = &spec.accelerator {
button = button.right_text(model::accelerator_label(accelerator));
}
if ui.add(button).clicked() {
bridge::dispatch(spec.action);
}
}
MenuEntry::Profiles => {
for entry in model::profile_entries(profiles.profiles_ordered()) {
if ui.button(entry.label).clicked() {
bridge::dispatch(entry.action);
}
}
}
}
}
}
fn enabled_with_override(value: Option<&str>) -> bool {
match value.map(str::trim) {
Some("1" | "true" | "on" | "yes") => true,
Some("0" | "false" | "off" | "no") => false,
_ => !HAS_NATIVE_MENU_BAR,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_override_forces_the_menu_on_or_off() {
assert!(enabled_with_override(Some("1")));
assert!(enabled_with_override(Some("true")));
assert!(!enabled_with_override(Some("0")));
assert!(!enabled_with_override(Some("off")));
}
#[test]
fn default_follows_the_platform() {
let expected = !HAS_NATIVE_MENU_BAR;
assert_eq!(enabled_with_override(None), expected);
assert_eq!(enabled_with_override(Some("maybe")), expected);
assert_eq!(enabled_with_override(Some("")), expected);
}
#[test]
fn platform_default_matches_native_menu_availability() {
if cfg!(any(target_os = "macos", target_os = "windows")) {
assert!(!enabled_with_override(None));
} else {
assert!(enabled_with_override(None));
}
}
#[test]
fn menu_carries_the_full_command_set() {
let menu = AppMenuUi::new();
let titles: Vec<&str> = menu.sections.iter().map(|s| s.title).collect();
assert!(titles.contains(&"File"));
assert!(titles.contains(&"Edit"));
assert!(titles.contains(&model::HELP_SECTION_TITLE));
let actions: Vec<crate::menu::MenuAction> = menu
.sections
.iter()
.flat_map(|section| §ion.entries)
.filter_map(|entry| match entry {
MenuEntry::Item(spec) => Some(spec.action),
_ => None,
})
.collect();
for required in [
crate::menu::MenuAction::NewWindow,
crate::menu::MenuAction::CloseWindow,
crate::menu::MenuAction::Quit,
crate::menu::MenuAction::SelectAll,
crate::menu::MenuAction::MaximizeVertically,
] {
assert!(
actions.contains(&required),
"in-app menu is missing {required:?}, which has no keybinding on Linux"
);
}
}
#[test]
fn menu_starts_closed() {
assert!(!AppMenuUi::new().is_open());
}
#[test]
fn every_section_renders_its_entries() {
let menu = AppMenuUi::new();
let profiles = crate::profile::ProfileManager::new();
egui::__run_test_ui(|ui| {
for section in &menu.sections {
section_entries(ui, section, &profiles);
}
});
}
#[test]
fn opens_and_closes_through_the_toggle_request() {
let _guard = super::bridge::TEST_LOCK.lock();
let _ = bridge::take_toggle_request();
let _ = bridge::drain_pending_actions();
let ctx = egui::Context::default();
let profiles = crate::profile::ProfileManager::new();
let mut menu = AppMenuUi::new();
let frame = |menu: &mut AppMenuUi| {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
egui::Panel::top("test_bar").show(ui, |ui| {
menu.show(ui, &profiles, 24.0);
});
});
};
frame(&mut menu);
assert!(!menu.is_open(), "the menu must start closed");
bridge::request_toggle();
frame(&mut menu);
frame(&mut menu);
assert!(menu.is_open(), "toggle request did not open the menu");
bridge::request_toggle();
frame(&mut menu);
frame(&mut menu);
assert!(!menu.is_open(), "toggle request did not close the menu");
assert!(bridge::drain_pending_actions().is_empty());
}
#[test]
fn hiding_the_bar_discards_open_state_and_toggle_requests() {
let _guard = super::bridge::TEST_LOCK.lock();
let _ = bridge::take_toggle_request();
let ctx = egui::Context::default();
let profiles = crate::profile::ProfileManager::new();
let mut menu = AppMenuUi::new();
let frame = |menu: &mut AppMenuUi| {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
egui::Panel::top("test_bar").show(ui, |ui| {
menu.show(ui, &profiles, 24.0);
});
});
};
bridge::request_toggle();
frame(&mut menu);
frame(&mut menu);
assert!(menu.is_open());
menu.hide(&ctx);
assert!(!menu.is_open());
bridge::request_toggle();
menu.hide(&ctx);
assert!(!bridge::take_toggle_request(), "toggle request latched");
frame(&mut menu);
frame(&mut menu);
assert!(
!menu.is_open(),
"the menu re-opened itself after being hidden"
);
}
}