Files
ghostty-rs/crates/ghostty-macos/src/app/menu_spec/mod.rs
T
ddidderr 4861015105 feat(macos): foundation of the macOS app ported from Swift to Rust
Phase 9, MA1: crates/ghostty-macos (AppKit via objc2; empty stub on
non-Apple targets) ports the foundation of Ghostty 1.3.1's Swift macOS
app (ghostty/macos): main, AppDelegate (+Ghostty) with lifecycle, dock
menu, open-file, notifications, shortcut sync and quit/reopen paths;
MainMenu.xib as generated menu tables built into NSMenus; the
Ghostty.App/Config/Surface wrappers driving libghostty in-process via
ghostty-lib; the generic helpers; scripts/macos-bundle.sh to assemble
Ghostty.app (dry-run on Linux). Feature areas are stubbed for MA2-MA5.
Design and type mapping: docs/rewrite/notes/macos-app-design.md
(App Intents is a Swift-only framework and cannot be ported).

Compile-checked only (no macOS here): just check-macos passes for
aarch64/x86_64-apple-darwin with -D warnings; 125 platform-independent
tests run on Linux.

Test Plan:
- just check-macos
- cargo test -p ghostty-macos
2026-10-12 19:25:44 +02:00

427 lines
15 KiB
Rust

