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
This commit is contained in:
ddidderr committed 2026-10-12 19:25:44 +02:00
1 parent feacba2d69
commit 4861015105
112 files changed
+18438 -2

No files matched your search

@@ -0,0 +1,690 @@
//! `Helpers/Fullscreen.swift`: the fullscreen styles (native and the
//! non-native variants). The platform independent parts (the mode enum and
//! the frame/menu decisions) are in `fullscreen_logic`.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code, clippy::borrow_as_ptr, clippy::ref_as_ptr)]
use std::{
cell::{Cell, RefCell},
rc::{Rc, Weak},
};
use objc2::{Message, msg_send, rc::Retained, runtime::AnyObject};
use objc2_app_kit::{
NSApplication,
NSApplicationPresentationOptions,
NSResponder,
NSScreen,
NSTitlebarAccessoryViewController,
NSTitlebarSeparatorStyle,
NSToolbar,
NSWindow,
NSWindowDidChangeScreenNotification,
NSWindowDidEnterFullScreenNotification,
NSWindowDidExitFullScreenNotification,
NSWindowOrderingMode,
NSWindowStyleMask,
NSWindowTabGroup,
NSWindowToolbarStyle,
};
use objc2_foundation::{NSRect, NSSize};
pub use super::fullscreen_logic::FullscreenMode;
use super::{
dispatch::main_async,
extensions::{
ns_application::NSApplicationExt,
ns_notification::{self, NsObserver},
ns_screen::NSScreenExt,
ns_window::NSWindowExt,
},
fullscreen_logic::{self, Properties, TabReinsert},
geometry::Rect,
notification_center::{Name, NotificationCenter, Sender, UserInfo},
private::cgs::{CGSSpace, CGSSpaceMask, CGSSpaceType},
};
/// `Notification.Name.fullscreenDidEnter`. The sender is the style's window
/// (`Retained<NSWindow>`).
pub const FULLSCREEN_DID_ENTER: Name = Name("com.mitchellh.fullscreenDidEnter");
/// `Notification.Name.fullscreenDidExit`.
pub const FULLSCREEN_DID_EXIT: Name = Name("com.mitchellh.fullscreenDidExit");
/// Delegate that can be implemented for fullscreen implementations.
pub trait FullscreenDelegate {
/// Called whenever the fullscreen state changed. You can call
/// `is_fullscreen` to see the current state.
fn fullscreen_did_change(&self);
}
/// Protocol that must be implemented by all fullscreen styles.
pub trait FullscreenStyle {
fn set_delegate(&self, delegate: Option<Weak<dyn FullscreenDelegate>>);
fn fullscreen_mode(&self) -> FullscreenMode;
fn is_fullscreen(&self) -> bool;
fn supports_tabs(&self) -> bool;
fn enter(&self);
fn exit(&self);
}
/// `FullscreenMode.style(for:)`: initializes the fullscreen style
/// implementation for the mode. This will not toggle any fullscreen
/// properties. This may fail if the window isn't configured properly for a
/// given mode.
#[must_use]
pub fn style_for(mode: FullscreenMode, window: &NSWindow) -> Option<Rc<dyn FullscreenStyle>> {
match mode {
FullscreenMode::Native => Some(NativeFullscreen::new(window)),
_ => Some(NonNativeFullscreen::new(window, mode)),
}
}
/// `TerminalWindow.isTabBar(_:)`.
pub type IsTabBarFn = fn(&NSWindow, &NSTitlebarAccessoryViewController) -> bool;
thread_local! {
/// TODO(MA3): `TerminalWindow.isTabBar(_:)`, registered by the terminal
/// window feature. Non-native fullscreen exit uses it to skip restoring
/// the (private) tab bar accessory.
static IS_TAB_BAR: Cell<Option<IsTabBarFn>> = const { Cell::new(None) };
}
/// Register `TerminalWindow.isTabBar(_:)`.
pub fn set_is_tab_bar_hook(f: IsTabBarFn) {
IS_TAB_BAR.with(|h| h.set(Some(f)));
}
fn is_tab_bar(window: &NSWindow, c: &NSTitlebarAccessoryViewController) -> bool {
IS_TAB_BAR.with(Cell::get).is_some_and(|f| f(window, c))
}
/// The base class for fullscreen implementations, cannot be used as a
/// FullscreenStyle on its own.
struct FullscreenBase {
window: Retained<NSWindow>,
delegate: RefCell<Option<Weak<dyn FullscreenDelegate>>>,
observers: RefCell<Vec<NsObserver>>,
}
impl FullscreenBase {
fn new(window: &NSWindow) -> Rc<Self> {
let base = Rc::new(Self {
window: window.retain(),
delegate: RefCell::new(None),
observers: RefCell::new(Vec::new()),
});
// We want to trigger delegate methods on window native fullscreen
// changes (didEnterFullScreenNotification, etc.) no matter what our
// fullscreen style is.
let window_obj: &AnyObject = window.as_ref();
// SAFETY: reading AppKit's immutable notification name constants.
#[allow(unsafe_code)]
let (enter, exit) = unsafe {
(
NSWindowDidEnterFullScreenNotification,
NSWindowDidExitFullScreenNotification,
)
};
let weak = Rc::downgrade(&base);
let o1 = ns_notification::observe(enter, Some(window_obj), move |_| {
if let Some(b) = weak.upgrade() {
b.notify(FULLSCREEN_DID_ENTER);
}
});
let weak = Rc::downgrade(&base);
let o2 = ns_notification::observe(exit, Some(window_obj), move |_| {
if let Some(b) = weak.upgrade() {
b.notify(FULLSCREEN_DID_EXIT);
}
});
base.observers.borrow_mut().extend([o1, o2]);
base
}
/// Post the Ghostty notification and call the delegate.
fn notify(&self, name: Name) {
let id = Retained::as_ptr(&self.window) as usize;
NotificationCenter::default_center().post(
name,
Some(Sender::new(id, self.window.clone())),
UserInfo::new(),
);
self.fullscreen_did_change();
}
fn fullscreen_did_change(&self) {
let delegate = self.delegate.borrow().as_ref().and_then(Weak::upgrade);
if let Some(d) = delegate {
d.fullscreen_did_change();
}
}
}
/// macOS native fullscreen. This is the typical behavior you get by pressing
/// the green fullscreen button on regular titlebars.
pub struct NativeFullscreen {
base: Rc<FullscreenBase>,
}
impl NativeFullscreen {
#[must_use]
pub fn new(window: &NSWindow) -> Rc<Self> {
// TODO: There are many requirements for native fullscreen we should
// check here such as the stylemask.
Rc::new(Self {
base: FullscreenBase::new(window),
})
}
}
impl FullscreenStyle for NativeFullscreen {
fn set_delegate(&self, delegate: Option<Weak<dyn FullscreenDelegate>>) {
*self.base.delegate.borrow_mut() = delegate;
}
fn fullscreen_mode(&self) -> FullscreenMode {
FullscreenMode::Native
}
fn is_fullscreen(&self) -> bool {
self.base
.window
.styleMask()
.contains(NSWindowStyleMask::FullScreen)
}
fn supports_tabs(&self) -> bool {
true
}
fn enter(&self) {
if self.is_fullscreen() {
return;
}
// The titlebar separator shows up erroneously in fullscreen if the tab
// bar is made to appear and then disappear by opening and then closing
// a tab. We get rid of the separator while in fullscreen to prevent this.
self.base
.window
.setTitlebarSeparatorStyle(NSTitlebarSeparatorStyle::None);
// Enter fullscreen
self.base
.window
.toggleFullScreen(Some(self.base.window.as_ref()));
// Note: we don't call our delegate here because the base class
// will always trigger the delegate on native fullscreen notifications
// and we don't want to double notify.
}
fn exit(&self) {
if !self.is_fullscreen() {
return;
}
// Restore titlebar separator style. See enter for explanation.
self.base
.window
.setTitlebarSeparatorStyle(NSTitlebarSeparatorStyle::Automatic);
self.base.window.toggleFullScreen(None);
// Note: we don't call our delegate here because the base class
// will always trigger the delegate on native fullscreen notifications
// and we don't want to double notify.
}
}
/// The state that must be saved for non-native fullscreen to exit fullscreen.
struct SavedState {
screen_id: Option<u32>,
tab_group: Option<Retained<NSWindowTabGroup>>,
tab_group_index: Option<usize>,
content_frame: NSRect,
style_mask: NSWindowStyleMask,
toolbar: Option<Retained<NSToolbar>>,
toolbar_style: NSWindowToolbarStyle,
titlebar_accessory_view_controllers: Vec<Retained<NSTitlebarAccessoryViewController>>,
dock: bool,
menu: bool,
}
impl SavedState {
fn new(window: &NSWindow) -> Option<Self> {
let content_view = window.contentView()?;
let tab_group = window.tabGroup();
let tab_group_index = tab_group
.as_ref()
.and_then(|g| g.windows().iter().position(|w| std::ptr::eq(&*w, window)));
// Accessing titlebarAccessoryViewControllers without a titlebar
// triggers a crash.
// SAFETY: `hasTitleBar` is a plain BOOL getter on NSWindow.
#[allow(unsafe_code)]
let has_title_bar: bool = unsafe { msg_send![window, hasTitleBar] };
let titlebar_accessory_view_controllers = if has_title_bar {
window.titlebarAccessoryViewControllers().to_vec()
} else {
Vec::new()
};
let menu = if let Some(cg_window_id) = window.cg_window_id() {
// We hide the menu only if this window is not on any fullscreen
// spaces. We do this because fullscreen spaces already hide the
// menu and if we insert/remove this presentation option we get
// issues (see #7075)
let active_space = CGSSpace::active();
let spaces = CGSSpace::list(cg_window_id, CGSSpaceMask::ALL_SPACES);
let flags: Vec<(bool, bool)> = spaces
.iter()
.map(|s| {
(
*s == active_space,
s.space_type() == Some(CGSSpaceType::Fullscreen),
)
})
.collect();
fullscreen_logic::can_hide_menu(
&flags,
active_space.space_type() == Some(CGSSpaceType::Fullscreen),
)
} else {
// Window doesn't have a window device, its not visible or
// something. In this case, we assume we can hide the menu. We may
// want to do something more sophisticated but this works for now.
true
};
Some(Self {
screen_id: window.screen().and_then(|s| s.display_id()),
tab_group,
tab_group_index,
content_frame: window.convertRectToScreen(content_view.frame()),
style_mask: window.styleMask(),
toolbar: window.toolbar(),
toolbar_style: window.toolbarStyle(),
titlebar_accessory_view_controllers,
dock: window.screen().is_some_and(|s| s.has_dock()),
menu,
})
}
}
struct NonNativeInner {
base: Rc<FullscreenBase>,
mode: FullscreenMode,
saved_state: RefCell<Option<SavedState>>,
screen_observer: RefCell<Option<NsObserver>>,
close_observer: RefCell<Option<NsObserver>>,
}
/// Non-native fullscreen, with the variants selected by [`FullscreenMode`]
/// (`NonNativeFullscreen`, `...VisibleMenu`, `...PaddedNotch`).
pub struct NonNativeFullscreen {
inner: Rc<NonNativeInner>,
}
fn to_rect(r: NSRect) -> Rect {
Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height)
}
impl NonNativeFullscreen {
/// `mode` must be one of the non-native modes (anything else behaves like
/// plain `NonNative`).
#[must_use]
pub fn new(window: &NSWindow, mode: FullscreenMode) -> Rc<Self> {
let base = FullscreenBase::new(window);
let inner = Rc::new(NonNativeInner {
base,
mode: if mode == FullscreenMode::Native {
FullscreenMode::NonNative
} else {
mode
},
saved_state: RefCell::new(None),
screen_observer: RefCell::new(None),
close_observer: RefCell::new(None),
});
let weak = Rc::downgrade(&inner);
let window_obj: &AnyObject = window.as_ref();
// SAFETY: reading AppKit's immutable notification name constant.
#[allow(unsafe_code)]
let will_close = unsafe { objc2_app_kit::NSWindowWillCloseNotification };
let observer = ns_notification::observe(will_close, Some(window_obj), move |_| {
// When the window closes we need to explicitly exit non-native
// fullscreen otherwise some state like the menu bar can remain hidden.
if let Some(i) = weak.upgrade() {
i.exit();
}
});
*inner.close_observer.borrow_mut() = Some(observer);
Rc::new(Self { inner })
}
}
impl NonNativeInner {
/// The default properties for this style.
fn properties(&self) -> Properties {
Properties::for_mode(self.mode)
}
fn window(&self) -> &Retained<NSWindow> {
&self.base.window
}
fn is_fullscreen(&self) -> bool {
// isFullscreen is dependent on if we have saved state currently. We
// could one day try to do fancier stuff like inspecting the window
// state but there isn't currently a need for it.
self.saved_state.borrow().is_some()
}
fn enter(self: &Rc<Self>) {
// If we are in fullscreen we don't do it again.
if self.is_fullscreen() {
return;
}
let window = self.window().clone();
// If we are in native fullscreen, exit native fullscreen. This is
// counter intuitive but if we entered native fullscreen (through the
// green max button or an external event) and we press the fullscreen
// keybind, we probably want to EXIT fullscreen.
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
window.toggleFullScreen(None);
return;
}
// This is the screen that we're going to go fullscreen on. We use the
// screen the window is currently on.
let Some(screen) = window.screen() else {
return;
};
// Save the state that we need to exit again
let Some(saved_state) = SavedState::new(&window) else {
return;
};
let (save_dock, save_menu) = (saved_state.dock, saved_state.menu);
*self.saved_state.borrow_mut() = Some(saved_state);
// Get our current first responder on this window. For non-native
// fullscreen we have to restore this because for some reason the
// operations below lose it (see:
// https://github.com/ghostty-org/ghostty/issues/6999). I don't know
// the root cause here so if we can figure that out there may be a
// nicer way than this.
let first_responder: Option<Retained<NSResponder>> = window.firstResponder();
// We hide the dock if the window is on a screen with the dock.
// We must hide the dock FIRST then hide the menu:
// If you specify autoHideMenuBar, it must be accompanied by either
// hideDock or autoHideDock.
if save_dock {
self.hide_dock();
}
// Hide the menu if requested
if self.properties().hide_menu && save_menu {
self.hide_menu();
}
// When we change screens we need to redo everything.
let weak = Rc::downgrade(self);
let window_obj: &AnyObject = window.as_ref();
// SAFETY: reading AppKit's immutable notification name constant.
#[allow(unsafe_code)]
let did_change_screen = unsafe { NSWindowDidChangeScreenNotification };
*self.screen_observer.borrow_mut() = Some(ns_notification::observe(
did_change_screen,
Some(window_obj),
move |_| {
if let Some(i) = weak.upgrade() {
i.window_did_change_screen();
}
},
));
// Being untitled let's our content take up the full frame.
window.setStyleMask(window.styleMask() - NSWindowStyleMask::Titled);
// We don't want the non-native fullscreen window to be resizable
// from the edges.
window.setStyleMask(window.styleMask() - NSWindowStyleMask::Resizable);
// Focus window
window.makeKeyAndOrderFront(None);
// Set frame to screen size, accounting for any elements such as the
// menu bar. We do this async so that all the style edits above (title
// removal, dock hide, menu hide, etc.) take effect. This fixes:
// https://github.com/ghostty-org/ghostty/issues/1996
let this = self.clone();
main_async(move || {
this.window()
.setFrame_display(this.fullscreen_frame(&screen), true);
if let Some(first_responder) = &first_responder {
this.window().makeFirstResponder(Some(first_responder));
}
this.base.notify(FULLSCREEN_DID_ENTER);
});
}
fn exit(&self) {
if !self.is_fullscreen() {
return;
}
let Some(saved_state) = self.saved_state.borrow().as_ref().map(SavedStateView::of) else {
return;
};
let window = self.window().clone();
// Remove all our notifications. We remove them one by one because we
// don't want to remove the observers that our superclass sets.
*self.screen_observer.borrow_mut() = None;
// See enter where we do the same thing to understand why.
let first_responder: Option<Retained<NSResponder>> = window.firstResponder();
// Unhide our elements
if saved_state.dock {
self.unhide_dock();
}
if self.properties().hide_menu && saved_state.menu {
self.unhide_menu();
}
// Restore our saved state
let saved = self.saved_state.borrow_mut().take().expect("checked above");
window.setStyleMask(saved.style_mask);
window.setFrame_display(window.frameRectForContentRect(saved.content_frame), true);
// Removing the "titled" style also derefs all our accessory view
// controllers so we need to restore those.
for c in &saved.titlebar_accessory_view_controllers {
// Restoring the tab bar causes all sorts of problems. Its best to
// just ignore it, even though this is kind of a hack.
if is_tab_bar(&window, c) {
continue;
}
let present = window
.titlebarAccessoryViewControllers()
.iter()
.any(|x| std::ptr::eq(Retained::as_ptr(&x), Retained::as_ptr(c)));
if !present {
window.addTitlebarAccessoryViewController(c);
}
}
// Removing "titled" also clears our toolbar
window.setToolbar(saved.toolbar.as_deref());
window.setToolbarStyle(saved.toolbar_style);
// If the window was previously in a tab group that isn't empty now,
// we re-add it. We have to do this because our process of doing
// non-native fullscreen removes the window from the tab group.
if let (Some(tab_group), Some(tab_index)) = (&saved.tab_group, saved.tab_group_index) {
let windows = tab_group.windows();
if windows.count() > 0 {
match fullscreen_logic::tab_reinsert(tab_index, windows.count()) {
TabReinsert::BelowFirst => {
// We were previously the first tab. Add it before
// ("below") the first window in the tab group currently.
windows
.objectAtIndex(0)
.add_tabbed_window_safely(&window, NSWindowOrderingMode::Below);
}
TabReinsert::AboveIndex(i) => {
// We were somewhere in the middle
windows
.objectAtIndex(i)
.add_tabbed_window_safely(&window, NSWindowOrderingMode::Above);
}
TabReinsert::BelowLast => {
// We were at the end
windows
.objectAtIndex(windows.count() - 1)
.add_tabbed_window_safely(&window, NSWindowOrderingMode::Below);
}
}
}
}
if let Some(first_responder) = &first_responder {
window.makeFirstResponder(Some(first_responder));
}
// Focus window
window.makeKeyAndOrderFront(None);
// Notify the delegate
self.base.notify(FULLSCREEN_DID_EXIT);
}
fn fullscreen_frame(&self, screen: &NSScreen) -> NSRect {
let mtm = super::main_thread();
let app = NSApplication::sharedApplication(mtm);
let options = app.presentationOptions();
let menu_hidden = options.contains(NSApplicationPresentationOptions::AutoHideMenuBar)
|| options.contains(NSApplicationPresentationOptions::HideMenuBar);
// NOTE on macOS bugs: macOS used to have a bug where menuBarHeight
// didn't account for the notch. I reported this as a radar and it was
// fixed at some point. I don't know when that was so I can't put an
// #available check, but it was in a bug fix release so I think if a
// bug is reported to Ghostty we can just advise the user to update.
let f = fullscreen_logic::fullscreen_frame(
to_rect(screen.frame()),
menu_hidden,
app.mainMenu().map_or(0.0, |m| m.menuBarHeight()),
self.properties().padded_notch,
screen.safeAreaInsets().top,
);
NSRect::new(
objc2_foundation::NSPoint::new(f.origin.x, f.origin.y),
NSSize::new(f.size.width, f.size.height),
)
}
// MARK: Window Events
fn window_did_change_screen(&self) {
if !self.is_fullscreen() {
return;
}
let saved_screen_id = match self.saved_state.borrow().as_ref() {
Some(s) => s.screen_id,
None => return,
};
// Our screens must have changed
if saved_screen_id == self.window().screen().and_then(|s| s.display_id()) {
return;
}
// When we change screens, we simply exit fullscreen. Changing
// screens shouldn't naturally be possible, it can only happen
// through external window managers. There's a lot of accounting
// to do to get the screen change right so instead of breaking
// we just exit out. The user can re-enter fullscreen thereafter.
self.exit();
}
// MARK: Dock
#[allow(clippy::unused_self)]
fn hide_dock(&self) {
NSApplication::sharedApplication(super::main_thread())
.acquire_presentation_option(NSApplicationPresentationOptions::AutoHideDock);
}
#[allow(clippy::unused_self)]
fn unhide_dock(&self) {
NSApplication::sharedApplication(super::main_thread())
.release_presentation_option(NSApplicationPresentationOptions::AutoHideDock);
}
// MARK: Menu
#[allow(clippy::unused_self)]
fn hide_menu(&self) {
NSApplication::sharedApplication(super::main_thread())
.acquire_presentation_option(NSApplicationPresentationOptions::AutoHideMenuBar);
}
#[allow(clippy::unused_self)]
fn unhide_menu(&self) {
NSApplication::sharedApplication(super::main_thread())
.release_presentation_option(NSApplicationPresentationOptions::AutoHideMenuBar);
}
}
/// The `Bool` flags of the saved state (what `exit` reads before it is taken).
struct SavedStateView {
dock: bool,
menu: bool,
}
impl SavedStateView {
fn of(s: &SavedState) -> Self {
Self {
dock: s.dock,
menu: s.menu,
}
}
}
impl FullscreenStyle for NonNativeFullscreen {
fn set_delegate(&self, delegate: Option<Weak<dyn FullscreenDelegate>>) {
*self.inner.base.delegate.borrow_mut() = delegate;
}
fn fullscreen_mode(&self) -> FullscreenMode {
self.inner.mode
}
fn is_fullscreen(&self) -> bool {
self.inner.is_fullscreen()
}
// Non-native fullscreen never supports tabs because tabs require the
// "titled" style and we don't have it for non-native fullscreen.
fn supports_tabs(&self) -> bool {
false
}
fn enter(&self) {
self.inner.enter();
}
fn exit(&self) {
self.inner.exit();
}
}