Files
ghostty-rs/crates/ghostty-macos/src/app/main_menu.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

146 lines
5.5 KiB
Rust

//! `MainMenu.xib`, built programmatically from [`menu_spec`].
//!
//! `NSApplicationMain` loads the xib named by `NSMainNibFile` (`MainMenu`),
//! which instantiates the `AppDelegate`, the menus, the `NSFontManager` object
//! and wires the `@IBOutlet`s. The Rust app has no `ibtool`/nib, so
//! [`install`] creates the same objects in code:
//!
//! * menus/items with the same titles, key equivalents, modifier masks and
//! actions (selector names are registered with the runtime; items whose xib
//! target is the First Responder proxy get a nil target, so they travel the
//! responder chain exactly like in the nib; `AppDelegate` targets are set);
//! * the system menus (`systemMenu` = apple/window/help) are registered with
//! `NSApplication` (`setAppleMenu:`, `windowsMenu`, `helpMenu`);
//! * the outlet items are returned for `AppDelegate` ([`Outlets`]).
//!
//! Edit-menu extras AppKit injects (Start Dictation, Emoji & Symbols) and the
//! Window-menu tab items are added by AppKit on its own at runtime, as in the
//! nib case.
#![allow(unsafe_code)]
use std::{collections::HashMap, ffi::CString};
use objc2::{
MainThreadMarker,
MainThreadOnly,
msg_send,
rc::Retained,
runtime::{AnyObject, Sel},
sel,
};
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSFontManager, NSMenu, NSMenuItem};
use objc2_foundation::NSString;
use super::{
app_delegate::AppDelegate,
menu_spec::{ItemSpec, MenuSpec, Outlet, SystemMenu, Target},
};
/// The items/menus the xib connects to `AppDelegate` outlets.
#[derive(Default)]
pub struct Outlets {
pub items: HashMap<Outlet, Retained<NSMenuItem>>,
/// `menuServices`, the submenu of the Services item.
pub services_menu: Option<Retained<NSMenu>>,
}
/// Build the main menu from the spec and register it (and the system menus)
/// with `app`. Also instantiates the `NSFontManager` the xib contains.
pub fn install(mtm: MainThreadMarker, app: &NSApplication, delegate: &AppDelegate) -> Outlets {
// `<customObject customClass="NSFontManager"/>`: make sure the shared
// font manager exists as the nib's instantiation would.
let _ = NSFontManager::sharedFontManager(mtm);
let mut outlets = Outlets::default();
let main = build_menu(
mtm,
app,
delegate,
&super::menu_spec::MAIN_MENU,
&mut outlets,
);
app.setMainMenu(Some(&main));
outlets
}
fn build_menu(
mtm: MainThreadMarker,
app: &NSApplication,
delegate: &AppDelegate,
spec: &MenuSpec,
outlets: &mut Outlets,
) -> Retained<NSMenu> {
let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str(spec.title));
register_system_menu(app, spec.system_menu, &menu);
if spec.outlet == Some(Outlet::MenuServices) {
outlets.services_menu = Some(menu.clone());
}
for entry in spec.items {
match entry {
ItemSpec::Separator => menu.addItem(&NSMenuItem::separatorItem(mtm)),
ItemSpec::Item(item) => {
let action: Option<Sel> = item.action.map(|a| selector(a.selector));
// SAFETY: a valid title, selector (or none) and key equivalent.
let ns_item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(item.title),
action,
&NSString::from_str(item.key_equivalent),
)
};
if let Some(mods) = item.modifiers {
ns_item.setKeyEquivalentModifierMask(NSEventModifierFlags(mods.0));
}
if let Some(a) = item.action {
match a.target {
// SAFETY: a nil target / the delegate (which outlives the menu).
Target::FirstResponder => unsafe { ns_item.setTarget(None) },
Target::AppDelegate => {
let target: &AnyObject = delegate;
// SAFETY: the delegate outlives the menu.
unsafe { ns_item.setTarget(Some(target)) };
}
}
}
if let Some(sub) = item.submenu {
let submenu = build_menu(mtm, app, delegate, sub, outlets);
ns_item.setSubmenu(Some(&submenu));
}
if let Some(o) = item.outlet {
outlets.items.insert(o, ns_item.clone());
}
menu.addItem(&ns_item);
}
}
}
menu
}
fn register_system_menu(app: &NSApplication, kind: Option<SystemMenu>, menu: &NSMenu) {
match kind {
Some(SystemMenu::Window) => app.setWindowsMenu(Some(menu)),
Some(SystemMenu::Help) => app.setHelpMenu(Some(menu)),
Some(SystemMenu::Apple) => {
// `systemMenu="apple"`: Interface Builder calls the private
// `-[NSApplication setAppleMenu:]`.
let sel = sel!(setAppleMenu:);
let responds: bool = unsafe { msg_send![app, respondsToSelector: sel] };
if responds {
// SAFETY: `setAppleMenu:` takes an NSMenu and returns void.
let _: () = unsafe { msg_send![app, setAppleMenu: menu] };
}
}
Some(SystemMenu::Main) | None => {}
}
}
/// Register a selector by name (`#selector(name)`).
#[must_use]
pub fn selector(name: &str) -> Sel {
let c = CString::new(name).expect("selector names contain no NUL");
Sel::register(&c)
}