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:
1 parent
fb79cc8d5c
commit
0137168c66
112 files changed
+18438
-2
No files matched your search
@@ -0,0 +1,161 @@
|
||||
//! `Helpers/Extensions/NSWindow+Extension.swift`. Frame math is in
|
||||
//! `ns_window_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 objc2::{msg_send, rc::Retained, runtime::AnyObject, sel};
|
||||
use objc2_app_kit::{NSScreen, NSView, NSWindow, NSWindowOrderingMode};
|
||||
use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};
|
||||
|
||||
use super::{ns_view::NSViewExt, ns_window_logic};
|
||||
use crate::helpers::{geometry::Rect, objc_exception_catcher};
|
||||
|
||||
fn to_rect(r: NSRect) -> Rect {
|
||||
Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height)
|
||||
}
|
||||
|
||||
fn to_ns_rect(r: Rect) -> NSRect {
|
||||
NSRect::new(
|
||||
NSPoint::new(r.origin.x, r.origin.y),
|
||||
NSSize::new(r.size.width, r.size.height),
|
||||
)
|
||||
}
|
||||
|
||||
pub trait NSWindowExt {
|
||||
/// Get the CGWindowID type for the window (used for low level
|
||||
/// CoreGraphics APIs). `None` if the window has no window device.
|
||||
fn cg_window_id(&self) -> Option<u32>;
|
||||
/// Adjusts the window frame if necessary to ensure the window remains
|
||||
/// visible on screen. This constrains both the size (to not exceed the
|
||||
/// screen) and the origin (to keep the window on screen).
|
||||
fn constrain_to_screen(&self);
|
||||
/// True if this is the first window in the tab group.
|
||||
fn is_first_window_in_tab_group(&self) -> bool;
|
||||
/// Wraps `addTabbedWindow` with an Objective-C exception catcher because
|
||||
/// AppKit can throw NSExceptions in visual tab picker flows.
|
||||
fn add_tabbed_window_safely(&self, child: &NSWindow, ordered: NSWindowOrderingMode) -> bool;
|
||||
|
||||
// Native tabbing private API usage. :(
|
||||
|
||||
/// The [private] titlebar view. In normal windows `NSTabBar` typically
|
||||
/// appears as a subview of `NSTitlebarView` within `NSThemeFrame`. In
|
||||
/// fullscreen, the system creates a dedicated fullscreen window and the
|
||||
/// view hierarchy changes; in that case, the `titlebarView` is only
|
||||
/// accessible via a reference on `NSThemeFrame`.
|
||||
fn titlebar_view(&self) -> Option<Retained<NSView>>;
|
||||
/// Returns the [private] NSTabBar view, if it exists.
|
||||
fn tab_bar_view(&self) -> Option<Retained<NSView>>;
|
||||
/// Returns tab button views in visual order from left to right.
|
||||
fn tab_buttons_in_visual_order(&self) -> Vec<Retained<NSView>>;
|
||||
/// Returns the visual tab index and matching tab button at the given screen point.
|
||||
fn tab_button_hit(&self, screen_point: NSPoint) -> Option<(usize, Retained<NSView>)>;
|
||||
/// Returns the index of the tab button at the given screen point, if any.
|
||||
fn tab_index(&self, screen_point: NSPoint) -> Option<usize>;
|
||||
}
|
||||
|
||||
impl NSWindowExt for NSWindow {
|
||||
fn cg_window_id(&self) -> Option<u32> {
|
||||
ns_window_logic::cg_window_id(self.windowNumber())
|
||||
}
|
||||
|
||||
fn constrain_to_screen(&self) {
|
||||
let screen: Option<Retained<NSScreen>> = self
|
||||
.screen()
|
||||
.or_else(|| NSScreen::mainScreen(crate::helpers::main_thread()));
|
||||
let Some(screen) = screen else { return };
|
||||
let window_frame = to_rect(self.frame());
|
||||
if let Some(new_frame) =
|
||||
ns_window_logic::constrained_frame(window_frame, to_rect(screen.visibleFrame()))
|
||||
{
|
||||
self.setFrame_display(to_ns_rect(new_frame), true);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_first_window_in_tab_group(&self) -> bool {
|
||||
let Some(group) = self.tabGroup() else {
|
||||
return true;
|
||||
};
|
||||
let Some(first) = group.windows().firstObject() else {
|
||||
return true;
|
||||
};
|
||||
std::ptr::eq(&*first, self)
|
||||
}
|
||||
|
||||
fn add_tabbed_window_safely(&self, child: &NSWindow, ordered: NSWindowOrderingMode) -> bool {
|
||||
match objc_exception_catcher::add_tabbed_window_safely(self, child, ordered) {
|
||||
Ok(()) => true,
|
||||
Err(error) => {
|
||||
log::error!("addTabbedWindow failed: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn titlebar_view(&self) -> Option<Retained<NSView>> {
|
||||
let theme_frame_view = self.contentView()?.root_view();
|
||||
// SAFETY: `respondsToSelector:` is safe to send to any object.
|
||||
let responds: bool =
|
||||
unsafe { msg_send![&*theme_frame_view, respondsToSelector: sel!(titlebarView)] };
|
||||
if !responds {
|
||||
return None;
|
||||
}
|
||||
// `value(forKey: "titlebarView") as? NSView`
|
||||
let value: Option<Retained<AnyObject>> = unsafe {
|
||||
msg_send![&*theme_frame_view, valueForKey: &*NSString::from_str("titlebarView")]
|
||||
};
|
||||
value?.downcast::<NSView>().ok()
|
||||
}
|
||||
|
||||
fn tab_bar_view(&self) -> Option<Retained<NSView>> {
|
||||
self.titlebar_view()?
|
||||
.first_descendant_with_class_name("NSTabBar")
|
||||
}
|
||||
|
||||
fn tab_buttons_in_visual_order(&self) -> Vec<Retained<NSView>> {
|
||||
let Some(tab_bar_view) = self.tab_bar_view() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut buttons = tab_bar_view.descendants_with_class_name("NSTabButton");
|
||||
buttons.sort_by(|a, b| a.frame().origin.x.total_cmp(&b.frame().origin.x));
|
||||
buttons
|
||||
}
|
||||
|
||||
fn tab_button_hit(&self, screen_point: NSPoint) -> Option<(usize, Retained<NSView>)> {
|
||||
let tab_bar_view = self.tab_bar_view()?;
|
||||
let tab_bar_window = tab_bar_view.window()?;
|
||||
|
||||
// In fullscreen, AppKit can host the titlebar and tab bar in a
|
||||
// separate NSToolbarFullScreenWindow. Hit testing has to use that
|
||||
// window's base coordinate space or content clicks can be
|
||||
// misinterpreted as tab clicks.
|
||||
let location_in_tab_bar_window = tab_bar_window.convertPointFromScreen(screen_point);
|
||||
let location_in_tab_bar =
|
||||
tab_bar_view.convertPoint_fromView(location_in_tab_bar_window, None);
|
||||
if !point_in_rect(location_in_tab_bar, tab_bar_view.bounds()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
for (index, tab_button) in self.tab_buttons_in_visual_order().into_iter().enumerate() {
|
||||
let location_in_tab_button =
|
||||
tab_button.convertPoint_fromView(location_in_tab_bar_window, None);
|
||||
if point_in_rect(location_in_tab_button, tab_button.bounds()) {
|
||||
return Some((index, tab_button));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn tab_index(&self, screen_point: NSPoint) -> Option<usize> {
|
||||
self.tab_button_hit(screen_point).map(|(i, _)| i)
|
||||
}
|
||||
}
|
||||
|
||||
/// `CGRect.contains(CGPoint)`.
|
||||
fn point_in_rect(p: NSPoint, r: NSRect) -> bool {
|
||||
p.x >= r.origin.x
|
||||
&& p.x < r.origin.x + r.size.width
|
||||
&& p.y >= r.origin.y
|
||||
&& p.y < r.origin.y + r.size.height
|
||||
}
|
||||
Reference in new issue
Block a user