mod actions;
#[cfg(target_os = "macos")]
pub(super) mod macos;
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
pub(super) mod linux;
pub use actions::MenuAction;
use crate::profile::Profile;
use anyhow::Result;
use muda::{
Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, Code, Modifiers},
};
use std::collections::HashMap;
use std::sync::Arc;
use winit::window::Window;
pub struct MenuManager {
menu: Menu,
action_map: HashMap<MenuId, MenuAction>,
profiles_submenu: Submenu,
profile_menu_items: Vec<MenuItem>,
}
impl MenuManager {
pub fn new() -> Result<Self> {
let menu = Menu::new();
let mut action_map = HashMap::new();
#[cfg(target_os = "macos")]
let cmd_or_ctrl = Modifiers::META;
#[cfg(not(target_os = "macos"))]
let cmd_or_ctrl = Modifiers::CONTROL | Modifiers::SHIFT;
#[cfg(target_os = "macos")]
let cmd_or_ctrl_shift = Modifiers::META | Modifiers::SHIFT;
#[cfg(not(target_os = "macos"))]
let cmd_or_ctrl_shift = Modifiers::CONTROL | Modifiers::SHIFT;
#[cfg(target_os = "macos")]
let tab_switch_mod = Modifiers::META;
#[cfg(not(target_os = "macos"))]
let tab_switch_mod = Modifiers::ALT;
#[cfg(target_os = "macos")]
macos::build_app_menu(&menu, &mut action_map)?;
let file_menu = Submenu::new("File", true);
let new_window = MenuItem::with_id(
"new_window",
"New Window",
true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyN)),
);
action_map.insert(new_window.id().clone(), MenuAction::NewWindow);
file_menu.append(&new_window)?;
let close_window = MenuItem::with_id(
"close_window",
"Close", true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyW)),
);
action_map.insert(close_window.id().clone(), MenuAction::CloseWindow);
file_menu.append(&close_window)?;
file_menu.append(&PredefinedMenuItem::separator())?;
#[cfg(not(target_os = "macos"))]
{
let quit = MenuItem::with_id(
"quit",
"Quit",
true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyQ)),
);
action_map.insert(quit.id().clone(), MenuAction::Quit);
file_menu.append(&quit)?;
}
menu.append(&file_menu)?;
let tab_menu = Submenu::new("Tab", true);
let new_tab = MenuItem::with_id(
"new_tab",
"New Tab",
true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyT)),
);
action_map.insert(new_tab.id().clone(), MenuAction::NewTab);
tab_menu.append(&new_tab)?;
let close_tab = MenuItem::with_id(
"close_tab",
"Close Tab",
true,
None, );
action_map.insert(close_tab.id().clone(), MenuAction::CloseTab);
tab_menu.append(&close_tab)?;
tab_menu.append(&PredefinedMenuItem::separator())?;
let next_tab = MenuItem::with_id(
"next_tab",
"Next Tab",
true,
Some(Accelerator::new(
Some(cmd_or_ctrl_shift),
Code::BracketRight,
)),
);
action_map.insert(next_tab.id().clone(), MenuAction::NextTab);
tab_menu.append(&next_tab)?;
let prev_tab = MenuItem::with_id(
"prev_tab",
"Previous Tab",
true,
Some(Accelerator::new(Some(cmd_or_ctrl_shift), Code::BracketLeft)),
);
action_map.insert(prev_tab.id().clone(), MenuAction::PreviousTab);
tab_menu.append(&prev_tab)?;
tab_menu.append(&PredefinedMenuItem::separator())?;
for i in 1..=9 {
let code = match i {
1 => Code::Digit1,
2 => Code::Digit2,
3 => Code::Digit3,
4 => Code::Digit4,
5 => Code::Digit5,
6 => Code::Digit6,
7 => Code::Digit7,
8 => Code::Digit8,
9 => Code::Digit9,
_ => unreachable!(),
};
let tab_item = MenuItem::with_id(
format!("tab_{}", i),
format!("Tab {}", i),
true,
Some(Accelerator::new(Some(tab_switch_mod), code)),
);
action_map.insert(tab_item.id().clone(), MenuAction::SwitchToTab(i));
tab_menu.append(&tab_item)?;
}
menu.append(&tab_menu)?;
let profiles_menu = Submenu::new("Profiles", true);
let manage_profiles = MenuItem::with_id(
"manage_profiles",
"Manage Profiles...",
true,
Some(Accelerator::new(Some(cmd_or_ctrl_shift), Code::KeyP)),
);
action_map.insert(manage_profiles.id().clone(), MenuAction::ManageProfiles);
profiles_menu.append(&manage_profiles)?;
let toggle_drawer = MenuItem::with_id(
"toggle_profile_drawer",
"Toggle Profile Drawer",
true,
None, );
action_map.insert(toggle_drawer.id().clone(), MenuAction::ToggleProfileDrawer);
profiles_menu.append(&toggle_drawer)?;
profiles_menu.append(&PredefinedMenuItem::separator())?;
menu.append(&profiles_menu)?;
let profiles_submenu = profiles_menu;
let edit_menu = Submenu::new("Edit", true);
let copy = MenuItem::with_id(
"copy",
"Copy",
true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyC)),
);
action_map.insert(copy.id().clone(), MenuAction::Copy);
edit_menu.append(©)?;
let paste = MenuItem::with_id(
"paste",
"Paste",
true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyV)),
);
action_map.insert(paste.id().clone(), MenuAction::Paste);
edit_menu.append(&paste)?;
let select_all = MenuItem::with_id(
"select_all",
"Select All",
true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::KeyA)),
);
action_map.insert(select_all.id().clone(), MenuAction::SelectAll);
edit_menu.append(&select_all)?;
edit_menu.append(&PredefinedMenuItem::separator())?;
let clear_scrollback = MenuItem::with_id(
"clear_scrollback",
"Clear Scrollback",
true,
Some(Accelerator::new(Some(cmd_or_ctrl_shift), Code::KeyK)),
);
action_map.insert(clear_scrollback.id().clone(), MenuAction::ClearScrollback);
edit_menu.append(&clear_scrollback)?;
let clipboard_history = MenuItem::with_id(
"clipboard_history",
"Clipboard History",
true,
Some(Accelerator::new(Some(cmd_or_ctrl_shift), Code::KeyH)),
);
action_map.insert(clipboard_history.id().clone(), MenuAction::ClipboardHistory);
edit_menu.append(&clipboard_history)?;
#[cfg(not(target_os = "macos"))]
{
edit_menu.append(&PredefinedMenuItem::separator())?;
let preferences = MenuItem::with_id(
"preferences",
"Preferences...",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::Comma,
)),
);
action_map.insert(preferences.id().clone(), MenuAction::OpenSettings);
edit_menu.append(&preferences)?;
}
menu.append(&edit_menu)?;
let view_menu = Submenu::new("View", true);
let toggle_fullscreen = MenuItem::with_id(
"toggle_fullscreen",
"Toggle Fullscreen",
true,
Some(Accelerator::new(None, Code::F11)),
);
action_map.insert(toggle_fullscreen.id().clone(), MenuAction::ToggleFullscreen);
view_menu.append(&toggle_fullscreen)?;
let maximize_vertically = MenuItem::with_id(
"maximize_vertically",
"Maximize Vertically",
true,
Some(Accelerator::new(Some(Modifiers::SHIFT), Code::F11)),
);
action_map.insert(
maximize_vertically.id().clone(),
MenuAction::MaximizeVertically,
);
view_menu.append(&maximize_vertically)?;
view_menu.append(&PredefinedMenuItem::separator())?;
let increase_font = MenuItem::with_id(
"increase_font",
"Increase Font Size",
true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::Equal)),
);
action_map.insert(increase_font.id().clone(), MenuAction::IncreaseFontSize);
view_menu.append(&increase_font)?;
let decrease_font = MenuItem::with_id(
"decrease_font",
"Decrease Font Size",
true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::Minus)),
);
action_map.insert(decrease_font.id().clone(), MenuAction::DecreaseFontSize);
view_menu.append(&decrease_font)?;
let reset_font = MenuItem::with_id(
"reset_font",
"Reset Font Size",
true,
Some(Accelerator::new(Some(cmd_or_ctrl), Code::Digit0)),
);
action_map.insert(reset_font.id().clone(), MenuAction::ResetFontSize);
view_menu.append(&reset_font)?;
view_menu.append(&PredefinedMenuItem::separator())?;
let fps_overlay = MenuItem::with_id(
"fps_overlay",
"FPS Overlay",
true,
Some(Accelerator::new(None, Code::F3)),
);
action_map.insert(fps_overlay.id().clone(), MenuAction::ToggleFpsOverlay);
view_menu.append(&fps_overlay)?;
let settings = MenuItem::with_id(
"settings",
"Settings...",
true,
Some(Accelerator::new(None, Code::F12)),
);
action_map.insert(settings.id().clone(), MenuAction::OpenSettings);
view_menu.append(&settings)?;
view_menu.append(&PredefinedMenuItem::separator())?;
let save_arrangement =
MenuItem::with_id("save_arrangement", "Save Window Arrangement...", true, None);
action_map.insert(save_arrangement.id().clone(), MenuAction::SaveArrangement);
view_menu.append(&save_arrangement)?;
menu.append(&view_menu)?;
let shell_menu = Submenu::new("Shell", true);
let install_remote_integration = MenuItem::with_id(
"install_remote_shell_integration",
"Install Shell Integration on Remote Host...",
true,
None,
);
action_map.insert(
install_remote_integration.id().clone(),
MenuAction::InstallShellIntegrationRemote,
);
shell_menu.append(&install_remote_integration)?;
menu.append(&shell_menu)?;
#[cfg(target_os = "macos")]
macos::build_window_menu(&menu, &mut action_map)?;
let help_menu = Submenu::new("Help", true);
let keyboard_shortcuts = MenuItem::with_id(
"keyboard_shortcuts",
"Keyboard Shortcuts",
true,
Some(Accelerator::new(None, Code::F1)),
);
action_map.insert(keyboard_shortcuts.id().clone(), MenuAction::ShowHelp);
help_menu.append(&keyboard_shortcuts)?;
help_menu.append(&PredefinedMenuItem::separator())?;
let about = MenuItem::with_id("about", "About par-term", true, None);
action_map.insert(about.id().clone(), MenuAction::About);
help_menu.append(&about)?;
menu.append(&help_menu)?;
Ok(Self {
menu,
action_map,
profiles_submenu,
profile_menu_items: Vec::new(),
})
}
pub fn init_global(&self) -> Result<()> {
#[cfg(target_os = "macos")]
{
macos::init_for_nsapp(&self.menu)
}
#[cfg(not(target_os = "macos"))]
{
Ok(())
}
}
#[allow(unused_variables)] pub fn init_for_window(&self, window: &Arc<Window>) -> Result<()> {
#[cfg(target_os = "macos")]
{
macos::init_for_nsapp(&self.menu)
}
#[cfg(target_os = "windows")]
{
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
if let Ok(handle) = window.window_handle()
&& let RawWindowHandle::Win32(win32_handle) = handle.as_raw()
{
unsafe {
self.menu.init_for_hwnd(win32_handle.hwnd.get() as _)?;
}
log::info!("Initialized Windows menu bar for window");
}
return Ok(());
}
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
{
return linux::init_for_window(window);
}
#[cfg(not(any(
target_os = "macos",
target_os = "windows",
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
)))]
{
log::warn!("Menu bar not supported on this platform");
Ok(())
}
}
pub fn poll_events(&self) -> impl Iterator<Item = MenuAction> + '_ {
std::iter::from_fn(|| {
match MenuEvent::receiver().try_recv() {
Ok(event) => self.action_map.get(&event.id).copied(),
Err(_) => None,
}
})
}
pub fn update_profiles(&mut self, profiles: &[&Profile]) {
for item in self.profile_menu_items.drain(..) {
self.action_map.remove(item.id());
let _ = self.profiles_submenu.remove(&item);
}
for profile in profiles {
let menu_id = format!("profile_{}", profile.id);
let label = profile.display_label();
let item = MenuItem::with_id(menu_id, &label, true, None);
self.action_map
.insert(item.id().clone(), MenuAction::OpenProfile(profile.id));
if let Err(e) = self.profiles_submenu.append(&item) {
log::warn!("Failed to add profile menu item '{}': {}", label, e);
continue;
}
self.profile_menu_items.push(item);
}
log::info!("Updated profiles menu with {} items", profiles.len());
}
pub fn update_profiles_from_manager(&mut self, manager: &crate::profile::ProfileManager) {
let profiles: Vec<&Profile> = manager.profiles_ordered();
self.update_profiles(&profiles);
}
}