//! `MainMenu.xib` and the menu bookkeeping of `AppDelegate.swift` as data.
//!
//! The xib is not translatable at build time on Linux (no `ibtool`), so
//! `scripts/gen-macos-menu-spec.py` reads it and emits [`generated`]: every
//! menu, item, key equivalent, modifier mask, action selector, target and
//! AppDelegate outlet. `app::main_menu` builds the real `NSMenu` tree from
//! these tables, and everything that is pure logic lives here so it is tested
//! on every target:
//!
//! * [`Outlet`]: the `@IBOutlet private var menuXxx: NSMenuItem?` properties;
//! * [`SYNCED_SHORTCUTS`]: the `syncMenuShortcut(config, action:, menuItem:)`
//! list of `syncMenuShortcuts`;
//! * [`MENU_IMAGES`]: the SF Symbols of `setupMenuImages`;
//! * [`MenuShortcutKey`]: the normalized shortcut lookup key.
//!
//! Upstream quirk preserved: the xib connects the "Use Selection for Find" item
//! to an outlet named `menuSelectionForSearch`, which does not exist in
//! `AppDelegate.swift` (AppKit only logs "Failed to connect ... outlet"), and
//! "Jump to Selection" is not connected at all. As a result the Swift
//! `menuSelectionForFind` / `menuScrollToSelection` are always `nil` and their
//! `syncMenuShortcut` calls are no-ops; [`CONNECTED_OUTLETS`] therefore does not
//! contain them and `AppDelegate` leaves them unset.
pub mod generated;
pub use generated::{CONNECTED_OUTLETS, MAIN_MENU};
/// Where an item's action is sent (`target` of the xib connection).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
/// `-1`, the First Responder proxy: a nil target, resolved through the
/// responder chain (controllers, window, then the app delegate).
FirstResponder,
/// The `AppDelegate` object of the xib.
AppDelegate,
}
/// An item's action: an Objective-C selector name and its target.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Action {
pub selector: &'static str,
pub target: Target,
}
/// `NSEvent.ModifierFlags` device independent mask bits for a key equivalent
/// (the raw values AppKit uses).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Mods(pub usize);
impl Mods {
pub const NONE: Self = Self(0);
pub const SHIFT: Self = Self(1 << 17);
pub const CONTROL: Self = Self(1 << 18);
pub const OPTION: Self = Self(1 << 19);
pub const COMMAND: Self = Self(1 << 20);
/// The four modifiers that take part in a shortcut
/// (`MenuShortcutKey.shortcutModifiers`).
pub const SHORTCUT: Self =
Self(Self::SHIFT.0 | Self::CONTROL.0 | Self::OPTION.0 | Self::COMMAND.0);
}
/// Which special menu AppKit is told about (`systemMenu` in the xib).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SystemMenu {
/// The application main menu (`NSApp.mainMenu`).
Main,
/// The application menu ("Ghostty"), `NSApp.setAppleMenu:`.
Apple,
/// `NSApp.windowsMenu`.
Window,
/// `NSApp.helpMenu`.
Help,
}
/// One entry of a menu.
#[derive(Clone, Copy, Debug)]
pub enum ItemSpec {
Separator,
Item(Item),
}
/// A menu item.
#[derive(Clone, Copy, Debug)]
pub struct Item {
pub title: &'static str,
pub key_equivalent: &'static str,
/// `None` is Interface Builder's default mask (command); `Some(Mods::NONE)`
/// is an explicitly empty `<modifierMask key="keyEquivalentModifierMask"/>`.
pub modifiers: Option<Mods>,
pub action: Option<Action>,
pub outlet: Option<Outlet>,
pub submenu: Option<&'static MenuSpec>,
}
/// A menu.
#[derive(Clone, Copy, Debug)]
pub struct MenuSpec {
pub title: &'static str,
pub system_menu: Option<SystemMenu>,
/// An outlet connected to the menu itself (`menuServices`).
pub outlet: Option<Outlet>,
pub items: &'static [ItemSpec],
}
impl MenuSpec {
/// Visit every item of this menu and its submenus, depth first.
pub fn walk(&self, f: &mut impl FnMut(&Item)) {
for item in self.items {
if let ItemSpec::Item(item) = item {
f(item);
if let Some(sub) = item.submenu {
sub.walk(f);
}
}
}
}
}
/// The `@IBOutlet`s of the Swift `AppDelegate` (plus `MenuSelectionForSearch`, the
/// outlet name the xib actually connects, see the module docs).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Outlet {
MenuAbout,
MenuServices,
MenuCheckForUpdates,
MenuOpenConfig,
MenuReloadConfig,
MenuSecureInput,
MenuQuit,
MenuNewWindow,
MenuNewTab,
MenuSplitRight,
MenuSplitLeft,
MenuSplitDown,
MenuSplitUp,
MenuClose,
MenuCloseTab,
MenuCloseWindow,
MenuCloseAllWindows,
MenuUndo,
MenuRedo,
MenuCopy,
MenuPaste,
MenuPasteSelection,
MenuSelectAll,
MenuFindParent,
MenuFind,
MenuSelectionForFind,
MenuScrollToSelection,
MenuFindNext,
MenuFindPrevious,
MenuHideFindBar,
MenuToggleVisibility,
MenuToggleFullScreen,
MenuBringAllToFront,
MenuZoomSplit,
MenuPreviousSplit,
MenuNextSplit,
MenuSelectSplitAbove,
MenuSelectSplitBelow,
MenuSelectSplitLeft,
MenuSelectSplitRight,
MenuReturnToDefaultSize,
MenuFloatOnTop,
MenuUseAsDefault,
MenuSetAsDefaultTerminal,
MenuIncreaseFontSize,
MenuDecreaseFontSize,
MenuResetFontSize,
MenuChangeTitle,
MenuChangeTabTitle,
MenuReadonly,
MenuQuickTerminal,
MenuTerminalInspector,
MenuCommandPalette,
MenuEqualizeSplits,
MenuMoveSplitDividerUp,
MenuMoveSplitDividerDown,
MenuMoveSplitDividerLeft,
MenuMoveSplitDividerRight,
MenuSelectionForSearch,
}
/// `syncMenuShortcuts`: (Ghostty keybind action, menu item), in the Swift order.
pub const SYNCED_SHORTCUTS: &[(&str, Outlet)] = &[
("check_for_updates", Outlet::MenuCheckForUpdates),
("open_config", Outlet::MenuOpenConfig),
("reload_config", Outlet::MenuReloadConfig),
("quit", Outlet::MenuQuit),
("new_window", Outlet::MenuNewWindow),
("new_tab", Outlet::MenuNewTab),
("close_surface", Outlet::MenuClose),
("close_tab", Outlet::MenuCloseTab),
("close_window", Outlet::MenuCloseWindow),
("close_all_windows", Outlet::MenuCloseAllWindows),
("new_split:right", Outlet::MenuSplitRight),
("new_split:left", Outlet::MenuSplitLeft),
("new_split:down", Outlet::MenuSplitDown),
("new_split:up", Outlet::MenuSplitUp),
("undo", Outlet::MenuUndo),
("redo", Outlet::MenuRedo),
("copy_to_clipboard", Outlet::MenuCopy),
("paste_from_clipboard", Outlet::MenuPaste),
("paste_from_selection", Outlet::MenuPasteSelection),
("select_all", Outlet::MenuSelectAll),
("start_search", Outlet::MenuFind),
("search_selection", Outlet::MenuSelectionForFind),
("scroll_to_selection", Outlet::MenuScrollToSelection),
("search:next", Outlet::MenuFindNext),
("search:previous", Outlet::MenuFindPrevious),
("toggle_split_zoom", Outlet::MenuZoomSplit),
("goto_split:previous", Outlet::MenuPreviousSplit),
("goto_split:next", Outlet::MenuNextSplit),
("goto_split:up", Outlet::MenuSelectSplitAbove),
("goto_split:down", Outlet::MenuSelectSplitBelow),
("goto_split:left", Outlet::MenuSelectSplitLeft),
("goto_split:right", Outlet::MenuSelectSplitRight),
("resize_split:up,10", Outlet::MenuMoveSplitDividerUp),
("resize_split:down,10", Outlet::MenuMoveSplitDividerDown),
("resize_split:right,10", Outlet::MenuMoveSplitDividerRight),
("resize_split:left,10", Outlet::MenuMoveSplitDividerLeft),
("equalize_splits", Outlet::MenuEqualizeSplits),
("reset_window_size", Outlet::MenuReturnToDefaultSize),
("increase_font_size:1", Outlet::MenuIncreaseFontSize),
("decrease_font_size:1", Outlet::MenuDecreaseFontSize),
("reset_font_size", Outlet::MenuResetFontSize),
("prompt_surface_title", Outlet::MenuChangeTitle),
("prompt_tab_title", Outlet::MenuChangeTabTitle),
("toggle_quick_terminal", Outlet::MenuQuickTerminal),
("toggle_visibility", Outlet::MenuToggleVisibility),
("toggle_window_float_on_top", Outlet::MenuFloatOnTop),
("inspector:toggle", Outlet::MenuTerminalInspector),
("toggle_command_palette", Outlet::MenuCommandPalette),
("toggle_secure_input", Outlet::MenuSecureInput),
];
/// `setupMenuImages`: (menu item, SF Symbol name), in the Swift order.
pub const MENU_IMAGES: &[(Outlet, &str)] = &[
(Outlet::MenuAbout, "info.circle"),
(Outlet::MenuCheckForUpdates, "square.and.arrow.down"),
(Outlet::MenuOpenConfig, "gear"),
(
Outlet::MenuReloadConfig,
"arrow.trianglehead.2.clockwise.rotate.90",
),
(Outlet::MenuSecureInput, "lock.display"),
(Outlet::MenuNewWindow, "macwindow.badge.plus"),
(Outlet::MenuNewTab, "macwindow"),
(Outlet::MenuSplitRight, "rectangle.righthalf.inset.filled"),
(Outlet::MenuSplitLeft, "rectangle.leadinghalf.inset.filled"),
(Outlet::MenuSplitUp, "rectangle.tophalf.inset.filled"),
(Outlet::MenuSplitDown, "rectangle.bottomhalf.inset.filled"),
(Outlet::MenuClose, "xmark"),
(Outlet::MenuPasteSelection, "doc.on.clipboard.fill"),
(Outlet::MenuIncreaseFontSize, "textformat.size.larger"),
(Outlet::MenuResetFontSize, "textformat.size"),
(Outlet::MenuDecreaseFontSize, "textformat.size.smaller"),
(Outlet::MenuCommandPalette, "filemenu.and.selection"),
(Outlet::MenuQuickTerminal, "apple.terminal"),
(Outlet::MenuChangeTabTitle, "pencil.line"),
(Outlet::MenuTerminalInspector, "scope"),
(Outlet::MenuReadonly, "eye.fill"),
(Outlet::MenuSetAsDefaultTerminal, "star.fill"),
(
Outlet::MenuToggleFullScreen,
"square.arrowtriangle.4.outward",
),
(Outlet::MenuToggleVisibility, "eye"),
(Outlet::MenuZoomSplit, "arrow.up.left.and.arrow.down.right"),
(Outlet::MenuPreviousSplit, "chevron.backward.2"),
(Outlet::MenuNextSplit, "chevron.forward.2"),
(
Outlet::MenuEqualizeSplits,
"inset.filled.topleft.topright.bottomleft.bottomright.rectangle",
),
(Outlet::MenuSelectSplitLeft, "arrow.left"),
(Outlet::MenuSelectSplitRight, "arrow.right"),
(Outlet::MenuSelectSplitAbove, "arrow.up"),
(Outlet::MenuSelectSplitBelow, "arrow.down"),
(Outlet::MenuMoveSplitDividerUp, "arrow.up.to.line"),
(Outlet::MenuMoveSplitDividerDown, "arrow.down.to.line"),
(Outlet::MenuMoveSplitDividerLeft, "arrow.left.to.line"),
(Outlet::MenuMoveSplitDividerRight, "arrow.right.to.line"),
(Outlet::MenuFloatOnTop, "square.filled.on.square"),
(Outlet::MenuFindParent, "text.page.badge.magnifyingglass"),
];
/// `AppDelegate.MenuShortcutKey`: a menu shortcut normalized for quick lookup
/// (lower-cased key equivalent + only the shift/control/option/command bits).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct MenuShortcutKey {
key_equivalent: String,
modifiers_raw_value: usize,
}
impl MenuShortcutKey {
/// `init?(keyEquivalent:modifiers:)`; `modifiers_raw` is the
/// `NSEvent.ModifierFlags.rawValue`.
#[must_use]
pub fn new(key_equivalent: &str, modifiers_raw: usize) -> Option<Self> {
let normalized = key_equivalent.to_lowercase();
if normalized.is_empty() {
return None;
}
Some(Self {
key_equivalent: normalized,
modifiers_raw_value: modifiers_raw & Mods::SHORTCUT.0,
})
}
}
/// The `dockMenu` entries of `reloadDockMenu`: (title, selector).
pub const DOCK_MENU: &[(&str, &str)] = &[("New Window", "newWindow:"), ("New Tab", "newTab:")];
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn all_items() -> Vec<Item> {
let mut v = Vec::new();
MAIN_MENU.walk(&mut |i| v.push(*i));
v
}
#[test]
fn xib_menu_structure() {
let top: Vec<_> = MAIN_MENU
.items
.iter()
.filter_map(|i| {
if let ItemSpec::Item(i) = i {
Some(i.title)
} else {
None
}
})
.collect();
assert_eq!(top, ["Ghostty", "File", "Edit", "View", "Window", "Help"]);
assert_eq!(MAIN_MENU.system_menu, Some(SystemMenu::Main));
let sys: Vec<_> = all_items()
.iter()
.filter_map(|i| i.submenu.and_then(|m| m.system_menu))
.collect();
assert_eq!(
sys,
[SystemMenu::Apple, SystemMenu::Window, SystemMenu::Help]
);
}
#[test]
fn fixed_key_equivalents_from_the_xib() {
let find = |title: &str| *all_items().iter().find(|i| i.title == title).unwrap();
let prefs = find("Preferences…");
assert_eq!(
(prefs.key_equivalent, prefs.modifiers.is_none()),
(",", true)
);
let hide_others = find("Hide Others");
assert_eq!(hide_others.key_equivalent, "h");
assert_eq!(
hide_others.modifiers,
Some(Mods(Mods::OPTION.0 | Mods::COMMAND.0))
);
let fs = find("Toggle Full Screen");
assert_eq!(fs.key_equivalent, "f");
assert_eq!(fs.modifiers, Some(Mods(Mods::CONTROL.0 | Mods::COMMAND.0)));
assert_eq!(find("Ghostty Help").key_equivalent, "?");
assert_eq!(find("Minimize").key_equivalent, "m");
}
#[test]
fn outlets_match_the_swift_app_delegate() {
// Every Swift outlet except the two the xib never connects has an item.
let connected: std::collections::HashSet<_> = CONNECTED_OUTLETS.iter().copied().collect();
for o in [Outlet::MenuSelectionForFind, Outlet::MenuScrollToSelection] {
assert!(!connected.contains(&o), "{o:?} is not connected by the xib");
}
assert!(connected.contains(&Outlet::MenuSelectionForSearch));
// Each connected outlet is attached to exactly one item/menu.
let mut seen = std::collections::HashMap::new();
MAIN_MENU.walk(&mut |i| {
if let Some(o) = i.outlet {
*seen.entry(o).or_insert(0) += 1;
}
if let Some(o) = i.submenu.and_then(|m| m.outlet) {
*seen.entry(o).or_insert(0) += 1;
}
});
assert_eq!(seen.len(), CONNECTED_OUTLETS.len());
assert!(seen.values().all(|&n| n == 1));
}
#[test]
fn synced_actions_are_unique_and_ordered_like_swift() {
assert_eq!(SYNCED_SHORTCUTS.len(), 49);
assert_eq!(
SYNCED_SHORTCUTS[0],
("check_for_updates", Outlet::MenuCheckForUpdates)
);
assert_eq!(
SYNCED_SHORTCUTS[48],
("toggle_secure_input", Outlet::MenuSecureInput)
);
// toggle_fullscreen is deliberately not synced.
assert!(
!SYNCED_SHORTCUTS
.iter()
.any(|(a, _)| *a == "toggle_fullscreen")
);
}
#[test]
fn menu_shortcut_key_normalizes() {
let a = MenuShortcutKey::new("T", Mods::COMMAND.0 | 0x10000).unwrap();
let b = MenuShortcutKey::new("t", Mods::COMMAND.0).unwrap();
// caps lock (0x10000) is not a shortcut modifier.
assert_eq!(a, b);
assert_ne!(
b,
MenuShortcutKey::new("t", Mods::COMMAND.0 | Mods::SHIFT.0).unwrap()
);
assert!(MenuShortcutKey::new("", Mods::COMMAND.0).is_none());
}
}