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 fb79cc8d5c
commit 0137168c66
112 files changed
+18438 -2

No files matched your search

Generated
+54
View File
@@ -1611,6 +1611,27 @@ dependencies = [
"tempfile",
]
[[package]]
name = "ghostty-macos"
version = "0.1.0"
dependencies = [
"bitflags 2.13.2",
"block2",
"dispatch2",
"ghostty-lib",
"libc",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"objc2-metal",
"objc2-quartz-core",
"objc2-user-notifications",
"tempfile",
]
[[package]]
name = "ghostty-onig"
version = "0.1.0"
@@ -2770,6 +2791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
"objc2-exception-helper",
]
[[package]]
@@ -2854,6 +2876,16 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
@@ -2889,6 +2921,15 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-exception-helper"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
dependencies = [
"cc",
]
[[package]]
name = "objc2-foundation"
version = "0.3.2"
@@ -2947,6 +2988,19 @@ dependencies = [
"objc2-metal",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"bitflags 2.13.2",
"block2",
"objc2",
"objc2-core-location",
"objc2-foundation",
]
[[package]]
name = "objc_id"
version = "0.1.1"
+84
View File
@@ -0,0 +1,84 @@
[package]
name = "ghostty-macos"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Ghostty's macOS app (Sources/ of the Swift app) ported to Rust/AppKit; drives libghostty (ghostty-lib) in-process"
# On non-Apple targets both the library and the binary compile to empty stubs so
# the workspace stays green; the real code is `cfg(target_vendor = "apple")`.
[lib]
name = "ghostty_macos"
[[bin]]
name = "ghostty-macos-app"
path = "src/main.rs"
[dependencies]
bitflags = "2"
libghostty = { package = "ghostty-lib", path = "../ghostty-lib" }
log = "0.4.34"
[target.'cfg(target_vendor = "apple")'.dependencies]
# The embedded C API, called in-process exactly like the Swift app calls
# `ghostty.h` (renamed so it does not clash with the `ghostty` module tree).
block2 = "0.6.2"
dispatch2 = "0.3.1"
libc = "0.2.190"
objc2 = { version = "0.6.5", features = ["exception"] }
objc2-app-kit = "0.3.2"
objc2-core-foundation = "0.3.2"
objc2-core-graphics = "0.3.2"
objc2-foundation = "0.3.2"
objc2-metal = "0.3.2"
objc2-quartz-core = "0.3.2"
objc2-user-notifications = "0.3.2"
# The workspace forbids `unsafe`; objc2 needs it for class definitions, message
# sends with raw pointers and the C API, so this crate denies it and allows it
# (with SAFETY comments) in the few modules that need it.
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
cast_possible_truncation = "allow"
cast_possible_wrap = "allow"
cast_sign_loss = "allow"
cast_precision_loss = "allow"
cast_lossless = "allow"
module_name_repetitions = "allow"
too_many_lines = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
similar_names = "allow"
struct_excessive_bools = "allow"
fn_params_excessive_bools = "allow"
match_same_arms = "allow"
unreadable_literal = "allow"
many_single_char_names = "allow"
doc_markdown = "allow"
must_use_candidate = "allow"
return_self_not_must_use = "allow"
used_underscore_binding = "allow"
large_stack_arrays = "allow"
inline_always = "allow"
missing_safety_doc = "allow"
# The AppKit code mirrors the Swift sources statement by statement (nested
# `if let`s, `match` on bools, wildcard imports of the generated C enumerators).
collapsible_if = "allow"
collapsible_else_if = "allow"
match_bool = "allow"
wildcard_imports = "allow"
unused_self = "allow"
ref_as_ptr = "allow"
ptr_cast_constness = "allow"
redundant_closure_for_method_calls = "allow"
manual_is_variant_and = "allow"
semicolon_if_nothing_returned = "allow"
[dev-dependencies]
tempfile = "3"
+815
View File
@@ -0,0 +1,815 @@
Compiling ghostty-build-info v0.1.0 (../../crates/ghostty-build-info)
Checking ghostty-config v0.1.0 (../../crates/ghostty-config)
Checking ghostty-font v0.1.0 (../../crates/ghostty-font)
Checking ghostty-renderer v0.1.0 (../../crates/ghostty-renderer)
Checking ghostty-inspector v0.1.0 (../../crates/ghostty-inspector)
Checking ghostty-apprt v0.1.0 (../../crates/ghostty-apprt)
Checking ghostty-termio v0.1.0 (../../crates/ghostty-termio)
Checking ghostty-cli v0.1.0 (../../crates/ghostty-cli)
Checking ghostty-core v0.1.0 (../../crates/ghostty-core)
Checking ghostty-lib v0.1.0 (../../crates/ghostty-lib)
Checking ghostty-macos v0.1.0 (../../crates/ghostty-macos)
error[E0432]: unresolved import `crate::ghostty::capi::c_int`
--> crates/ghostty-macos/src/ghostty/surface_view.rs:52:5
|
52 | c_int, ghostty_surface_needs_confirm_quit,
| ^^^^^ no `c_int` in `ghostty::capi`
|
= help: consider importing one of these type aliases instead:
std::ffi::c_int
std::os::raw::c_int
core::ffi::c_int
libc::c_int
error[E0432]: unresolved import `crate::ghostty::capi::c_int`
--> crates/ghostty-macos/src/ghostty/surface_view.rs:52:5
|
52 | c_int, ghostty_surface_needs_confirm_quit,
| ^^^^^ no `c_int` in `ghostty::capi`
|
= help: consider importing one of these type aliases instead:
std::ffi::c_int
std::os::raw::c_int
core::ffi::c_int
libc::c_int
= note: type alias `crate::helpers::extensions::ns_pasteboard::tests::c_int` exists but is inaccessible
error[E0425]: cannot find function `remove_persistent_domain_for_ghostty_suite` in module `user_defaults`
--> crates/ghostty-macos/src/app/app_delegate.rs:227:40
|
227 | user_defaults::remove_persistent_domain_for_ghostty_suite();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `user_defaults`
error[E0425]: cannot find function `register_defaults` in module `user_defaults`
--> crates/ghostty-macos/src/app/app_delegate.rs:231:28
|
231 | user_defaults::register_defaults(&[
| ^^^^^^^^^^^^^^^^^ not found in `user_defaults`
error[E0425]: cannot find function `register_defaults` in module `user_defaults`
--> crates/ghostty-macos/src/app/app_delegate.rs:670:24
|
670 | user_defaults::register_defaults(&[
| ^^^^^^^^^^^^^^^^^ not found in `user_defaults`
error[E0425]: cannot find function `name_window_did_become_key` in module `ns_notification`
--> crates/ghostty-macos/src/app/app_delegate.rs:1072:30
|
1072 | ns_notification::name_window_did_become_key(),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `ns_notification`
error[E0425]: cannot find value `TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION` in module `gn`
--> crates/ghostty-macos/src/app/app_delegate.rs:1104:38
|
1104 | bus.push(nc.add_observer(gn::TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION, None, move |n| {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `gn`
error[E0425]: cannot find function `bundle_info_bool` in module `crate::helpers::app_info`
--> crates/ghostty-macos/src/app/app_delegate.rs:1321:38
|
1321 | if crate::helpers::app_info::bundle_info_bool("SUEnableAutomaticChecks") == Some(false) {
| ^^^^^^^^^^^^^^^^ not found in `crate::helpers::app_info`
error[E0425]: cannot find function `post_icon_did_change` in module `crate::features::custom_app_icon`
--> crates/ghostty-macos/src/app/app_delegate.rs:1416:43
|
1416 | crate::features::custom_app_icon::post_icon_did_change();
| ^^^^^^^^^^^^^^^^^^^^ not found in `crate::features::custom_app_icon`
error[E0425]: cannot find function `set_default_application_for_unix_executable` in module `crate::helpers::extensions::ns_workspace`
--> crates/ghostty-macos/src/app/app_delegate.rs:1500:51
|
1500 | crate::helpers::extensions::ns_workspace::set_default_application_for_unix_executable(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::ns_workspace`
error[E0425]: cannot find function `ns_flags_from_swiftui` in module `crate::helpers::extensions::event_modifiers`
--> crates/ghostty-macos/src/app/app_delegate.rs:1572:74
|
1572 | let modifier_mask = crate::helpers::extensions::event_modifiers::ns_flags_from_swiftui(shortcut.modifiers);
| ^^^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::event_modifiers`
error[E0425]: cannot find function `default_application_url_for_extension` in module `crate::helpers::extensions::ns_workspace`
--> crates/ghostty-macos/src/ghostty/app.rs:962:68
|
962 | let editor = crate::helpers::extensions::ns_workspace::default_application_url_for_extension(&ext)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::ns_workspace`
error[E0425]: cannot find value `default_text_editor` in module `crate::helpers::extensions::ns_workspace`
--> crates/ghostty-macos/src/ghostty/app.rs:963:68
|
963 | .or_else(crate::helpers::extensions::ns_workspace::default_text_editor);
| ^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::ns_workspace`
error[E0425]: cannot find function `from_ghostty` in module `fullscreen_mode`
--> crates/ghostty-macos/src/ghostty/app.rs:1141:39
|
1141 | let Some(mode) = fullscreen_mode::from_ghostty(raw) else {
| ^^^^^^^^^^^^ not found in `fullscreen_mode`
|
help: consider importing this function
|
20 + use crate::helpers::extensions::os_color::from_ghostty;
|
help: if you import `from_ghostty`, refer to it directly
|
1141 - let Some(mode) = fullscreen_mode::from_ghostty(raw) else {
1141 + let Some(mode) = from_ghostty(raw) else {
|
warning: unused import: `NSNumber`
--> crates/ghostty-macos/src/app/app_delegate.rs:34:74
|
34 | NSArray, NSCoder, NSDictionary, NSKeyValueChangeKey, NSNotification, NSNumber, NSObject,
| ^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: unused import: `Sender`
--> crates/ghostty-macos/src/app/app_delegate.rs:76:92
|
76 | use crate::helpers::notification_center::{Notification, NotificationCenter, ObserverToken, Sender};
| ^^^^^^
error: usage of an `unsafe` block
--> crates/ghostty-macos/src/features/quick_terminal/controller.rs:64:9
|
64 | unsafe { msg_send![super(this), init] }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: requested on the command line with `-D unsafe-code`
error: usage of an `unsafe` block
--> crates/ghostty-macos/src/features/services/mod.rs:26:9
|
26 | unsafe { msg_send![this, init] }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
warning: unused import: `NSPasteboardNameGeneral`
--> crates/ghostty-macos/src/ghostty/app.rs:36:19
|
36 | NSPasteboard, NSPasteboardNameGeneral, NSPasteboardTypeString,
| ^^^^^^^^^^^^^^^^^^^^^^^
warning: unused import: `NSUserInterfaceItemSearching`
--> crates/ghostty-macos/src/app/app_delegate.rs:31:52
|
31 | NSWindowLevel, NSWindowStyleMask, NSWorkspace, NSUserInterfaceItemSearching,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: unused import: `NSNumber`
--> crates/ghostty-macos/src/app/app_delegate.rs:34:74
|
34 | NSArray, NSCoder, NSDictionary, NSKeyValueChangeKey, NSNotification, NSNumber, NSObject,
| ^^^^^^^^
error[E0308]: mismatched types
--> crates/ghostty-macos/src/app/app_delegate.rs:288:24
|
199 | / define_class!(
200 | | // SAFETY: NSObject has no subclassing requirements; AppDelegate does not
201 | | // implement Drop.
202 | | #[unsafe(super(NSObject))]
... |
288 | | return true;
| | ^^^^ expected `Bool`, found `bool`
... |
523 | | );
| |_- expected `objc2::runtime::Bool` because of return type
|
help: call `Into::into` on this expression to convert `bool` into `objc2::runtime::Bool`
|
288 | return true.into();
| +++++++
error[E0599]: no method named `surface_tree` found for struct `Retained<TerminalController>` in the current scope
--> crates/ghostty-macos/src/app/app_delegate.rs:653:27
|
653 | for view in c.surface_tree() {
| ^^^^^^^^^^^^
|
help: there is a method `surface_tree_is_split` with a similar name
|
653 | for view in c.surface_tree_is_split() {
| +++++++++
error[E0599]: no method named `attributeDescriptorForKeyword` found for struct `Retained<NSAppleEventDescriptor>` in the current scope
--> crates/ghostty-macos/src/app/app_delegate.rs:807:38
|
807 | if let Some(why) = event.attributeDescriptorForKeyword(keyword) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ method not found in `Retained<NSAppleEventDescriptor>`
error[E0599]: no method named `retain` found for reference `&AppDelegate` in the current scope
--> crates/ghostty-macos/src/app/app_delegate.rs:939:46
|
939 | let weak = Weak::from_retained(&self.retain());
| ^^^^^^
|
= help: items from traits can only be used if the trait is in scope
help: trait `Message` which provides `retain` is implemented but not in scope; perhaps you want to import it
|
16 + use objc2::Message;
|
help: there is a method `retainCount` with a similar name
|
939 | let weak = Weak::from_retained(&self.retainCount());
| +++++
error[E0599]: no method named `retain` found for reference `&AppDelegate` in the current scope
--> crates/ghostty-macos/src/app/app_delegate.rs:957:35
|
957 | Weak::from_retained(&self.retain())
| ^^^^^^
|
= help: items from traits can only be used if the trait is in scope
help: trait `Message` which provides `retain` is implemented but not in scope; perhaps you want to import it
|
16 + use objc2::Message;
|
help: there is a method `retainCount` with a similar name
|
957 | Weak::from_retained(&self.retainCount())
| +++++
error[E0599]: the associated function or constant `alloc` exists for struct `NSSound`, but its trait bounds were not satisfied
--> crates/ghostty-macos/src/app/app_delegate.rs:1199:30
|
1199 | NSSound::alloc(),
| ^^^^^ associated function or constant cannot be called on `NSSound` due to unsatisfied trait bounds
|
::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/macros/extern_class.rs:251:9
|
251 | / $v struct $class $(<$($generic $(: $($bounds)+)? $(= $default)?),*>)? {
252 | | __superclass: $crate::__fallback_if_not_set! {
253 | | ($($superclass)?)
... |
261 | | $(__generics: $crate::__macro_helpers::PhantomData<($(*mut $generic),+)>,)?
262 | | }
| |_________- doesn't satisfy `<_ as ClassType>::ThreadKind = dyn MainThreadOnly` or `NSSound: MainThreadOnly`
|
= note: the following trait bounds were not satisfied:
`<NSSound as ClassType>::ThreadKind = dyn MainThreadOnly`
which is required by `NSSound: MainThreadOnly`
`<&NSSound as ClassType>::ThreadKind = dyn MainThreadOnly`
which is required by `&NSSound: MainThreadOnly`
`&NSSound: ClassType`
which is required by `&NSSound: MainThreadOnly`
`<&mut NSSound as ClassType>::ThreadKind = dyn MainThreadOnly`
which is required by `&mut NSSound: MainThreadOnly`
`&mut NSSound: ClassType`
which is required by `&mut NSSound: MainThreadOnly`
= help: items from traits can only be used if the trait is in scope
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
16 + use objc2::AnyThread;
|
error[E0599]: no method named `exec_after` found for reference `&'static DispatchQueue` in the current scope
--> crates/ghostty-macos/src/app/app_delegate.rs:1389:39
|
1389 | DispatchQueue::main().exec_after(
| ----------------------^^^^^^^^^^
|
help: there is a method `after` with a similar name
|
1389 - DispatchQueue::main().exec_after(
1389 + DispatchQueue::main().after(
|
error[E0599]: no method named `mtm` found for reference `&AppDelegate` in the current scope
--> crates/ghostty-macos/src/app/app_delegate_ghostty.rs:17:61
|
17 | for window in NSApplication::sharedApplication(self.mtm()).windows() {
| ^^^ method not found in `&AppDelegate`
|
= help: items from traits can only be used if the trait is in scope
help: trait `MainThreadOnly` which provides `mtm` is implemented but not in scope; perhaps you want to import it
|
5 + use objc2::MainThreadOnly;
|
error[E0599]: no method named `surface_tree` found for struct `Retained<BaseTerminalController>` in the current scope
--> crates/ghostty-macos/src/app/app_delegate_ghostty.rs:25:39
|
25 | for surface in controller.surface_tree() {
| ^^^^^^^^^^^^
|
help: there is a method `surface_tree_is_split` with a similar name
|
25 | for surface in controller.surface_tree_is_split() {
| +++++++++
error[E0599]: no associated function or constant named `alloc` found for struct `NSMenu` in the current scope
--> crates/ghostty-macos/src/app/main_menu.rs:62:46
|
62 | let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str(spec.title));
| ^^^^^ associated function or constant not found in `NSMenu`
|
= help: items from traits can only be used if the trait is in scope
help: trait `MainThreadOnly` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
22 + use objc2::MainThreadOnly;
|
error[E0599]: no associated function or constant named `alloc` found for struct `NSMenuItem` in the current scope
--> crates/ghostty-macos/src/app/main_menu.rs:74:33
|
74 | NSMenuItem::alloc(mtm),
| ^^^^^ associated function or constant not found in `NSMenuItem`
|
= help: items from traits can only be used if the trait is in scope
help: trait `MainThreadOnly` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
22 + use objc2::MainThreadOnly;
|
error[E0599]: no method named `ivars` found for struct `PartialInit<T>` in the current scope
--> crates/ghostty-macos/src/features/quick_terminal/controller.rs:62:14
|
62 | this.ivars().position.set(position);
| ^^^^^ method not found in `PartialInit<QuickTerminalController>`
error[E0308]: mismatched types
--> crates/ghostty-macos/src/ghostty/app.rs:103:45
|
103 | unsafe { self.center.removeObserver(&self.token) };
| -------------- ^^^^^^^^^^^ expected `&AnyObject`, found `&Retained<ProtocolObject<_>>`
| |
| arguments to this method are incorrect
|
= note: expected reference `&AnyObject`
found reference `&Retained<ProtocolObject<(dyn NSObjectProtocol + 'static)>>`
note: method defined here
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-foundation-0.3.2/src/generated/NSNotification.rs:188:23
|
188 | pub unsafe fn removeObserver(&self, observer: &AnyObject);
| ^^^^^^^^^^^^^^
error[E0599]: no associated function or constant named `alloc` found for struct `NSUUID` in the current scope
--> crates/ghostty-macos/src/ghostty/app.rs:554:40
|
554 | NSUUID::initWithUUIDString(NSUUID::alloc(), &s)
| ^^^^^ associated function or constant not found in `NSUUID`
|
= help: items from traits can only be used if the trait is in scope
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
20 + use objc2::AnyThread;
|
error[E0599]: no associated function or constant named `class` found for struct `NSURL` in the current scope
--> crates/ghostty-macos/src/ghostty/app.rs:660:48
|
660 | let classes = NSArray::from_slice(&[NSURL::class()]);
| ^^^^^ associated function or constant not found in `NSURL`
|
= help: items from traits can only be used if the trait is in scope
help: trait `ClassType` which provides `class` is implemented but not in scope; perhaps you want to import it
|
20 + use objc2::ClassType;
|
help: the function `class` is implemented on `AnyObject`
|
660 - let classes = NSArray::from_slice(&[NSURL::class()]);
660 + let classes = NSArray::from_slice(&[AnyObject::class()]);
|
error[E0599]: no method named `copy` found for reference `&NSString` in the current scope
--> crates/ghostty-macos/src/ghostty/app.rs:690:50
|
690 | return unsafe { NSPasteboardTypeString }.copy();
| ^^^^ method not found in `&NSString`
|
= help: items from traits can only be used if the trait is in scope
help: trait `NSCopying` which provides `copy` is implemented but not in scope; perhaps you want to import it
|
20 + use objc2_foundation::NSCopying;
|
error[E0061]: this method takes 0 arguments but 1 argument was supplied
--> crates/ghostty-macos/src/ghostty/app.rs:938:18
|
938 | delegate.check_for_updates(None);
| ^^^^^^^^^^^^^^^^^ ---- unexpected argument of type `Option<_>`
|
note: method defined here
--> crates/ghostty-macos/src/app/app_delegate.rs:1437:12
|
1437 | pub fn check_for_updates(&self) {
| ^^^^^^^^^^^^^^^^^
help: remove the extra argument
|
938 - delegate.check_for_updates(None);
938 + delegate.check_for_updates();
|
error[E0308]: mismatched types
--> crates/ghostty-macos/src/ghostty/app.rs:990:31
|
988 | fn undo_manager_for(target: &ghostty_target_s) -> Option<Retained<objc2_foundation::NSUndoManager>> {
| ------------------------------------------------- expected `Option<Retained<NSUndoManager>>` because of return type
989 | match target.tag {
990 | GHOSTTY_TARGET_APP => app_delegate().map(|d| d.undo_manager()),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Option<Retained<NSUndoManager>>`, found `Option<Retained<ExpiringUndoManager>>`
|
= note: expected enum `Option<Retained<NSUndoManager>>`
found enum `Option<Retained<ExpiringUndoManager>>`
error[E0061]: this method takes 0 arguments but 1 argument was supplied
--> crates/ghostty-macos/src/ghostty/app.rs:1136:14
|
1136 | delegate.close_all_windows(None);
| ^^^^^^^^^^^^^^^^^ ---- unexpected argument of type `Option<_>`
|
note: method defined here
--> crates/ghostty-macos/src/app/app_delegate.rs:1442:12
|
1442 | pub fn close_all_windows(&self) {
| ^^^^^^^^^^^^^^^^^
help: remove the extra argument
|
1136 - delegate.close_all_windows(None);
1136 + delegate.close_all_windows();
|
error[E0061]: this method takes 0 arguments but 1 argument was supplied
--> crates/ghostty-macos/src/ghostty/app.rs:1164:14
|
1164 | delegate.toggle_visibility(None);
| ^^^^^^^^^^^^^^^^^ ---- unexpected argument of type `Option<_>`
|
note: method defined here
--> crates/ghostty-macos/src/app/app_delegate.rs:1453:12
|
1453 | pub fn toggle_visibility(&self) {
| ^^^^^^^^^^^^^^^^^
help: remove the extra argument
|
1164 - delegate.toggle_visibility(None);
1164 + delegate.toggle_visibility();
|
error[E0061]: this method takes 0 arguments but 1 argument was supplied
--> crates/ghostty-macos/src/ghostty/app.rs:1499:14
|
1499 | delegate.toggle_quick_terminal(None);
| ^^^^^^^^^^^^^^^^^^^^^ ---- unexpected argument of type `Option<_>`
|
note: method defined here
--> crates/ghostty-macos/src/app/app_delegate.rs:1448:12
|
1448 | pub fn toggle_quick_terminal(&self) {
| ^^^^^^^^^^^^^^^^^^^^^
help: remove the extra argument
|
1499 - delegate.toggle_quick_terminal(None);
1499 + delegate.toggle_quick_terminal();
|
error[E0308]: mismatched types
--> crates/ghostty-macos/src/ghostty/surface_view.rs:514:13
|
512 | let user_info: Retained<NSDictionary<NSString, AnyObject>> = NSDictionary::from_retained_objects(
| ----------------------------------- arguments to this function are incorrect
513 | &[&*surface_key, &*focus_key],
514 | vec![surface_val, focus_val],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `&[Retained<AnyObject>]`, found `Vec<Retained<AnyObject>>`
|
= note: expected reference `&[Retained<AnyObject>]`
found struct `Vec<Retained<AnyObject>>`
note: associated function defined here
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-foundation-0.3.2/src/dictionary.rs:100:12
|
100 | pub fn from_retained_objects<CopiedKey>(
| ^^^^^^^^^^^^^^^^^^^^^
error[E0308]: mismatched types
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:22:14
|
20 | fn cg_image(image: &NSImage) -> Option<objc2_core_foundation::CFRetained<objc2_core_graphics::CGImage>> {
| ----------------------------------------------------------------------- expected `Option<CFRetained<CGImage>>` because of return type
21 | // SAFETY: nil rect/context/hints are allowed.
22 | unsafe { image.CGImageForProposedRect_context_hints(std::ptr::null_mut(), None, None) }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Option<CFRetained<CGImage>>`, found `Option<Retained<CGImage>>`
|
= note: expected enum `Option<CFRetained<CGImage>>`
found enum `Option<Retained<CGImage>>`
warning: use of deprecated method `objc2_app_kit::NSImage::lockFocus`: This method is incompatible with resolution-independent drawing and should not be used. Instead, use +[NSImage imageWithSize:flipped:drawingHandler:] to create a block-based image describing the desired image drawing, or use +[NSGraphicsContext graphicsContextWithBitmapImageRep:] to manipulate specific bitmap image representations.
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:82:16
|
82 | result.lockFocus();
| ^^^^^^^^^
|
= note: `#[warn(deprecated)]` on by default
warning: use of deprecated method `objc2_app_kit::NSImage::unlockFocus`: This method is incompatible with resolution-independent drawing and should not be used. Instead, use +[NSImage imageWithSize:flipped:drawingHandler:] to create a block-based image describing the desired image drawing, or use +[NSGraphicsContext graphicsContextWithBitmapImageRep:] to manipulate specific bitmap image representations.
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:98:16
|
98 | result.unlockFocus();
| ^^^^^^^^^^^
warning: use of deprecated method `objc2_app_kit::NSImage::lockFocus`: This method is incompatible with resolution-independent drawing and should not be used. Instead, use +[NSImage imageWithSize:flipped:drawingHandler:] to create a block-based image describing the desired image drawing, or use +[NSGraphicsContext graphicsContextWithBitmapImageRep:] to manipulate specific bitmap image representations.
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:109:19
|
109 | new_image.lockFocus();
| ^^^^^^^^^
warning: use of deprecated method `objc2_app_kit::NSImage::unlockFocus`: This method is incompatible with resolution-independent drawing and should not be used. Instead, use +[NSImage imageWithSize:flipped:drawingHandler:] to create a block-based image describing the desired image drawing, or use +[NSGraphicsContext graphicsContextWithBitmapImageRep:] to manipulate specific bitmap image representations.
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:129:19
|
129 | new_image.unlockFocus();
| ^^^^^^^^^^^
error[E0599]: no method named `usingColorSpace` found for struct `Retained<NSColor>` in the current scope
--> crates/ghostty-macos/src/helpers/extensions/ns_view.rs:198:27
|
198 | c.usingColorSpace(&objc2_app_kit::NSColorSpace::deviceRGBColorSpace())
| ^^^^^^^^^^^^^^^
|
help: there is a method `colorSpace` with a similar name, but with different arguments
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/macros/extern_methods.rs:267:14
|
267 | ($v fn $fn_name($($params)*) $(-> $ret)?)
| ^^
|
::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-app-kit-0.3.2/src/generated/NSColor.rs:112:5
|
112 | / extern_methods!(
113 | | #[unsafe(method(init))]
114 | | #[unsafe(method_family = init)]
115 | | pub fn init(this: Allocated<Self>) -> Retained<Self>;
... |
901 | | pub fn setIgnoresAlpha(ignores_alpha: bool, mtm: MainThreadMarker);
902 | | );
| |_____- in this macro invocation
= note: this error originates in the macro `$crate::extern_methods` which comes from the expansion of the macro `extern_methods` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0599]: no associated function or constant named `alloc` found for struct `NSUserDefaults` in the current scope
--> crates/ghostty-macos/src/helpers/extensions/user_defaults.rs:18:63
|
18 | NSUserDefaults::initWithSuiteName(NSUserDefaults::alloc(), Some(&NSString::from_str(&suite)))
| ^^^^^ associated function or constant not found in `NSUserDefaults`
|
= help: items from traits can only be used if the trait is in scope
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
3 + use objc2::AnyThread;
|
error[E0599]: no associated function or constant named `alloc` found for struct `NSUUID` in the current scope
--> crates/ghostty-macos/src/helpers/extensions/uuid.rs:11:40
|
11 | NSUUID::initWithUUIDString(NSUUID::alloc(), &NSString::from_str(&s.to_string()))
| ^^^^^ associated function or constant not found in `NSUUID`
|
= help: items from traits can only be used if the trait is in scope
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
3 + use objc2::AnyThread;
|
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeLeftCursor`: Use either `+[NSCursor columnResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
--> crates/ghostty-macos/src/helpers/cursor.rs:82:43
|
82 | Self::ResizeLeft => NSCursor::resizeLeftCursor(),
| ^^^^^^^^^^^^^^^^
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeRightCursor`: Use either `+[NSCursor columnResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
--> crates/ghostty-macos/src/helpers/cursor.rs:86:44
|
86 | Self::ResizeRight => NSCursor::resizeRightCursor(),
| ^^^^^^^^^^^^^^^^^
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeUpCursor`: Use either `+[NSCursor rowResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
--> crates/ghostty-macos/src/helpers/cursor.rs:90:41
|
90 | Self::ResizeUp => NSCursor::resizeUpCursor(),
| ^^^^^^^^^^^^^^
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeDownCursor`: Use either `+[NSCursor rowResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
--> crates/ghostty-macos/src/helpers/cursor.rs:94:43
|
94 | Self::ResizeDown => NSCursor::resizeDownCursor(),
| ^^^^^^^^^^^^^^^^
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeUpDownCursor`: Use either `+[NSCursor rowResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
--> crates/ghostty-macos/src/helpers/cursor.rs:96:45
|
96 | Self::ResizeUpDown => NSCursor::resizeUpDownCursor(),
| ^^^^^^^^^^^^^^^^^^
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeLeftRightCursor`: Use either `+[NSCursor columnResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
--> crates/ghostty-macos/src/helpers/cursor.rs:98:48
|
98 | Self::ResizeLeftRight => NSCursor::resizeLeftRightCursor(),
| ^^^^^^^^^^^^^^^^^^^^^
error[E0599]: the associated function or constant `alloc` exists for struct `ExpiringTarget`, but its trait bounds were not satisfied
--> crates/ghostty-macos/src/helpers/expiring_undo_manager.rs:74:26
|
56 | / define_class!(
57 | | /// A target object for ExpiringUndoManager that removes itself from the
58 | | /// undo manager after it expires.
59 | | ///
... |
69 | | struct ExpiringTarget;
70 | | );
| |_- associated function or constant `alloc` not found for this struct because it doesn't satisfy `<_ as ClassType>::ThreadKind = dyn MainThreadOnly` or `ExpiringTarget: MainThreadOnly`
...
74 | let this = Self::alloc().set_ivars(TargetIvars {
| ^^^^^ associated function or constant cannot be called on `ExpiringTarget` due to unsatisfied trait bounds
|
= note: the following trait bounds were not satisfied:
`<ExpiringTarget as ClassType>::ThreadKind = dyn MainThreadOnly`
which is required by `ExpiringTarget: MainThreadOnly`
`<&ExpiringTarget as ClassType>::ThreadKind = dyn MainThreadOnly`
which is required by `&ExpiringTarget: MainThreadOnly`
`&ExpiringTarget: ClassType`
which is required by `&ExpiringTarget: MainThreadOnly`
`<&mut ExpiringTarget as ClassType>::ThreadKind = dyn MainThreadOnly`
which is required by `&mut ExpiringTarget: MainThreadOnly`
`&mut ExpiringTarget: ClassType`
which is required by `&mut ExpiringTarget: MainThreadOnly`
= help: items from traits can only be used if the trait is in scope
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
25 + use objc2::AnyThread;
|
error[E0599]: no associated function or constant named `ptr_eq` found for struct `Retained<_>` in the current scope
--> crates/ghostty-macos/src/helpers/expiring_undo_manager.rs:191:86
|
191 | self.ivars().expiring_targets.borrow_mut().retain(|x| !Retained::ptr_eq(x, &e));
| ^^^^^^ associated function or constant not found in `Retained<_>`
|
note: if you're trying to build a new `Retained<_>` consider using one of the following associated functions:
Retained::<T>::from_raw
Retained::<T>::new
Retained::<T>::cast_unchecked
Retained::<T>::cast
and 2 others
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/rc/retained.rs:215:5
|
215 | pub unsafe fn from_raw(ptr: *mut T) -> Option<Self> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
229 | pub unsafe fn new(ptr: *mut T) -> Option<Self> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
381 | pub unsafe fn cast_unchecked<U: Message>(this: Self) -> Retained<U> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
396 | pub unsafe fn cast<U: Message>(this: Self) -> Retained<U> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0599]: no method named `retain` found for reference `&NSWindow` in the current scope
--> crates/ghostty-macos/src/helpers/fullscreen.rs:96:28
|
96 | window: window.retain(),
| ^^^^^^
|
= help: items from traits can only be used if the trait is in scope
help: trait `Message` which provides `retain` is implemented but not in scope; perhaps you want to import it
|
8 + use objc2::Message;
|
help: there is a method `retainCount` with a similar name
|
96 | window: window.retainCount(),
| +++++
error[E0599]: no associated function or constant named `ptr_eq` found for struct `Retained<_>` in the current scope
--> crates/ghostty-macos/src/helpers/fullscreen.rs:458:94
|
458 | let present = window.titlebarAccessoryViewControllers().iter().any(|x| Retained::ptr_eq(&x, c));
| ^^^^^^ associated function or constant not found in `Retained<_>`
|
note: if you're trying to build a new `Retained<_>` consider using one of the following associated functions:
Retained::<T>::from_raw
Retained::<T>::new
Retained::<T>::cast_unchecked
Retained::<T>::cast
and 2 others
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/rc/retained.rs:215:5
|
215 | pub unsafe fn from_raw(ptr: *mut T) -> Option<Self> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
229 | pub unsafe fn new(ptr: *mut T) -> Option<Self> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
381 | pub unsafe fn cast_unchecked<U: Message>(this: Self) -> Retained<U> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
396 | pub unsafe fn cast<U: Message>(this: Self) -> Retained<U> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
warning: unused import: `NSUserInterfaceItemSearching`
--> crates/ghostty-macos/src/app/app_delegate.rs:31:52
|
31 | NSWindowLevel, NSWindowStyleMask, NSWorkspace, NSUserInterfaceItemSearching,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0133]: call to unsafe function `NSEvent::addLocalMonitorForEventsMatchingMask_handler` is unsafe and requires unsafe block
--> crates/ghostty-macos/src/app/app_delegate.rs:973:23
|
973 | let monitor = NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &handler);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
error[E0133]: call to unsafe function `NSMenuItem::initWithTitle_action_keyEquivalent` is unsafe and requires unsafe block
--> crates/ghostty-macos/src/app/app_delegate.rs:1515:24
|
1515 | let item = NSMenuItem::initWithTitle_action_keyEquivalent(
| ________________________^
1516 | | NSMenuItem::alloc(mtm),
1517 | | &NSString::from_str(title),
1518 | | Some(main_menu::selector(selector)),
1519 | | &NSString::from_str(""),
1520 | | );
| |_____________^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
error[E0133]: call to unsafe function `NSMenuItem::menu` is unsafe and requires unsafe block
--> crates/ghostty-macos/src/app/app_delegate.rs:1615:33
|
1615 | let Some(parent_menu) = item.menu() else { return false };
| ^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
warning: unnecessary `unsafe` block
--> crates/ghostty-macos/src/ghostty/app.rs:340:38
|
340 | let s = AllocatedString::new(unsafe { ghostty_config_open_path() }).string();
| ^^^^^^ unnecessary `unsafe` block
|
= note: `#[warn(unused_unsafe)]` (part of `#[warn(unused)]`) on by default
warning: unnecessary `unsafe` block
--> crates/ghostty-macos/src/ghostty/app.rs:482:25
|
482 | let user_info = unsafe { notification.request().content().userInfo() };
| ^^^^^^ unnecessary `unsafe` block
warning: unnecessary `unsafe` block
--> crates/ghostty-macos/src/ghostty/app.rs:506:60
|
506 | let (user_info, action_identifier, notification) = unsafe {
| ^^^^^^ unnecessary `unsafe` block
warning: unnecessary `unsafe` block
--> crates/ghostty-macos/src/ghostty/app.rs:1453:30
|
1453 | let (floating, normal) = unsafe { (NSFloatingWindowLevel, NSNormalWindowLevel) };
| ^^^^^^ unnecessary `unsafe` block
warning: unnecessary `unsafe` block
--> crates/ghostty-macos/src/ghostty/surface_view.rs:577:18
|
577 | let id = unsafe { notification.request().identifier() }.to_string();
| ^^^^^^ unnecessary `unsafe` block
error[E0133]: call to unsafe function `NSResponder::nextResponder` is unsafe and requires unsafe block
--> crates/ghostty-macos/src/helpers/extensions/ns_view.rs:63:25
|
63 | responder = current.nextResponder();
| ^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
error[E0133]: call to unsafe function `NSView::superview` is unsafe and requires unsafe block
--> crates/ghostty-macos/src/helpers/extensions/ns_view.rs:80:37
|
80 | while let Some(superview) = root.superview() {
| ^^^^^^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
error[E0133]: call to unsafe function `NSView::superview` is unsafe and requires unsafe block
--> crates/ghostty-macos/src/helpers/extensions/ns_view.rs:101:25
|
101 | let superview = self.superview()?;
| ^^^^^^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
error[E0133]: call to unsafe function `NSCoder::decodeObjectOfClass_forKey` is unsafe and requires unsafe block
--> crates/ghostty-macos/src/helpers/codable_bridge.rs:33:34
|
33 | let data: Retained<NSData> = coder.decodeObjectOfClass_forKey(NSData::class(), &NSString::from_str(DATA_KEY))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
Some errors have detailed explanations: E0061, E0133, E0308, E0425, E0432, E0599.
For more information about an error, try `rustc --explain E0061`.
warning: `ghostty-macos` (lib) generated 19 warnings
error: could not compile `ghostty-macos` (lib) due to 52 previous errors; 19 warnings emitted
warning: build failed, waiting for other jobs to finish...
warning: `ghostty-macos` (lib test) generated 19 warnings (17 duplicates)
error: could not compile `ghostty-macos` (lib test) due to 52 previous errors; 19 warnings emitted
+1935
View File
@@ -0,0 +1,1935 @@
//! `AppDelegate.swift`: the application delegate.
//!
//! Mapping notes (see `docs/rewrite/notes/macos-app-design.md`):
//! * the `@IBOutlet private var menuXxx` properties are the [`Outlet`] map in
//! [`Ivars`], filled by `app::main_menu::install`;
//! * `@Published appIcon`, `ObservableObject` conformance: not needed (the only
//! consumers are SwiftUI views, which are ported programmatically);
//! * `NotificationCenter.default.addObserver(self, selector:)` becomes
//! block-based observers whose tokens live in [`Ivars::observers`];
//! * `[weak self]` closures capture an `objc2::rc::Weak<AppDelegate>`;
//! * `@IBAction`s are Objective-C methods with the same selectors so the
//! responder chain / menu targets resolve them exactly like in the nib.
#![allow(unsafe_code)]
use std::{
cell::{Cell, RefCell},
collections::HashMap,
ffi::CString,
ptr::NonNull,
rc::Rc,
};
use block2::{DynBlock, RcBlock};
use dispatch2::{DispatchObject, DispatchQueue, DispatchRetained, DispatchSource};
use objc2::{
AnyThread,
DefinedClass,
MainThreadMarker,
MainThreadOnly,
Message,
define_class,
msg_send,
rc::{Retained, Weak},
runtime::{AnyObject, ProtocolObject},
sel,
};
use objc2_app_kit::{
NSAlert,
NSAlertFirstButtonReturn,
NSAlertStyle,
NSApplication,
NSApplicationActivationPolicy,
NSApplicationDelegate,
NSApplicationTerminateReply,
NSEvent,
NSEventMask,
NSEventModifierFlags,
NSEventType,
NSMenu,
NSMenuItem,
NSMenuItemValidation,
NSModalResponse,
NSSound,
NSWindow,
NSWindowLevel,
NSWindowStyleMask,
NSWorkspace,
};
use objc2_foundation::{
NSArray,
NSCoder,
NSDictionary,
NSKeyValueChangeKey,
NSNotification,
NSObject,
NSObjectProtocol,
NSProcessInfo,
NSSet,
NSString,
NSURL,
NSUUID,
};
use objc2_user_notifications::{
UNAuthorizationOptions,
UNAuthorizationStatus,
UNNotification,
UNNotificationAction,
UNNotificationActionOptions,
UNNotificationCategory,
UNNotificationCategoryOptions,
UNNotificationPresentationOptions,
UNNotificationResponse,
UNNotificationSetting,
UNUserNotificationCenter,
UNUserNotificationCenterDelegate,
};
use super::{
main_menu::{self, Outlets},
menu_spec::{DOCK_MENU, MENU_IMAGES, MenuShortcutKey, Outlet, SYNCED_SHORTCUTS},
};
use crate::{
features::{
about::AboutController,
global_keybinds::GlobalEventTap,
quick_terminal::{
QUICK_TERMINAL_DID_CHANGE_VISIBILITY,
QuickTerminalPosition,
controller::{QuickTerminalController, QuickTerminalRestorableState},
},
secure_input::SecureInput,
services::ServiceProvider,
settings::ConfigurationErrorsController,
terminal::{BaseTerminalController, TerminalController, TerminalWindow},
update::UpdateController,
},
ghostty::{
app::{App, Readiness},
capi::{
consts::{GHOSTTY_ACTION_PRESS, GHOSTTY_COLOR_SCHEME_DARK, GHOSTTY_COLOR_SCHEME_LIGHT},
ghostty_app_has_global_keybinds,
ghostty_app_key,
ghostty_app_key_is_binding,
ghostty_app_set_color_scheme,
},
config::Config,
config_types::{AutoUpdate, BellFeatures, MacDockDropBehavior, MacHidden},
notification as gn,
package::{LaunchSource, SetSecureInput, launch_source},
shell,
surface_configuration::SurfaceConfiguration,
surface_view::SurfaceView,
},
helpers::{
extensions::{
ns_menu_item::NSMenuItemExt,
ns_notification::{self, NsObserver},
ns_workspace::NSWorkspaceExt,
user_defaults,
},
notification_center::{Notification, NotificationCenter, ObserverToken},
},
};
/// `DerivedConfig`: the parts of the config the delegate caches.
#[derive(Clone, Copy)]
struct DerivedConfig {
initial_window: bool,
should_quit_after_last_window_closed: bool,
quick_terminal_position: QuickTerminalPosition,
}
impl Default for DerivedConfig {
fn default() -> Self {
Self {
initial_window: true,
should_quit_after_last_window_closed: false,
quick_terminal_position: QuickTerminalPosition::Top,
}
}
}
impl DerivedConfig {
fn new(config: &Config) -> Self {
Self {
initial_window: config.initial_window(),
should_quit_after_last_window_closed: config.should_quit_after_last_window_closed(),
quick_terminal_position: config.quick_terminal_position(),
}
}
}
/// `QuickTerminalState`.
enum QuickTerminalState {
/// Controller has not been initialized and has no pending restoration state.
Uninitialized,
/// Restoration state is pending; controller will use this when first accessed.
/// (Set by `application(_:didDecodeRestorableState:)`; TODO(MA4).)
#[allow(dead_code)]
PendingRestore(QuickTerminalRestorableState),
/// Controller has been initialized.
Initialized(Retained<QuickTerminalController>),
}
/// `AppDelegate.ToggleVisibilityState`.
pub struct ToggleVisibilityState {
hidden_windows: Vec<Weak<NSWindow>>,
key_window: Option<Weak<NSWindow>>,
}
impl ToggleVisibilityState {
fn new(mtm: MainThreadMarker) -> Self {
let app = NSApplication::sharedApplication(mtm);
// We need to know the key window so that we can bring focus back to the
// right window if it was hidden.
let key_window = app.keyWindow().map(|w| Weak::from_retained(&w));
// We need to keep track of the windows that were visible because we only
// want to bring back these windows if we remove the toggle.
//
// We also ignore fullscreen windows because they don't hide anyways.
let mut visible: Vec<Retained<NSWindow>> = Vec::new();
for window in app.windows() {
if !window.isVisible() || window.styleMask().contains(NSWindowStyleMask::FullScreen) {
continue;
}
// We only keep track of selectedWindow if it's in a tabGroup,
// so we can keep its selection state when restoring
let to_hide = window
.tabGroup()
.and_then(|g| g.selectedWindow())
.unwrap_or(window);
if !visible
.iter()
.any(|w| Retained::as_ptr(w) == Retained::as_ptr(&to_hide))
{
visible.push(to_hide);
}
}
Self {
hidden_windows: visible.iter().map(Weak::from_retained).collect(),
key_window,
}
}
fn restore(&self) {
for w in &self.hidden_windows {
if let Some(w) = w.load() {
w.orderFrontRegardless();
}
}
if let Some(w) = self.key_window.as_ref().and_then(Weak::load) {
w.makeKeyWindow();
}
}
}
/// Instance variables of [`AppDelegate`].
pub struct Ivars {
/// The ghostty global state. Only one per process.
ghostty: Rc<App>,
/// The `@IBOutlet` menu items.
outlets: RefCell<HashMap<Outlet, Retained<NSMenuItem>>>,
services_menu: RefCell<Option<Retained<NSMenu>>>,
/// The dock menu.
dock_menu: Retained<NSMenu>,
/// This is only true before application has become active.
application_has_become_active: Cell<bool>,
/// Set in applicationDidFinishLaunching with the system uptime.
application_launch_time: Cell<f64>,
derived_config: Cell<DerivedConfig>,
/// The global undo manager for app-level state such as window restoration.
undo_manager: Retained<crate::helpers::expiring_undo_manager::ExpiringUndoManager>,
quick_terminal_state: RefCell<QuickTerminalState>,
/// Manages updates.
update_controller: UpdateController,
/// Tracks the windows that we hid for toggleVisibility.
hidden_state: RefCell<Option<ToggleVisibilityState>>,
/// Signals.
signals: RefCell<Vec<DispatchRetained<DispatchSource>>>,
/// Ghostty menu items indexed by their normalized shortcut. This avoids
/// traversing the entire menu tree on every key equivalent event. Weak so the
/// cache can never own menu items; the most recent item wins.
menu_items_by_shortcut: RefCell<HashMap<MenuShortcutKey, Weak<NSMenuItem>>>,
/// Observation tokens (NotificationCenter observers).
observers: RefCell<Vec<NsObserver>>,
bus_observers: RefCell<Vec<ObserverToken>>,
local_monitor: RefCell<Option<Retained<AnyObject>>>,
}
define_class!(
// SAFETY: NSObject has no subclassing requirements; AppDelegate does not
// implement Drop.
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "AppDelegate"]
#[ivars = Ivars]
pub struct AppDelegate;
// SAFETY: NSObjectProtocol has no requirements.
unsafe impl NSObjectProtocol for AppDelegate {}
// SAFETY: the protocol methods are implemented in the inherent block below
// with the protocol's selectors/signatures.
unsafe impl NSApplicationDelegate for AppDelegate {}
// SAFETY: as above.
unsafe impl UNUserNotificationCenterDelegate for AppDelegate {}
// SAFETY: as above.
unsafe impl NSMenuItemValidation for AppDelegate {}
impl AppDelegate {
// MARK: - NSApplicationDelegate
#[unsafe(method(applicationWillFinishLaunching:))]
fn application_will_finish_launching(&self, _notification: &NSNotification) {
if cfg!(debug_assertions) {
if user_defaults::ghostty_suite().is_some() {
let clear = std::env::var("GHOSTTY_CLEAR_USER_DEFAULTS").ok();
if clear.as_deref().is_some_and(objc_bool_value) {
super::support::remove_persistent_domain_for_ghostty_suite();
}
}
}
super::support::register_defaults(&[
// Disable the automatic full screen menu item because we handle
// it manually.
("NSFullScreenMenuItemEverywhere", false),
// On macOS 26 RC1, the autofill heuristic controller causes unusable levels
// of slowdowns and CPU usage in the terminal window under certain [unknown]
// conditions. We don't know exactly why/how. This disables the full heuristic
// controller.
//
// Practically, this means things like SMS autofill don't work, but that is
// a desirable behavior to NOT have happen for a terminal, so this is a win.
// Manual autofill via the `Edit => AutoFill` menu item still work as expected.
("NSAutoFillHeuristicControllerEnabled", false),
]);
}
#[unsafe(method(applicationDidFinishLaunching:))]
fn application_did_finish_launching(&self, _notification: &NSNotification) {
self.did_finish_launching();
}
#[unsafe(method(applicationDidHide:))]
fn application_did_hide(&self, _notification: &NSNotification) {
// Keep track of our hidden state to restore properly
*self.ivars().hidden_state.borrow_mut() = Some(ToggleVisibilityState::new(self.mtm()));
}
#[unsafe(method(applicationDidBecomeActive:))]
fn application_did_become_active(&self, _notification: &NSNotification) {
self.did_become_active();
}
#[unsafe(method(applicationShouldTerminateAfterLastWindowClosed:))]
fn application_should_terminate_after_last_window_closed(&self, _sender: &NSApplication) -> bool {
self.ivars().derived_config.get().should_quit_after_last_window_closed
}
#[unsafe(method(applicationShouldTerminate:))]
fn application_should_terminate(&self, sender: &NSApplication) -> NSApplicationTerminateReply {
self.should_terminate(sender)
}
#[unsafe(method(applicationWillTerminate:))]
fn application_will_terminate(&self, _notification: &NSNotification) {
// We have no notifications we want to persist after death,
// so remove them all now. In the future we may want to be
// more selective and only remove surface-targeted notifications.
UNUserNotificationCenter::currentNotificationCenter().removeAllDeliveredNotifications();
}
/// This is called when the application is already open and someone
/// double-clicks the icon or clicks the dock icon.
#[unsafe(method(applicationShouldHandleReopen:hasVisibleWindows:))]
fn application_should_handle_reopen(&self, _sender: &NSApplication, flag: bool) -> bool {
self.should_handle_reopen(flag)
}
#[unsafe(method(application:openFile:))]
fn application_open_file(&self, _sender: &NSApplication, filename: &NSString) -> bool {
self.open_file(&filename.to_string())
}
// MARK: Restorable state
/// We support NSSecureCoding for restorable state. Required as of macOS
/// Sonoma (14) but a good idea anyways.
#[unsafe(method(applicationSupportsSecureRestorableState:))]
fn application_supports_secure_restorable_state(&self, _app: &NSApplication) -> bool {
true
}
#[unsafe(method(application:willEncodeRestorableState:))]
fn application_will_encode_restorable_state(&self, _app: &NSApplication, _coder: &NSCoder) {
log::debug!("application will save window state");
if self.ivars().ghostty.config().window_save_state() == "never" {
return;
}
// TODO(MA4): encode the quick terminal state
// (QuickTerminalRestorableState.encode(with:)).
match &*self.ivars().quick_terminal_state.borrow() {
QuickTerminalState::Initialized(c) if c.restorable() => {
log::warn!("TODO(MA4): encode quick terminal restorable state");
}
_ => {}
}
}
#[unsafe(method(application:didDecodeRestorableState:))]
fn application_did_decode_restorable_state(&self, _app: &NSApplication, _coder: &NSCoder) {
log::debug!("application will restore window state");
// TODO(MA4): decode QuickTerminalRestorableState(coder:) and, if present
// and window-save-state != never, set `.pendingRestore(state)`.
}
#[unsafe(method_id(applicationDockMenu:))]
fn application_dock_menu(&self, _sender: &NSApplication) -> Option<Retained<NSMenu>> {
Some(self.ivars().dock_menu.clone())
}
// MARK: - UNUserNotificationCenterDelegate
#[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))]
fn user_notification_did_receive(
&self,
_center: &UNUserNotificationCenter,
response: &UNNotificationResponse,
completion: &DynBlock<dyn Fn()>,
) {
self.ivars().ghostty.handle_user_notification(response);
completion.call(());
}
#[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
fn user_notification_will_present(
&self,
_center: &UNUserNotificationCenter,
notification: &UNNotification,
completion: &DynBlock<dyn Fn(UNNotificationPresentationOptions)>,
) {
let should_present = self.ivars().ghostty.should_present_notification(notification);
let options = if should_present {
UNNotificationPresentationOptions::Banner | UNNotificationPresentationOptions::Sound
} else {
UNNotificationPresentationOptions::empty()
};
completion.call((options,));
}
// MARK: - KVO (NSApplication.effectiveAppearance)
#[unsafe(method(observeValueForKeyPath:ofObject:change:context:))]
fn observe_value(
&self,
key_path: Option<&NSString>,
_object: Option<&AnyObject>,
_change: Option<&NSDictionary<NSKeyValueChangeKey, AnyObject>>,
_context: *mut std::ffi::c_void,
) {
if key_path.is_some_and(|k| k.to_string() == "effectiveAppearance") {
self.report_color_scheme();
}
}
// MARK: - IB Actions
#[unsafe(method(openConfig:))]
fn open_config_action(&self, _sender: Option<&AnyObject>) {
App::open_config();
}
#[unsafe(method(reloadConfig:))]
fn reload_config_action(&self, _sender: Option<&AnyObject>) {
self.ivars().ghostty.reload_config(false);
}
#[unsafe(method(checkForUpdates:))]
fn check_for_updates_action(&self, _sender: Option<&AnyObject>) {
self.ivars().update_controller.check_for_updates();
// UpdateSimulator.happyPath.simulate(with: updateViewModel)
}
#[unsafe(method(newWindow:))]
fn new_window_action(&self, _sender: Option<&AnyObject>) {
let _ = TerminalController::new_window(&self.ivars().ghostty, None);
}
#[unsafe(method(newTab:))]
fn new_tab_action(&self, _sender: Option<&AnyObject>) {
let parent = TerminalController::preferred_parent().and_then(|c| c.window());
let _ = TerminalController::new_tab(&self.ivars().ghostty, parent.as_deref(), None);
}
#[unsafe(method(closeAllWindows:))]
fn close_all_windows_action(&self, _sender: Option<&AnyObject>) {
TerminalController::close_all_windows();
AboutController::shared().hide();
}
#[unsafe(method(showAbout:))]
fn show_about_action(&self, _sender: Option<&AnyObject>) {
AboutController::shared().show();
}
#[unsafe(method(showHelp:))]
fn show_help_action(&self, _sender: Option<&AnyObject>) {
if let Some(url) = NSURL::URLWithString(&NSString::from_str("https://ghostty.org/docs")) {
NSWorkspace::sharedWorkspace().openURL(&url);
}
}
#[unsafe(method(toggleSecureInput:))]
fn toggle_secure_input_action(&self, _sender: Option<&AnyObject>) {
self.set_secure_input(SetSecureInput::Toggle);
}
#[unsafe(method(toggleQuickTerminal:))]
fn toggle_quick_terminal_action(&self, _sender: Option<&AnyObject>) {
self.quick_controller().toggle();
}
/// Toggles visibility of all Ghosty Terminal windows. When hidden,
/// activates Ghostty as the frontmost application.
#[unsafe(method(toggleVisibility:))]
fn toggle_visibility_action(&self, _sender: Option<&AnyObject>) {
self.toggle_visibility();
}
#[unsafe(method(bringAllToFront:))]
fn bring_all_to_front_action(&self, sender: Option<&AnyObject>) {
let app = NSApplication::sharedApplication(self.mtm());
if !app.isActive() {
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
}
app.arrangeInFront(sender);
}
#[unsafe(method(undo:))]
fn undo_action(&self, _sender: Option<&AnyObject>) {
self.ivars().undo_manager.undo();
}
#[unsafe(method(redo:))]
fn redo_action(&self, _sender: Option<&AnyObject>) {
self.ivars().undo_manager.redo();
}
#[unsafe(method(floatOnTop:))]
fn float_on_top_action(&self, menu_item: &NSMenuItem) {
use objc2_app_kit::{NSControlStateValueOff, NSControlStateValueOn};
let on = menu_item.state() == NSControlStateValueOn;
menu_item.setState(if on { NSControlStateValueOff } else { NSControlStateValueOn });
let Some(window) = NSApplication::sharedApplication(self.mtm()).keyWindow() else { return };
window.setLevel(if menu_item.state() == NSControlStateValueOn {
FLOATING_WINDOW_LEVEL
} else {
NORMAL_WINDOW_LEVEL
});
}
#[unsafe(method(useAsDefault:))]
fn use_as_default_action(&self, _sender: &NSMenuItem) {
use objc2_app_kit::NSControlStateValueOn;
let ud = user_defaults::ghostty();
let key = NSString::from_str(TerminalWindow::DEFAULT_LEVEL_KEY);
let float_on = self
.menu_item(Outlet::MenuFloatOnTop)
.is_some_and(|i| i.state() == NSControlStateValueOn);
if float_on {
ud.setInteger_forKey(FLOATING_WINDOW_LEVEL, &key);
} else {
ud.removeObjectForKey(&key);
}
}
#[unsafe(method(setAsDefaultTerminal:))]
fn set_as_default_terminal_action(&self, _sender: &NSMenuItem) {
self.set_as_default_terminal();
}
// MARK: NSMenuItemValidation
#[unsafe(method(validateMenuItem:))]
fn validate_menu_item(&self, item: &NSMenuItem) -> bool {
self.validate(item)
}
// MARK: Services / dock helpers are in the inherent impl below.
}
);
const NORMAL_WINDOW_LEVEL: NSWindowLevel = 0;
const FLOATING_WINDOW_LEVEL: NSWindowLevel = 3;
/// `(clear as NSString).boolValue`.
fn objc_bool_value(s: &str) -> bool {
NSString::from_str(s).boolValue()
}
impl AppDelegate {
/// `override init()`: creates the Ghostty app and the delegate.
#[must_use]
pub fn new(mtm: MainThreadMarker) -> Retained<Self> {
let config_path = if cfg!(debug_assertions) {
std::env::var("GHOSTTY_CONFIG_PATH").ok()
} else {
None
};
let ghostty = App::new(config_path.as_deref());
let ivars = Ivars {
ghostty,
outlets: RefCell::default(),
services_menu: RefCell::default(),
dock_menu: NSMenu::new(mtm),
application_has_become_active: Cell::new(false),
application_launch_time: Cell::new(0.0),
derived_config: Cell::new(DerivedConfig::default()),
undo_manager: crate::helpers::expiring_undo_manager::ExpiringUndoManager::new(mtm),
quick_terminal_state: RefCell::new(QuickTerminalState::Uninitialized),
update_controller: UpdateController::new(),
hidden_state: RefCell::default(),
signals: RefCell::default(),
menu_items_by_shortcut: RefCell::default(),
observers: RefCell::default(),
bus_observers: RefCell::default(),
local_monitor: RefCell::default(),
};
let this = Self::alloc(mtm).set_ivars(ivars);
// SAFETY: `init` is NSObject's designated initializer.
let this: Retained<Self> = unsafe { msg_send![super(this), init] };
this.ivars()
.ghostty
.set_delegate(Weak::from_retained(&this));
this
}
/// `NSApp.delegate as? AppDelegate`.
#[must_use]
pub fn shared(mtm: MainThreadMarker) -> Option<Retained<Self>> {
let delegate = NSApplication::sharedApplication(mtm).delegate()?;
// SAFETY: a protocol object is an Objective-C object; we only use the
// result to attempt a checked downcast.
let obj: Retained<AnyObject> = unsafe { Retained::cast_unchecked(delegate) };
obj.downcast::<Self>().ok()
}
/// Connect the menu built from the xib spec (`@IBOutlet`s) -- done by
/// `entry::run` before the application starts, like nib loading.
pub fn connect_outlets(&self, outlets: Outlets) {
*self.ivars().outlets.borrow_mut() = outlets.items;
*self.ivars().services_menu.borrow_mut() = outlets.services_menu;
}
/// `applicationShouldHandleReopen(_:hasVisibleWindows:)`: called when the
/// application is already open and someone double-clicks the icon or
/// clicks the dock icon.
fn should_handle_reopen(&self, flag: bool) -> bool {
// If we have visible windows then we allow macOS to do its default behavior
// of focusing one of them.
if flag {
return true;
}
// If we have any windows in our terminal manager we don't do anything.
// This is possible with flag set to false if there a race where the
// window is still initializing and is not visible but the user clicked
// the dock icon.
if !TerminalController::all().is_empty() {
return true;
}
// If the application isn't active yet then we don't want to process
// this because we're not ready. This happens sometimes in Xcode runs
// but I haven't seen it happen in releases. I'm unsure why.
if !self.ivars().application_has_become_active.get() {
return true;
}
// No visible windows, open a new one.
let _ = TerminalController::new_window(&self.ivars().ghostty, None);
false
}
/// `ghostty`.
#[must_use]
pub fn ghostty(&self) -> Rc<App> {
self.ivars().ghostty.clone()
}
/// `undoManager`.
#[must_use]
pub fn undo_manager(
&self,
) -> Retained<crate::helpers::expiring_undo_manager::ExpiringUndoManager> {
self.ivars().undo_manager.clone()
}
/// `updateController`.
#[must_use]
pub fn update_controller(&self) -> &UpdateController {
&self.ivars().update_controller
}
/// `hiddenState`.
#[must_use]
pub fn has_hidden_state(&self) -> bool {
self.ivars().hidden_state.borrow().is_some()
}
fn menu_item(&self, outlet: Outlet) -> Option<Retained<NSMenuItem>> {
self.ivars().outlets.borrow().get(&outlet).cloned()
}
/// The elapsed time since the process was started.
fn time_since_launch(&self) -> f64 {
NSProcessInfo::processInfo().systemUptime() - self.ivars().application_launch_time.get()
}
/// Our quick terminal. This starts out uninitialized and only initializes if used.
#[must_use]
pub fn quick_controller(&self) -> Retained<QuickTerminalController> {
let ivars = self.ivars();
let mut state = ivars.quick_terminal_state.borrow_mut();
match &*state {
QuickTerminalState::Initialized(controller) => controller.clone(),
QuickTerminalState::PendingRestore(restore) => {
let controller = QuickTerminalController::new(
&ivars.ghostty,
ivars.derived_config.get().quick_terminal_position,
restore.base_config(),
Some(restore),
);
*state = QuickTerminalState::Initialized(controller.clone());
controller
}
QuickTerminalState::Uninitialized => {
let controller = QuickTerminalController::new(
&ivars.ghostty,
ivars.derived_config.get().quick_terminal_position,
None,
None,
);
*state = QuickTerminalState::Initialized(controller.clone());
controller
}
}
}
/// `findSurface(forUUID:)` (GhosttyAppDelegate) and
/// `ghosttySurface(id:)` (Ghostty.Delegate, `AppDelegate+Ghostty.swift`).
#[must_use]
pub fn find_surface(&self, uuid: &NSUUID) -> Option<Retained<SurfaceView>> {
for c in TerminalController::all() {
for view in c.surface_tree() {
if view.id().isEqual(Some(uuid)) {
return Some(view);
}
}
}
None
}
// MARK: Launch
fn did_finish_launching(&self) {
let mtm = self.mtm();
let app = NSApplication::sharedApplication(mtm);
let ivars = self.ivars();
// System settings overrides
super::support::register_defaults(&[
// Disable this so that repeated key events make it through to our terminal views.
("ApplePressAndHoldEnabled", false),
]);
// Store our start time
ivars
.application_launch_time
.set(NSProcessInfo::processInfo().systemUptime());
// Check if secure input was enabled when we last quit.
if user_defaults::ghostty().boolForKey(&NSString::from_str("SecureInput"))
!= SecureInput::shared().enabled()
{
self.set_secure_input(SetSecureInput::Toggle);
}
// Initial config loading
self.ghostty_config_did_change(&ivars.ghostty.config());
// Start our update checker.
ivars.update_controller.start_updater();
// Register our service provider. This must happen after everything is initialized.
let provider = ServiceProvider::new();
// SAFETY: the provider is retained by NSApplication's `servicesProvider`.
unsafe { app.setServicesProvider(Some(&provider)) };
// This registers the Ghostty => Services menu to exist.
app.setServicesMenu(ivars.services_menu.borrow().as_deref());
// Setup a local event monitor for app-level keyboard shortcuts. See
// localEventHandler for more info why.
self.install_local_monitor();
// Notifications
self.install_observers();
// Configure user notifications
self.configure_user_notifications();
// Observe our appearance so we can report the correct value to libghostty.
// SAFETY: KVO registration with a live observer (self lives for the whole
// process as the application delegate).
unsafe {
let observer: &AnyObject = self;
let _: () = msg_send![
&*app,
addObserver: observer,
forKeyPath: &*NSString::from_str("effectiveAppearance"),
options: objc2_foundation::NSKeyValueObservingOptions::New
| objc2_foundation::NSKeyValueObservingOptions::Initial,
context: std::ptr::null_mut::<std::ffi::c_void>()
];
}
// Setup our menu
self.setup_menu_images();
// Setup signal handlers
self.setup_signals();
match launch_source() {
LaunchSource::App => {
// Don't have to do anything.
}
LaunchSource::ZigRun | LaunchSource::Cli => {
// Part of launch services (clicking an app, using `open`, etc.) activates
// the application and brings it to the front. When using the CLI we don't
// get this behavior, so we have to do it manually.
// This never gets called until we click the dock icon. This forces it
// activate immediately.
self.did_become_active();
// We run in the background, this forces us to the front.
DispatchQueue::main().exec_async(|| {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
app.unhide(None);
app.arrangeInFront(None);
});
}
}
}
fn did_become_active(&self) {
let ivars = self.ivars();
// If we're back manually then clear the hidden state because macOS handles it.
*ivars.hidden_state.borrow_mut() = None;
// First launch stuff
if !ivars.application_has_become_active.get() {
ivars.application_has_become_active.set(true);
// Let's launch our first window. We only do this if we have no other windows. It
// is possible to have other windows in a few scenarios:
// - if we're opening a URL since `application(_:openFile:)` is called before this.
// - if we're restoring from persisted state
if TerminalController::all().is_empty() && ivars.derived_config.get().initial_window {
ivars.undo_manager.disableUndoRegistration();
let _ = TerminalController::new_window(&ivars.ghostty, None);
ivars.undo_manager.enableUndoRegistration();
}
}
}
fn should_terminate(&self, sender: &NSApplication) -> NSApplicationTerminateReply {
let windows = sender.windows();
if windows.is_empty() {
return NSApplicationTerminateReply::TerminateNow;
}
// If we've already accepted to install an update, then we don't need to
// confirm quit. The user is already expecting the update to happen.
if self.ivars().update_controller.is_installing() {
return NSApplicationTerminateReply::TerminateNow;
}
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
// quite work with SwiftUI because windows are retained on close. So instead we check
// if there are any that are visible. I'm guessing this breaks under certain scenarios.
//
// NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it
// here because I don't want to remove it in a patch release cycle but we should
// target removing it soon.
if windows.iter().all(|w| !w.isVisible()) {
return NSApplicationTerminateReply::TerminateNow;
}
// If the user is shutting down, restarting, or logging out, we don't confirm quit.
if let Some(event) =
objc2_foundation::NSAppleEventManager::sharedAppleEventManager().currentAppleEvent()
{
// If all Ghostty windows are in the background (i.e. you Cmd-Q from the Cmd-Tab
// view), then this is null. I don't know why (pun intended) but we have to
// guard against it.
let keyword = u32::from_be_bytes(*b"why?");
// SAFETY: `attributeDescriptorForKeyword:` takes an AEKeyword (u32) and
// returns an optional descriptor.
let why: Option<Retained<objc2_foundation::NSAppleEventDescriptor>> =
unsafe { msg_send![&*event, attributeDescriptorForKeyword: keyword] };
if let Some(why) = why {
const K_AE_SHUT_DOWN: u32 = u32::from_be_bytes(*b"shut");
const K_AE_RESTART: u32 = u32::from_be_bytes(*b"rest");
const K_AE_REALLY_LOG_OUT: u32 = u32::from_be_bytes(*b"rlgo");
if matches!(
why.typeCodeValue(),
K_AE_SHUT_DOWN | K_AE_RESTART | K_AE_REALLY_LOG_OUT
) {
return NSApplicationTerminateReply::TerminateNow;
}
}
}
// If our app says we don't need to confirm, we can exit now.
if !self.ivars().ghostty.needs_confirm_quit() {
return NSApplicationTerminateReply::TerminateNow;
}
// We have some visible window. Show an app-wide modal to confirm quitting.
let alert = NSAlert::new(self.mtm());
alert.setMessageText(&NSString::from_str("Quit Ghostty?"));
alert.setInformativeText(&NSString::from_str(
"All terminal sessions will be terminated.",
));
alert.addButtonWithTitle(&NSString::from_str("Close Ghostty"));
alert.addButtonWithTitle(&NSString::from_str("Cancel"));
alert.setAlertStyle(NSAlertStyle::Warning);
if alert.runModal() == NSAlertFirstButtonReturn {
NSApplicationTerminateReply::TerminateNow
} else {
NSApplicationTerminateReply::TerminateCancel
}
}
/// `application(_:openFile:)`.
fn open_file(&self, filename: &str) -> bool {
// Ghostty will validate as well but we can avoid creating an entirely new
// surface by doing our own validation here. We can also show a useful error
// this way.
let path = std::path::Path::new(filename);
if !path.exists() {
return false;
}
let is_directory = path.is_dir();
// Set to true if confirmation is required before starting up the
// new terminal.
let mut requires_confirm = false;
// Initialize the surface config which will be used to create the tab or window for the opened file.
let mut config = SurfaceConfiguration::default();
if is_directory {
// When opening a directory, check the configuration to decide
// whether to open in a new tab or new window.
config.working_directory = Some(filename.to_string());
} else {
// Unconditionally require confirmation in the file execution case.
// In the future I have ideas about making this more fine-grained if
// we can not inherit of unsandboxed state. For now, we need to confirm
// because there is a sandbox escape possible if a sandboxed application
// somehow is tricked into `open`-ing a non-sandboxed application.
requires_confirm = true;
// When opening a file, we want to execute the file. To do this, we
// don't override the command directly, because it won't load the
// profile/rc files for the shell, which is super important on macOS
// due to things like Homebrew. Instead, we set the command to
// `<filename>; exit` which is what Terminal and iTerm2 do.
config.initial_input = Some(format!("{}; exit\n", shell::quote(filename)));
// For commands executed directly, we want to ensure we wait after exit
// because in most cases scripts don't block on exit and we don't want
// the window to just flash closed once complete.
config.wait_after_command = true;
// Set the parent directory to our working directory so that relative
// paths in scripts work.
config.working_directory = Some(
NSString::from_str(filename)
.stringByDeletingLastPathComponent()
.to_string(),
);
}
if requires_confirm {
// Confirmation required. We use an app-wide NSAlert for now. In the future we
// may want to show this as a sheet on the focused window (especially if we're
// opening a tab). I'm not sure.
let alert = NSAlert::new(self.mtm());
alert.setMessageText(&NSString::from_str(&format!(
"Allow Ghostty to execute \"{filename}\"?"
)));
alert.addButtonWithTitle(&NSString::from_str("Allow"));
alert.addButtonWithTitle(&NSString::from_str("Cancel"));
alert.setAlertStyle(NSAlertStyle::Warning);
let response: NSModalResponse = alert.runModal();
if response != NSAlertFirstButtonReturn {
return false;
}
}
match self.ivars().ghostty.config().macos_dock_drop_behavior() {
MacDockDropBehavior::NewTab => {
let parent = TerminalController::preferred_parent().and_then(|c| c.window());
let _ = TerminalController::new_tab(
&self.ivars().ghostty,
parent.as_deref(),
Some(&config),
);
}
MacDockDropBehavior::NewWindow => {
let _ = TerminalController::new_window(&self.ivars().ghostty, Some(&config));
}
}
true
}
/// Setup signal handlers.
fn setup_signals(&self) {
// Register a signal handler for config reloading. It appears that all
// of this is required. I've commented each line because its a bit unclear.
// Warning: signal handlers don't work when run via Xcode. They have to be
// run on a real app bundle.
// We need to ignore signals we register with makeSignalSource or they
// don't seem to handle.
// SAFETY: `signal` with SIG_IGN is always sound.
unsafe { libc::signal(libc::SIGUSR2, libc::SIG_IGN) };
// Make the signal source and register our event handle. We keep a weak
// ref to ourself so we don't create a retain cycle.
// SAFETY: `_dispatch_source_type_signal` is the documented signal
// source type and the handle is a signal number.
let sigusr2 = unsafe {
DispatchSource::new(
std::ptr::addr_of!(dispatch2::_dispatch_source_type_signal).cast_mut(),
libc::SIGUSR2 as usize,
0,
Some(DispatchQueue::main()),
)
};
let weak = Weak::from_retained(&self.retain());
let handler = RcBlock::new(move || {
let Some(this) = weak.load() else { return };
log::info!("reloading configuration in response to SIGUSR2");
this.ivars().ghostty.reload_config(false);
});
// SAFETY: the block is copied by libdispatch.
unsafe { sigusr2.set_event_handler_with_block(RcBlock::as_ptr(&handler).cast()) };
// The signal source starts unactivated, so we have to resume it once
// we setup the event handler.
sigusr2.resume();
// We need to keep a strong reference to it so it isn't disabled.
self.ivars().signals.borrow_mut().push(sigusr2);
}
fn weak_self(&self) -> Weak<Self> {
Weak::from_retained(&self.retain())
}
// MARK: Notifications and Events
fn install_local_monitor(&self) {
let weak = self.weak_self();
let handler = RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
// SAFETY: AppKit passes a valid event.
let event_ref = unsafe { event.as_ref() };
let Some(this) = weak.load() else {
return event.as_ptr();
};
match this.local_event_handler(event_ref) {
true => event.as_ptr(),
false => std::ptr::null_mut(),
}
});
// SAFETY: the handler block has the signature AppKit expects (event in,
// event or nil out) and is copied by AppKit.
let monitor = unsafe {
NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &handler)
};
*self.ivars().local_monitor.borrow_mut() = monitor;
}
/// `localEventHandler`: returns true if the event should continue (Swift
/// returned the event), false if it was consumed (Swift returned nil).
fn local_event_handler(&self, event: &NSEvent) -> bool {
if event.r#type() == NSEventType::KeyDown {
return self.local_event_key_down(event);
}
true
}
/// This handles events from the NSEvent.addLocalEventMonitor. We use this so
/// we can get events without any terminal windows open.
fn local_event_key_down(&self, event: &NSEvent) -> bool {
let mtm = self.mtm();
let app = NSApplication::sharedApplication(mtm);
// If the tab overview is visible and escape is pressed, close it.
// This can't POSSIBLY be right and is probably a FirstResponder problem
// that we should handle elsewhere in our program. But this works and it
// is guarded by the tab overview currently showing.
if event.keyCode() == 0x35 {
if let Some(window) = app.keyWindow() {
if let Some(tab_group) = window.tabGroup() {
if tab_group.isOverviewVisible() {
window.toggleTabOverview(None);
return false;
}
}
}
}
// If we have a main window then we don't process any of the keys
// because we let it capture and propagate.
if app.mainWindow().is_some() {
return true;
}
let ghostty = self.ivars().ghostty.clone();
// If this event as-is would result in a key binding then we send it.
if let Some(app_ptr) = ghostty.app_ptr() {
let mut ghostty_event =
crate::ghostty::ns_event::ghostty_key_event(event, GHOSTTY_ACTION_PRESS, None);
let chars = event
.characters()
.map(|s| s.to_string())
.unwrap_or_default();
let text = CString::new(chars).unwrap_or_default();
ghostty_event.text = text.as_ptr();
// SAFETY: `app_ptr` is a live app handle and `text` outlives the calls.
let matched = unsafe {
if ghostty_app_key_is_binding(app_ptr, ghostty_event) {
ghostty_app_key(app_ptr, ghostty_event)
} else {
false
}
};
// If the key was handled by Ghostty we stop the event chain. If
// the key wasn't handled then we let it fall through and continue
// processing. This is important because some bindings may have no
// affect at this scope.
if matched {
return false;
}
}
// If this event would be handled by our menu then we do nothing.
if let Some(main_menu) = app.mainMenu() {
if main_menu.performKeyEquivalent(event) {
return false;
}
}
// If we reach this point then we try to process the key event
// through the Ghostty key mechanism.
// Ghostty must be loaded
let Some(app_ptr) = ghostty.app_ptr() else {
return true;
};
// Build our event input and call ghostty
let key = crate::ghostty::ns_event::ghostty_key_event(event, GHOSTTY_ACTION_PRESS, None);
// SAFETY: `app_ptr` is a live app handle; `text` is null.
if unsafe { ghostty_app_key(app_ptr, key) } {
// The key was used so we want to stop it from going to our Mac app
log::debug!("local key event handled");
return false;
}
true
}
fn install_observers(&self) {
let ivars = self.ivars();
let mtm = self.mtm();
let _ = mtm;
// AppKit notifications.
let weak = self.weak_self();
ivars.observers.borrow_mut().push(ns_notification::observe(
ns_notification::name_window_did_become_key(),
None,
move |n| {
if let Some(this) = weak.load() {
this.window_did_become_key(n);
}
},
));
// Ghostty's own notifications.
let nc = NotificationCenter::default_center();
let mut bus = ivars.bus_observers.borrow_mut();
let weak = self.weak_self();
bus.push(
nc.add_observer(QUICK_TERMINAL_DID_CHANGE_VISIBILITY, None, move |n| {
if let Some(this) = weak.load() {
this.quick_terminal_did_change_visibility(n);
}
}),
);
let weak = self.weak_self();
bus.push(
nc.add_observer(gn::GHOSTTY_CONFIG_DID_CHANGE, None, move |n| {
if let Some(this) = weak.load() {
this.ghostty_config_did_change_notification(n);
}
}),
);
let weak = self.weak_self();
bus.push(nc.add_observer(gn::GHOSTTY_BELL_DID_RING, None, move |_| {
if let Some(this) = weak.load() {
this.ghostty_bell_did_ring();
}
}));
let weak = self.weak_self();
bus.push(nc.add_observer(
gn::TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION,
None,
move |n| {
if let Some(this) = weak.load() {
this.terminal_window_has_bell(n);
}
},
));
let weak = self.weak_self();
bus.push(nc.add_observer(gn::GHOSTTY_NEW_WINDOW, None, move |n| {
if let Some(this) = weak.load() {
this.ghostty_new_window(n);
}
}));
let weak = self.weak_self();
bus.push(nc.add_observer(gn::GHOSTTY_NEW_TAB, None, move |n| {
if let Some(this) = weak.load() {
this.ghostty_new_tab(n);
}
}));
}
fn configure_user_notifications(&self) {
let show = UNNotificationAction::actionWithIdentifier_title_options(
&NSString::from_str(crate::ghostty::package::USER_NOTIFICATION_ACTION_SHOW),
&NSString::from_str("Show"),
UNNotificationActionOptions::empty(),
);
let center = UNUserNotificationCenter::currentNotificationCenter();
let category =
UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options(
&NSString::from_str(crate::ghostty::package::USER_NOTIFICATION_CATEGORY),
&NSArray::from_retained_slice(&[show]),
&NSArray::new(),
UNNotificationCategoryOptions::CustomDismissAction,
);
center.setNotificationCategories(&NSSet::from_retained_slice(&[category]));
center.setDelegate(Some(ProtocolObject::from_ref(self)));
}
/// Report the effective appearance to libghostty (the KVO callback body).
fn report_color_scheme(&self) {
let Some(app) = self.ivars().ghostty.app_ptr() else {
return;
};
let appearance = NSApplication::sharedApplication(self.mtm()).effectiveAppearance();
let scheme =
if crate::helpers::extensions::ns_appearance::NSAppearanceExt::is_dark(&*appearance) {
GHOSTTY_COLOR_SCHEME_DARK
} else {
GHOSTTY_COLOR_SCHEME_LIGHT
};
// SAFETY: `app` is a live app handle.
unsafe { ghostty_app_set_color_scheme(app, scheme) };
}
fn window_did_become_key(&self, notification: &NSNotification) {
let window = notification
.object()
.and_then(|o| o.downcast::<NSWindow>().ok());
self.sync_float_on_top_menu(window.as_deref());
}
fn quick_terminal_did_change_visibility(&self, n: &Notification) {
use objc2_app_kit::{NSControlStateValueOff, NSControlStateValueOn};
let Some(controller) = n
.object
.as_ref()
.and_then(|o| o.downcast_ref::<Retained<QuickTerminalController>>())
else {
return;
};
if let Some(item) = self.menu_item(Outlet::MenuQuickTerminal) {
item.setState(if controller.visible() {
NSControlStateValueOn
} else {
NSControlStateValueOff
});
}
}
fn ghostty_config_did_change_notification(&self, n: &Notification) {
// We only care if the configuration is a global configuration, not a surface one.
if n.object.is_some() {
return;
}
// Get our managed configuration object out
let Some(config) = n.user_info.get::<Rc<Config>>(gn::GHOSTTY_CONFIG_CHANGE_KEY) else {
return;
};
self.ghostty_config_did_change(config);
}
fn ghostty_bell_did_ring(&self) {
let config = self.ivars().ghostty.config();
let features = config.bell_features();
if features.contains(BellFeatures::SYSTEM) {
// SAFETY: NSBeep has no preconditions.
unsafe { NSBeep() };
}
if features.contains(BellFeatures::AUDIO) {
if let Some(path) = config.bell_audio_path() {
let sound = NSSound::initWithContentsOfFile_byReference(
NSSound::alloc(),
&NSString::from_str(&path.path),
false,
);
if let Some(sound) = sound {
sound.setVolume(config.bell_audio_volume());
sound.play();
}
}
}
if features.contains(BellFeatures::ATTENTION) {
// Bounce the dock icon if we're not focused.
NSApplication::sharedApplication(self.mtm()).requestUserAttention(
objc2_app_kit::NSRequestUserAttentionType::InformationalRequest,
);
}
}
fn terminal_window_has_bell(&self, n: &Notification) {
if n.object
.as_ref()
.and_then(|o| o.downcast_ref::<Retained<BaseTerminalController>>())
.is_none()
{
return;
}
self.sync_dock_badge();
}
fn sync_dock_badge(&self) {
let center = UNUserNotificationCenter::currentNotificationCenter();
let center2 = center.clone();
let handler = RcBlock::new(
move |settings: NonNull<objc2_user_notifications::UNNotificationSettings>| {
// SAFETY: valid settings object for the duration of the callback.
let settings = unsafe { settings.as_ref() };
match settings.authorizationStatus() {
UNAuthorizationStatus::Authorized => {
// If we're authorized and allow badges, then set the badge.
if settings.badgeSetting() == UNNotificationSetting::Enabled {
DispatchQueue::main().exec_async(set_dock_badge_on_main);
}
}
UNAuthorizationStatus::NotDetermined => {
// Not determined yet, request authorization for badge
let completion = RcBlock::new(
|granted: objc2::runtime::Bool,
error: *mut objc2_foundation::NSError| {
if !error.is_null() {
// SAFETY: non-null error pointer provided by the framework.
let e = unsafe { &*error };
log::warn!(
"Error requesting badge authorization: {}",
e.localizedDescription()
);
return;
}
if granted.as_bool() {
// Permission granted, set the badge
DispatchQueue::main().exec_async(set_dock_badge_on_main);
}
},
);
center2.requestAuthorizationWithOptions_completionHandler(
UNAuthorizationOptions::Badge,
&completion,
);
}
// In these known non-authorized states, do not attempt to set the badge;
// future unknown states do nothing either.
_ => {}
}
},
);
center.getNotificationSettingsWithCompletionHandler(&handler);
}
fn ghostty_new_window(&self, n: &Notification) {
let config = n
.user_info
.get::<SurfaceConfiguration>(gn::NEW_SURFACE_CONFIG_KEY);
let _ = TerminalController::new_window(&self.ivars().ghostty, config);
}
fn ghostty_new_tab(&self, n: &Notification) {
let Some(surface_view) = n
.object
.as_ref()
.and_then(|o| o.downcast_ref::<Retained<SurfaceView>>())
else {
return;
};
let Some(window) = surface_view.window() else {
return;
};
// We only want to listen to new tabs if the focused parent is
// a regular terminal controller.
let is_terminal = window
.windowController()
.is_some_and(|c| c.downcast::<TerminalController>().is_ok());
if !is_terminal {
return;
}
let config = n
.user_info
.get::<SurfaceConfiguration>(gn::NEW_SURFACE_CONFIG_KEY);
let _ = TerminalController::new_tab(&self.ivars().ghostty, Some(&window), config);
}
/// `ghosttyConfigDidChange(config:)`.
fn ghostty_config_did_change(&self, config: &Rc<Config>) {
let ivars = self.ivars();
let mtm = self.mtm();
let app = NSApplication::sharedApplication(mtm);
// Update the config we need to store
ivars.derived_config.set(DerivedConfig::new(config));
// Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows
// configuration. This is the only way to carefully control whether macOS invokes the
// state restoration system.
let ud = user_defaults::ghostty();
let key = NSString::from_str("NSQuitAlwaysKeepsWindows");
match config.window_save_state().as_str() {
"never" => ud.setBool_forKey(false, &key),
"always" => ud.setBool_forKey(true, &key),
_ => ud.removeObjectForKey(&key),
}
// Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is
// explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
// user-based defaults.
let uc = &ivars.update_controller;
if super::support::bundle_info_bool("SUEnableAutomaticChecks") == Some(false) {
uc.automatically_checks_for_updates.set(false);
uc.automatically_downloads_updates.set(false);
} else if let Some(auto_update) = config.auto_update() {
uc.automatically_checks_for_updates
.set(auto_update == AutoUpdate::Check || auto_update == AutoUpdate::Download);
uc.automatically_downloads_updates
.set(auto_update == AutoUpdate::Download);
// To test `auto-update` easily, uncomment the line below and
// delete `SUEnableAutomaticChecks` in Ghostty-Info.plist.
//
// Note: When `auto-update = download`, you may need to
// `Clean Build Folder` if a background install has already begun.
// updateController.updater.checkForUpdatesInBackground()
}
// Config could change keybindings, so update everything that depends on that
self.sync_menu_shortcuts(config);
for c in TerminalController::all() {
c.relabel_tabs();
}
// Update our badge since config can change what we show.
self.sync_dock_badge();
// Config could change window appearance. We wrap this in an async queue because when
// this is called as part of application launch it can deadlock with an internal
// AppKit mutex on the appearance.
let weak = self.weak_self();
let config_for_appearance = config.clone();
main_async_local(move || {
if let Some(this) = weak.load() {
this.sync_appearance(&config_for_appearance);
}
});
// Decide whether to hide/unhide app from dock and app switcher
match config.macos_hidden() {
MacHidden::Never => {
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
}
MacHidden::Always => {
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController::shared_instance();
c.set_errors(config.errors());
if !c.errors().is_empty() && !c.is_window_visible() {
c.show_window();
}
// We need to handle our global event tap depending on if there are global
// events that we care about in Ghostty.
let has_global = ivars
.ghostty
.app_ptr()
// SAFETY: live app handle.
.is_some_and(|a| unsafe { ghostty_app_has_global_keybinds(a) });
if has_global {
if self.time_since_launch() > 5.0 {
// If the process has been running for awhile we enable right away
// because no windows are likely to pop up.
GlobalEventTap::shared().enable();
} else {
// If the process just started, we wait a couple seconds to allow
// the initial windows and so on to load so our permissions dialog
// doesn't get buried.
crate::helpers::dispatch::main_async_after(
std::time::Duration::from_secs(2),
|| GlobalEventTap::shared().enable(),
);
}
} else {
GlobalEventTap::shared().disable();
}
self.update_app_icon(config);
}
/// Sync the appearance of our app with the theme specified in the config.
fn sync_appearance(&self, config: &Config) {
let app = NSApplication::sharedApplication(self.mtm());
let appearance =
crate::helpers::extensions::ns_appearance::appearance_from_ghostty_config(config);
app.setAppearance(appearance.as_deref());
}
fn update_app_icon(&self, config: &Config) {
// Since this is called after `DockTilePlugin` has been running,
// clean it up here to trigger a correct update of the current config.
user_defaults::ghostty().removeObjectForKey(&NSString::from_str("CustomGhosttyIcon"));
// TODO(MA4): `DispatchQueue.global().async { UserDefaults.ghostty.appIcon =
// AppIcon(config: config); post .ghosttyIconDidChange on the distributed
// notification center }` (Custom App Icon feature).
let _ = config;
crate::features::custom_app_icon::post_icon_did_change();
}
// MARK: - Global State
/// `setSecureInput(_:)`.
pub fn set_secure_input(&self, mode: SetSecureInput) {
use objc2_app_kit::{NSControlStateValueOff, NSControlStateValueOn};
let input = SecureInput::shared();
match mode {
SetSecureInput::On => input.set_global(true),
SetSecureInput::Off => input.set_global(false),
SetSecureInput::Toggle => input.set_global(!input.global()),
}
if let Some(item) = self.menu_item(Outlet::MenuSecureInput) {
item.setState(if input.global() {
NSControlStateValueOn
} else {
NSControlStateValueOff
});
}
user_defaults::ghostty().setBool_forKey(input.global(), &NSString::from_str("SecureInput"));
}
/// `@IBAction checkForUpdates` (called by Ghostty.App's `checkForUpdates` action).
pub fn check_for_updates(&self) {
self.ivars().update_controller.check_for_updates();
}
/// `@IBAction closeAllWindows` (called by Ghostty.App's action).
pub fn close_all_windows(&self) {
TerminalController::close_all_windows();
AboutController::shared().hide();
}
/// `@IBAction toggleQuickTerminal`.
pub fn toggle_quick_terminal(&self) {
self.quick_controller().toggle();
}
/// `@IBAction toggleVisibility`.
pub fn toggle_visibility(&self) {
let app = NSApplication::sharedApplication(self.mtm());
// If we have focus, then we hide all windows.
if app.isActive() {
// Toggle visibility doesn't do anything if the focused window is native
// fullscreen. This is only relevant if Ghostty is active.
let Some(key_window) = app.keyWindow() else {
return;
};
if key_window
.styleMask()
.contains(NSWindowStyleMask::FullScreen)
{
return;
}
app.hide(None);
return;
}
// If we're not active, we want to become active
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
// Bring all windows to the front. Note: we don't use NSApp.unhide because
// that will unhide ALL hidden windows. We want to only bring forward the
// ones that we hid.
if let Some(state) = self.ivars().hidden_state.borrow_mut().take() {
state.restore();
}
}
fn set_as_default_terminal(&self) {
let mtm = self.mtm();
let handler = RcBlock::new(|error: *mut objc2_foundation::NSError| {
if error.is_null() {
return;
}
// SAFETY: non-null error pointer provided by the framework.
let desc = unsafe { &*error }.localizedDescription().to_string();
DispatchQueue::main().exec_async(move || {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let alert = NSAlert::new(mtm);
alert.setMessageText(&NSString::from_str("Failed to Set Default Terminal"));
alert.setInformativeText(&NSString::from_str(&format!(
"Ghostty could not be set as the default terminal application.\n\nError: {desc}"
)));
alert.setAlertStyle(NSAlertStyle::Warning);
alert.runModal();
});
});
let _ = mtm;
super::support::set_default_terminal(&handler);
}
}
// MARK: Menu
impl AppDelegate {
fn reload_dock_menu(&self) {
let mtm = self.mtm();
let dock_menu = &self.ivars().dock_menu;
dock_menu.removeAllItems();
for (title, selector) in DOCK_MENU {
// SAFETY: valid title/selector/key equivalent.
let item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str(title),
Some(main_menu::selector(selector)),
&NSString::from_str(""),
)
};
dock_menu.addItem(&item);
}
}
/// Setup all the images for our menu items.
fn setup_menu_images(&self) {
// Note: This COULD Be done all in the xib file, but I find it easier to
// modify this stuff as code.
for (outlet, symbol) in MENU_IMAGES {
if let Some(item) = self.menu_item(*outlet) {
item.set_image_if_desired(symbol);
}
}
}
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
fn sync_menu_shortcuts(&self, config: &Config) {
if self.ivars().ghostty.readiness() != Readiness::Ready {
return;
}
// Reset our shortcut index since we're about to rebuild all menu bindings.
self.ivars().menu_items_by_shortcut.borrow_mut().clear();
for (action, outlet) in SYNCED_SHORTCUTS {
self.sync_menu_shortcut(config, action, self.menu_item(*outlet).as_deref());
}
// This menu item is NOT synced with the configuration because it disables macOS
// global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue
// to work but it won't be reflected in the menu item.
//
// syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
// Dock menu
self.reload_dock_menu();
}
/// Syncs a single menu shortcut for the given action. The action string is the same
/// action string used for the Ghostty configuration.
fn sync_menu_shortcut(&self, config: &Config, action: &str, menu_item: Option<&NSMenuItem>) {
let Some(menu) = menu_item else { return };
let Some(shortcut) = config.keyboard_shortcut(action) else {
// No shortcut, clear the menu item
menu.setKeyEquivalent(&NSString::from_str(""));
menu.setKeyEquivalentModifierMask(NSEventModifierFlags::empty());
return;
};
let key_equivalent = shortcut.key.character.to_string();
let modifier_mask =
crate::helpers::extensions::event_modifiers::ns_flags_from_swiftui(shortcut.modifiers);
menu.setKeyEquivalent(&NSString::from_str(&key_equivalent));
menu.setKeyEquivalentModifierMask(modifier_mask);
// Build a direct lookup for key-equivalent dispatch so we don't need to
// linearly walk the full menu hierarchy at event time.
let Some(key) = MenuShortcutKey::new(&key_equivalent, modifier_mask.0) else {
return;
};
// Later registrations intentionally override earlier ones for the same key.
// SAFETY: the item is alive (we hold `menu`); Weak::new needs a retained ref.
let retained = unsafe { Retained::retain(menu as *const NSMenuItem as *mut NSMenuItem) };
if let Some(retained) = retained {
self.ivars()
.menu_items_by_shortcut
.borrow_mut()
.insert(key, Weak::from_retained(&retained));
}
}
/// Attempts to perform a menu key equivalent only for menu items that represent
/// Ghostty keybind actions. This is important because it lets our surface dispatch
/// bindings through the menu so they flash but also lets our surface override macOS built-ins
/// like Cmd+H.
pub fn perform_ghostty_binding_menu_key_equivalent(&self, event: &NSEvent) -> bool {
// Convert this event into the same normalized lookup key we use when
// syncing menu shortcuts from configuration.
let Some(chars) = event.charactersIgnoringModifiers() else {
return false;
};
let Some(key) = MenuShortcutKey::new(&chars.to_string(), event.modifierFlags().0) else {
return false;
};
// If we don't have an entry for this key combo, no Ghostty-owned
// menu shortcut exists for this event.
let Some(weak_item) = self
.ivars()
.menu_items_by_shortcut
.borrow()
.get(&key)
.map(|w| w.load())
else {
return false;
};
// Weak references can be nil if a menu item was deallocated after sync.
let Some(item) = weak_item else {
self.ivars()
.menu_items_by_shortcut
.borrow_mut()
.remove(&key);
return false;
};
// SAFETY: plain property getter.
let Some(parent_menu) = (unsafe { item.menu() }) else {
return false;
};
// Keep enablement state fresh in case menu validation hasn't run yet.
parent_menu.update();
if !item.isEnabled() {
return false;
}
let index = parent_menu.indexOfItem(&item);
if index < 0 {
return false;
}
parent_menu.performActionForItemAtIndex(index);
true
}
// MARK: Floating Windows
/// `syncFloatOnTopMenu`.
pub fn sync_float_on_top_menu(&self, window: Option<&NSWindow>) {
use objc2_app_kit::{NSControlStateValueOff, NSControlStateValueOn};
let Some(item) = self.menu_item(Outlet::MenuFloatOnTop) else {
return;
};
let key = NSApplication::sharedApplication(self.mtm()).keyWindow();
let window: Option<&NSWindow> = window.or(key.as_deref());
let terminal = window.and_then(|w| {
let obj: &AnyObject = w;
obj.downcast_ref::<TerminalWindow>()
});
let Some(window) = terminal else {
// If some other window became key we always turn this off
item.setState(NSControlStateValueOff);
return;
};
item.setState(if window.level() == FLOATING_WINDOW_LEVEL {
NSControlStateValueOn
} else {
NSControlStateValueOff
});
}
// MARK: NSMenuItemValidation
fn validate(&self, item: &NSMenuItem) -> bool {
let Some(action) = item.action() else {
return true;
};
let app = NSApplication::sharedApplication(self.mtm());
if action == sel!(setAsDefaultTerminal:) {
let current = NSWorkspace::sharedWorkspace().default_terminal();
let bundle = objc2_foundation::NSBundle::mainBundle().bundleURL();
return current.map(|u| u.isEqual(Some(&bundle))) != Some(true);
}
if action == sel!(floatOnTop:) || action == sel!(useAsDefault:) {
// Float on top items only active if the key window is a primary
// terminal window (not quick terminal).
return app.keyWindow().is_some_and(|w| {
let obj: &AnyObject = &w;
obj.downcast_ref::<TerminalWindow>().is_some()
});
}
let um = &self.ivars().undo_manager;
if action == sel!(undo:) {
if um.canUndo() {
item.setTitle(&NSString::from_str(&format!(
"Undo {}",
um.undoActionName()
)));
} else {
item.setTitle(&NSString::from_str("Undo"));
}
return um.canUndo();
}
if action == sel!(redo:) {
if um.canRedo() {
item.setTitle(&NSString::from_str(&format!(
"Redo {}",
um.redoActionName()
)));
} else {
item.setTitle(&NSString::from_str("Redo"));
}
return um.canRedo();
}
true
}
}
/// `setDockBadge()` on the main thread.
fn set_dock_badge_on_main() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let Some(delegate) = AppDelegate::shared(mtm) else {
return;
};
delegate.set_dock_badge();
}
impl AppDelegate {
fn set_dock_badge(&self) {
let app = NSApplication::sharedApplication(self.mtm());
let bell_count = app
.windows()
.iter()
.filter_map(|w| w.windowController())
.filter_map(|c| c.downcast::<BaseTerminalController>().ok())
.filter(|c| c.bell())
.count();
let wants_badge = self
.ivars()
.ghostty
.config()
.bell_features()
.contains(BellFeatures::ATTENTION)
&& bell_count > 0;
let label = if wants_badge {
Some(if bell_count > 99 {
"99+".to_string()
} else {
bell_count.to_string()
})
} else {
None
};
let tile = app.dockTile();
tile.setBadgeLabel(label.map(|l| NSString::from_str(&l)).as_deref());
tile.display();
}
}
/// `DispatchQueue.main.async { ... }` for closures that capture main-thread
/// only state (weak refs to AppKit objects). Must be called on the main thread.
fn main_async_local(f: impl FnOnce() + 'static) {
/// Wrapper asserting the closure is only run on the main queue.
struct MainOnly<F>(F);
// SAFETY: the closure is created on the main thread and only ever executed
// on the main queue (i.e. the main thread), so its captured state never
// crosses threads.
unsafe impl<F> Send for MainOnly<F> {}
let wrapped = MainOnly(f);
DispatchQueue::main().exec_async(move || {
let w = wrapped;
(w.0)();
});
}
#[link(name = "AppKit", kind = "framework")]
unsafe extern "C" {
fn NSBeep();
}
@@ -0,0 +1,33 @@
//! `AppDelegate+Ghostty.swift`: the Ghostty app delegate protocol
//! (`Ghostty.Delegate`), used by the libghostty glue for app-global
//! information.
use objc2::{MainThreadOnly, rc::Retained};
use objc2_app_kit::NSApplication;
use objc2_foundation::{NSObjectProtocol, NSUUID};
use super::app_delegate::AppDelegate;
use crate::{features::terminal::BaseTerminalController, ghostty::surface_view::SurfaceView};
impl AppDelegate {
/// `ghosttySurface(id:)`: find a surface view by its id in any terminal window.
#[must_use]
pub fn ghostty_surface(&self, id: &NSUUID) -> Option<Retained<SurfaceView>> {
for window in NSApplication::sharedApplication(self.mtm()).windows() {
let Some(controller) = window
.windowController()
.and_then(|c| c.downcast::<BaseTerminalController>().ok())
else {
continue;
};
for surface in controller.surface_tree() {
if surface.id().isEqual(Some(id)) {
return Some(surface);
}
}
}
None
}
}
+78
View File
@@ -0,0 +1,78 @@
//! `main.swift`: process entry point.
//!
//! 1. `ghostty_init(argc, argv)`; on failure log, print the CLI hint when
//! launched from a terminal, and `exit(1)`.
//! 2. `ghostty_cli_try_action()` runs a `+action` CLI command and exits.
//! 3. `NSApplicationMain`: there is no `Info.plist`-named nib here, so the nib's
//! work is done in code: create the `NSApplication`, the `AppDelegate`
//! (which creates `Ghostty.App`), build the menu from the xib tables
//! (`main_menu::install`), connect the outlets, set the delegate and run.
#![allow(unsafe_code)]
use std::{
ffi::{CString, c_char},
io::Write,
os::unix::ffi::OsStrExt,
};
use objc2::{MainThreadMarker, runtime::ProtocolObject};
use objc2_app_kit::NSApplication;
use super::{app_delegate::AppDelegate, main_menu};
use crate::ghostty::{
capi::{GHOSTTY_SUCCESS, ghostty_cli_try_action, ghostty_init},
package::{LaunchSource, launch_source},
};
/// The text printed to stderr when `ghostty_init` fails from a terminal.
pub const INIT_FAILED_MESSAGE: &str = "Ghostty failed to initialize! If you're executing Ghostty from the command line\n\
then this is usually because an invalid action or multiple actions were specified.\n\
Actions start with the `+` character.\n\n\
View all available actions by running `ghostty +help`.\n";
/// Run the app. Never returns.
pub fn run() -> ! {
// Initialize Ghostty global state. We do this once right away because the
// CLI APIs require it and it lets us ensure it is done immediately for the
// rest of the app.
let args: Vec<CString> = std::env::args_os()
.map(|a| CString::new(a.as_bytes()).unwrap_or_default())
.collect();
let mut argv: Vec<*mut c_char> = args.iter().map(|a| a.as_ptr().cast_mut()).collect();
argv.push(std::ptr::null_mut());
// SAFETY: `argv` holds `args.len()` valid NUL terminated strings followed by
// a null terminator and outlives the call.
let rc = unsafe { ghostty_init(args.len(), argv.as_mut_ptr()) };
if rc != GHOSTTY_SUCCESS {
log::error!("ghostty_init failed");
// We also write to stderr if this is executed from the CLI or zig run
match launch_source() {
LaunchSource::Cli | LaunchSource::ZigRun => {
let _ = std::io::stderr().write_all(INIT_FAILED_MESSAGE.as_bytes());
std::process::exit(1);
}
// For the app we exit immediately. We should handle this case more
// gracefully in the future.
LaunchSource::App => std::process::exit(1),
}
}
// This will run the CLI action and exit if one was specified. A CLI
// action is a command starting with a `+`, such as `ghostty +boo`.
ghostty_cli_try_action();
// NSApplicationMain(...)
let mtm = MainThreadMarker::new().expect("main() runs on the main thread");
let app = NSApplication::sharedApplication(mtm);
let delegate = AppDelegate::new(mtm);
let outlets = main_menu::install(mtm, &app, &delegate);
delegate.connect_outlets(outlets);
app.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
app.run();
// `-[NSApplication run]` only returns after `stop:`; NSApplicationMain
// exits the process in that case.
drop(delegate);
std::process::exit(0);
}
+145
View File
@@ -0,0 +1,145 @@
//! `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)
}
@@ -0,0 +1,916 @@
//! GENERATED by scripts/gen-macos-menu-spec.py from MainMenu.xib; do not edit.
//!
//! Every menu item of the xib: title, key equivalent, modifier mask, action
//! selector + target (`-1` first responder / the AppDelegate) and the AppDelegate
//! outlet it is connected to.
use super::{Action, Item, ItemSpec, MenuSpec, Mods, Outlet, SystemMenu, Target};
/// The main menu (`systemMenu="main"`).
pub const MAIN_MENU: MenuSpec = MenuSpec {
title: "Main Menu",
system_menu: Some(SystemMenu::Main),
outlet: None,
items: &[
ItemSpec::Item(Item {
title: "Ghostty",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: None,
submenu: Some(&MenuSpec {
title: "Ghostty",
system_menu: Some(SystemMenu::Apple),
outlet: None,
items: &[
ItemSpec::Item(Item {
title: "About Ghostty",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "showAbout:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuAbout),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Check for Updates...",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "checkForUpdates:",
target: Target::AppDelegate,
}),
outlet: Some(Outlet::MenuCheckForUpdates),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Preferences…",
key_equivalent: ",",
modifiers: None,
action: Some(Action {
selector: "openConfig:",
target: Target::AppDelegate,
}),
outlet: Some(Outlet::MenuOpenConfig),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Reload Configuration",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "reloadConfig:",
target: Target::AppDelegate,
}),
outlet: Some(Outlet::MenuReloadConfig),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Secure Keyboard Entry",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "toggleSecureInput:",
target: Target::AppDelegate,
}),
outlet: Some(Outlet::MenuSecureInput),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Make Ghostty the Default Terminal",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "setAsDefaultTerminal:",
target: Target::AppDelegate,
}),
outlet: Some(Outlet::MenuSetAsDefaultTerminal),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Services",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: None,
submenu: Some(&MenuSpec {
title: "Services",
system_menu: None,
outlet: Some(Outlet::MenuServices),
items: &[],
}),
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Hide Ghostty",
key_equivalent: "h",
modifiers: None,
action: Some(Action {
selector: "hide:",
target: Target::FirstResponder,
}),
outlet: None,
submenu: None,
}),
ItemSpec::Item(Item {
title: "Hide Others",
key_equivalent: "h",
modifiers: Some(Mods(Mods::OPTION.0 | Mods::COMMAND.0)),
action: Some(Action {
selector: "hideOtherApplications:",
target: Target::FirstResponder,
}),
outlet: None,
submenu: None,
}),
ItemSpec::Item(Item {
title: "Show All",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "unhideAllApplications:",
target: Target::FirstResponder,
}),
outlet: None,
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Quit Ghostty",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "terminate:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuQuit),
submenu: None,
}),
],
}),
}),
ItemSpec::Item(Item {
title: "File",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: None,
submenu: Some(&MenuSpec {
title: "File",
system_menu: None,
outlet: None,
items: &[
ItemSpec::Item(Item {
title: "New Window",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "newWindow:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuNewWindow),
submenu: None,
}),
ItemSpec::Item(Item {
title: "New Tab",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "newTab:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuNewTab),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Split Right",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitRight:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSplitRight),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Split Left",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitLeft:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSplitLeft),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Split Down",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitDown:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSplitDown),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Split Up",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitUp:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSplitUp),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Close",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "close:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuClose),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Close Tab",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "closeTab:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuCloseTab),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Close Window",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "closeWindow:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuCloseWindow),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Close All Windows",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "closeAllWindows:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuCloseAllWindows),
submenu: None,
}),
],
}),
}),
ItemSpec::Item(Item {
title: "Edit",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: None,
submenu: Some(&MenuSpec {
title: "Edit",
system_menu: None,
outlet: None,
items: &[
ItemSpec::Item(Item {
title: "Undo",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "undo:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuUndo),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Redo",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "redo:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuRedo),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Copy",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "copy:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuCopy),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Paste",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "paste:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuPaste),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Paste Selection",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "pasteSelection:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuPasteSelection),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Select All",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "selectAll:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSelectAll),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Find",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: Some(Outlet::MenuFindParent),
submenu: Some(&MenuSpec {
title: "Find",
system_menu: None,
outlet: None,
items: &[
ItemSpec::Item(Item {
title: "Find...",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "find:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuFind),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Find Next",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "findNext:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuFindNext),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Find Previous",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "findPrevious:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuFindPrevious),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Hide Find Bar",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "findHide:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuHideFindBar),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Use Selection for Find",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "selectionForFind:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSelectionForSearch),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Jump to Selection",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "scrollToSelection:",
target: Target::FirstResponder,
}),
outlet: None,
submenu: None,
}),
],
}),
}),
ItemSpec::Separator,
],
}),
}),
ItemSpec::Item(Item {
title: "View",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: None,
submenu: Some(&MenuSpec {
title: "View",
system_menu: None,
outlet: None,
items: &[
ItemSpec::Item(Item {
title: "Reset Font Size",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "resetFontSize:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuResetFontSize),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Increase Font Size",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "increaseFontSize:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuIncreaseFontSize),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Decrease Font Size",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "decreaseFontSize:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuDecreaseFontSize),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Command Palette",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "toggleCommandPalette:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuCommandPalette),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Change Tab Title...",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "changeTabTitle:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuChangeTabTitle),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Change Terminal Title...",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "changeTitle:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuChangeTitle),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Terminal Read-only",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "toggleReadonly:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuReadonly),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Quick Terminal",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "toggleQuickTerminal:",
target: Target::AppDelegate,
}),
outlet: Some(Outlet::MenuQuickTerminal),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Terminal Inspector",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "toggleTerminalInspector:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuTerminalInspector),
submenu: None,
}),
],
}),
}),
ItemSpec::Item(Item {
title: "Window",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: None,
submenu: Some(&MenuSpec {
title: "Window",
system_menu: Some(SystemMenu::Window),
outlet: None,
items: &[
ItemSpec::Item(Item {
title: "Minimize",
key_equivalent: "m",
modifiers: None,
action: Some(Action {
selector: "performMiniaturize:",
target: Target::FirstResponder,
}),
outlet: None,
submenu: None,
}),
ItemSpec::Item(Item {
title: "Zoom",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "performZoom:",
target: Target::FirstResponder,
}),
outlet: None,
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Toggle Full Screen",
key_equivalent: "f",
modifiers: Some(Mods(Mods::CONTROL.0 | Mods::COMMAND.0)),
action: Some(Action {
selector: "toggleGhosttyFullScreen:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuToggleFullScreen),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Show/Hide All Terminals",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "toggleVisibility:",
target: Target::AppDelegate,
}),
outlet: Some(Outlet::MenuToggleVisibility),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Zoom Split",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitZoom:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuZoomSplit),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Select Previous Split",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitMoveFocusPrevious:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuPreviousSplit),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Select Next Split",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitMoveFocusNext:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuNextSplit),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Select Split",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: None,
submenu: Some(&MenuSpec {
title: "Select Split",
system_menu: None,
outlet: None,
items: &[
ItemSpec::Item(Item {
title: "Select Split Above",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitMoveFocusAbove:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSelectSplitAbove),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Select Split Below",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitMoveFocusBelow:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSelectSplitBelow),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Select Split Left",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitMoveFocusLeft:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSelectSplitLeft),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Select Split Right",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "splitMoveFocusRight:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuSelectSplitRight),
submenu: None,
}),
],
}),
}),
ItemSpec::Item(Item {
title: "Resize Split",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: None,
submenu: Some(&MenuSpec {
title: "Resize Split",
system_menu: None,
outlet: None,
items: &[
ItemSpec::Item(Item {
title: "Equalize Splits",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "equalizeSplits:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuEqualizeSplits),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Move Divider Up",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "moveSplitDividerUp:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuMoveSplitDividerUp),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Move Divider Down",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "moveSplitDividerDown:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuMoveSplitDividerDown),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Move Divider Left",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "moveSplitDividerLeft:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuMoveSplitDividerLeft),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Move Divider Right",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "moveSplitDividerRight:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuMoveSplitDividerRight),
submenu: None,
}),
],
}),
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Return To Default Size",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "returnToDefaultSize:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuReturnToDefaultSize),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Float on Top",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "floatOnTop:",
target: Target::AppDelegate,
}),
outlet: Some(Outlet::MenuFloatOnTop),
submenu: None,
}),
ItemSpec::Item(Item {
title: "Use as Default",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "useAsDefault:",
target: Target::AppDelegate,
}),
outlet: Some(Outlet::MenuUseAsDefault),
submenu: None,
}),
ItemSpec::Separator,
ItemSpec::Item(Item {
title: "Bring All to Front",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: Some(Action {
selector: "arrangeInFront:",
target: Target::FirstResponder,
}),
outlet: Some(Outlet::MenuBringAllToFront),
submenu: None,
}),
],
}),
}),
ItemSpec::Item(Item {
title: "Help",
key_equivalent: "",
modifiers: Some(Mods::NONE),
action: None,
outlet: None,
submenu: Some(&MenuSpec {
title: "Help",
system_menu: Some(SystemMenu::Help),
outlet: None,
items: &[ItemSpec::Item(Item {
title: "Ghostty Help",
key_equivalent: "?",
modifiers: None,
action: Some(Action {
selector: "showHelp:",
target: Target::AppDelegate,
}),
outlet: None,
submenu: None,
})],
}),
}),
],
};
/// All outlets connected in the xib (the Swift names).
pub const CONNECTED_OUTLETS: &[Outlet] = &[
Outlet::MenuAbout,
Outlet::MenuBringAllToFront,
Outlet::MenuChangeTabTitle,
Outlet::MenuChangeTitle,
Outlet::MenuCheckForUpdates,
Outlet::MenuClose,
Outlet::MenuCloseAllWindows,
Outlet::MenuCloseTab,
Outlet::MenuCloseWindow,
Outlet::MenuCommandPalette,
Outlet::MenuCopy,
Outlet::MenuDecreaseFontSize,
Outlet::MenuEqualizeSplits,
Outlet::MenuFind,
Outlet::MenuFindNext,
Outlet::MenuFindParent,
Outlet::MenuFindPrevious,
Outlet::MenuFloatOnTop,
Outlet::MenuHideFindBar,
Outlet::MenuIncreaseFontSize,
Outlet::MenuMoveSplitDividerDown,
Outlet::MenuMoveSplitDividerLeft,
Outlet::MenuMoveSplitDividerRight,
Outlet::MenuMoveSplitDividerUp,
Outlet::MenuNewTab,
Outlet::MenuNewWindow,
Outlet::MenuNextSplit,
Outlet::MenuOpenConfig,
Outlet::MenuPaste,
Outlet::MenuPasteSelection,
Outlet::MenuPreviousSplit,
Outlet::MenuQuickTerminal,
Outlet::MenuQuit,
Outlet::MenuReadonly,
Outlet::MenuRedo,
Outlet::MenuReloadConfig,
Outlet::MenuResetFontSize,
Outlet::MenuReturnToDefaultSize,
Outlet::MenuSecureInput,
Outlet::MenuSelectAll,
Outlet::MenuSelectSplitAbove,
Outlet::MenuSelectSplitBelow,
Outlet::MenuSelectSplitLeft,
Outlet::MenuSelectSplitRight,
Outlet::MenuSelectionForSearch,
Outlet::MenuServices,
Outlet::MenuSetAsDefaultTerminal,
Outlet::MenuSplitDown,
Outlet::MenuSplitLeft,
Outlet::MenuSplitRight,
Outlet::MenuSplitUp,
Outlet::MenuTerminalInspector,
Outlet::MenuToggleFullScreen,
Outlet::MenuToggleVisibility,
Outlet::MenuUndo,
Outlet::MenuUseAsDefault,
Outlet::MenuZoomSplit,
];
@@ -0,0 +1,426 @@
//! `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());
}
}
+13
View File
@@ -0,0 +1,13 @@
//! `Sources/App/macOS`: `main.swift`, `AppDelegate.swift`,
//! `AppDelegate+Ghostty.swift` and `MainMenu.xib`.
#[cfg(target_vendor = "apple")]
pub mod app_delegate;
#[cfg(target_vendor = "apple")]
pub mod app_delegate_ghostty;
#[cfg(target_vendor = "apple")]
pub mod entry;
#[cfg(target_vendor = "apple")]
pub mod main_menu;
pub mod menu_spec;
#[cfg(target_vendor = "apple")]
pub mod support;
+81
View File
@@ -0,0 +1,81 @@
//! Small AppKit/Foundation operations `AppDelegate` needs that Swift gets from
//! framework conveniences (typed `register(defaults:)`, `Bundle.infoDictionary`,
//! `NSWorkspace.setDefaultApplication(at:toOpen:)` with a `UTType`).
#![allow(unsafe_code)]
use block2::RcBlock;
use objc2::{
msg_send,
rc::Retained,
runtime::{AnyClass, AnyObject},
};
use objc2_app_kit::NSWorkspace;
use objc2_foundation::{NSBundle, NSDictionary, NSError, NSNumber, NSString, NSURL};
use crate::helpers::extensions::user_defaults;
/// `UserDefaults.ghostty.register(defaults: [key: Bool])`.
pub fn register_defaults(defaults: &[(&str, bool)]) {
let keys: Vec<Retained<NSString>> = defaults
.iter()
.map(|(k, _)| NSString::from_str(k))
.collect();
let key_refs: Vec<&NSString> = keys.iter().map(|k| &**k).collect();
let values: Vec<Retained<AnyObject>> = defaults
.iter()
.map(|(_, v)| {
let n = NSNumber::new_bool(*v);
// SAFETY: an NSNumber is an Objective-C object.
unsafe { Retained::cast_unchecked::<AnyObject>(n) }
})
.collect();
let dict: Retained<NSDictionary<NSString, AnyObject>> =
NSDictionary::from_retained_objects(&key_refs, &values);
// SAFETY: `registerDefaults:` takes an NSDictionary of property-list values.
unsafe { user_defaults::ghostty().registerDefaults(&dict) };
}
/// `UserDefaults.ghostty.removePersistentDomain(forName: suite)` (debug builds).
pub fn remove_persistent_domain_for_ghostty_suite() {
if let Some(suite) = user_defaults::ghostty_suite() {
user_defaults::ghostty().removePersistentDomainForName(&NSString::from_str(&suite));
}
}
/// `Bundle.main.infoDictionary?[key] as? Bool`.
#[must_use]
pub fn bundle_info_bool(key: &str) -> Option<bool> {
let info = NSBundle::mainBundle().infoDictionary()?;
let value = info.objectForKey(&NSString::from_str(key))?;
let number = value.downcast_ref::<NSNumber>()?;
Some(number.boolValue())
}
/// `NSWorkspace.shared.setDefaultApplication(at: Bundle.main.bundleURL,
/// toOpen: .unixExecutable) { error in ... }`.
pub fn set_default_terminal(handler: &RcBlock<dyn Fn(*mut NSError)>) {
let Some(ut_type) = AnyClass::get(c"UTType") else {
log::warn!("UTType is unavailable");
return;
};
let identifier = NSString::from_str("public.unix-executable");
// SAFETY: `+[UTType typeWithIdentifier:]` takes an NSString and returns UTType?.
let content_type: Option<Retained<AnyObject>> =
unsafe { msg_send![ut_type, typeWithIdentifier: &*identifier] };
let Some(content_type) = content_type else {
return;
};
let url: Retained<NSURL> = NSBundle::mainBundle().bundleURL();
let workspace = NSWorkspace::sharedWorkspace();
// SAFETY: selector and argument types are those of
// `-setDefaultApplicationAtURL:toOpenContentType:completionHandler:`.
let _: () = unsafe {
msg_send![
&*workspace,
setDefaultApplicationAtURL: &*url,
toOpenContentType: &*content_type,
completionHandler: &**handler
]
};
}
@@ -0,0 +1,26 @@
//! `Sources/Features/About` (owner: MA4).
//! MA1 provides the `AboutController` surface `AppDelegate` calls.
//! TODO(MA4): AboutController.swift/About.xib/AboutView/CyclingIconView.
/// `AboutController.shared`.
#[derive(Default)]
pub struct AboutController;
thread_local! {
static SHARED: std::rc::Rc<AboutController> = std::rc::Rc::default();
}
impl AboutController {
#[must_use]
pub fn shared() -> std::rc::Rc<Self> {
SHARED.with(std::rc::Rc::clone)
}
/// `show()`. TODO(MA4)
pub fn show(&self) {
log::warn!("TODO(MA4): AboutController.show");
}
/// `hide()`. TODO(MA4)
pub fn hide(&self) {}
}
@@ -0,0 +1 @@
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
@@ -0,0 +1 @@
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
@@ -0,0 +1 @@
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
@@ -0,0 +1 @@
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
@@ -0,0 +1,28 @@
//! `Sources/Features/Custom App Icon` (owner: MA4).
//! MA1 provides only the notification name and the `AppDelegate.updateAppIcon`
//! hook. TODO(MA4): AppIcon, ColorizedGhosttyIcon, DockTilePlugin, UserDefaults+AppIcon.
use crate::helpers::notification_center::Name;
/// `Notification.Name.ghosttyIconDidChange` (posted on the *distributed*
/// notification center).
pub const GHOSTTY_ICON_DID_CHANGE: Name = Name("com.mitchellh.ghostty.iconDidChange");
/// Posts `.ghosttyIconDidChange` on the distributed notification center
/// (`DistributedNotificationCenter.default().postNotificationName(...,
/// deliverImmediately: true)`), which the dock tile plugin observes.
#[cfg(target_vendor = "apple")]
#[allow(unsafe_code)]
pub fn post_icon_did_change() {
use objc2_foundation::{NSDistributedNotificationCenter, NSString};
let center = NSDistributedNotificationCenter::defaultCenter();
// SAFETY: a name string, no object/userInfo.
unsafe {
center.postNotificationName_object_userInfo_deliverImmediately(
&NSString::from_str(GHOSTTY_ICON_DID_CHANGE.raw_value()),
None,
None,
true,
);
}
}
@@ -0,0 +1,28 @@
//! `Sources/Features/Global Keybinds` (owner: MA4).
//! MA1 provides the `GlobalEventTap.shared` surface `AppDelegate` uses.
//! TODO(MA4): CGEventTap implementation.
use std::rc::Rc;
/// `GlobalEventTap`.
#[derive(Default)]
pub struct GlobalEventTap;
thread_local! {
static SHARED: Rc<GlobalEventTap> = Rc::default();
}
impl GlobalEventTap {
#[must_use]
pub fn shared() -> Rc<Self> {
SHARED.with(Rc::clone)
}
/// `enable()`. TODO(MA4)
pub fn enable(&self) {
log::warn!("TODO(MA4): GlobalEventTap.enable");
}
/// `disable()`. TODO(MA4)
pub fn disable(&self) {}
}
+20
View File
@@ -0,0 +1,20 @@
//! `Sources/Features/<Area>`: one submodule per Swift feature folder.
//!
//! Each area is owned by one agent (see `docs/rewrite/notes/macos-app-design.md`);
//! until it is ported the module holds only the surface that the foundation
//! (`app`, `ghostty`) calls into, marked `TODO(<owner>)`.
pub mod about;
pub mod app_intents;
pub mod applescript;
pub mod clipboard_confirmation;
pub mod command_palette;
pub mod custom_app_icon;
pub mod global_keybinds;
pub mod quick_terminal;
pub mod secure_input;
#[cfg(target_vendor = "apple")]
pub mod services;
pub mod settings;
pub mod splits;
pub mod terminal;
pub mod update;
@@ -0,0 +1,83 @@
//! Minimal `QuickTerminalController` surface used by `AppDelegate`
//! (`quickController`, `toggleQuickTerminal`, state restoration).
//! TODO(MA4): port QuickTerminalController.swift, QuickTerminalWindow,
//! QuickTerminalScreenStateCache, the restorable state and the animations.
#![allow(unsafe_code)]
use std::{cell::Cell, rc::Rc};
use objc2::{DefinedClass, MainThreadOnly, define_class, msg_send, rc::Retained};
use objc2_app_kit::NSWindowController;
use super::QuickTerminalPosition;
use crate::ghostty::{app::App, surface_configuration::SurfaceConfiguration};
/// `QuickTerminalRestorableState` (TODO(MA4): surface tree, screen state).
#[derive(Clone, Debug, Default)]
pub struct QuickTerminalRestorableState;
impl QuickTerminalRestorableState {
/// `baseConfig`.
#[must_use]
pub fn base_config(&self) -> Option<SurfaceConfiguration> {
let mut config = SurfaceConfiguration::default();
config
.environment_variables
.insert("GHOSTTY_QUICK_TERMINAL".to_string(), "1".to_string());
Some(config)
}
}
/// Instance variables of [`QuickTerminalController`].
#[derive(Default)]
pub struct Ivars {
visible: Cell<bool>,
position: Cell<QuickTerminalPosition>,
}
define_class!(
// SAFETY: NSWindowController has no subclassing requirements we violate and
// the class does not implement Drop.
#[unsafe(super(NSWindowController))]
#[thread_kind = MainThreadOnly]
#[name = "QuickTerminalController"]
#[ivars = Ivars]
pub struct QuickTerminalController;
impl QuickTerminalController {}
);
impl QuickTerminalController {
/// `init(_ ghostty:position:baseConfig:restorationState:)`.
pub fn new(
_ghostty: &Rc<App>,
position: QuickTerminalPosition,
_base_config: Option<SurfaceConfiguration>,
_restoration_state: Option<&QuickTerminalRestorableState>,
) -> Retained<Self> {
let mtm = crate::helpers::main_thread();
let ivars = Ivars::default();
ivars.position.set(position);
let this = Self::alloc(mtm).set_ivars(ivars);
// SAFETY: `init` is NSWindowController's designated initializer.
unsafe { msg_send![super(this), init] }
}
/// `visible`.
#[must_use]
pub fn visible(&self) -> bool {
self.ivars().visible.get()
}
/// `restorable`.
#[must_use]
pub fn restorable(&self) -> bool {
false
}
/// `toggle()`. TODO(MA4)
pub fn toggle(&self) {
log::warn!("TODO(MA4): QuickTerminalController.toggle");
}
}
@@ -0,0 +1,22 @@
//! `Sources/Features/QuickTerminal` (owner: MA4).
//!
//! MA1 ports only what the foundation needs: the pure config value types
//! ([`QuickTerminalPosition`], [`QuickTerminalScreen`], [`QuickTerminalSize`],
//! [`QuickTerminalSpaceBehavior`]) and the controller surface `AppDelegate`
//! uses (`controller`), which MA4 replaces.
#[cfg(target_vendor = "apple")]
pub mod controller;
pub mod position;
pub mod screen;
pub mod size;
pub mod space_behavior;
pub use position::QuickTerminalPosition;
pub use screen::QuickTerminalScreen;
pub use size::QuickTerminalSize;
pub use space_behavior::QuickTerminalSpaceBehavior;
/// `Notification.Name.quickTerminalDidChangeVisibility`.
pub const QUICK_TERMINAL_DID_CHANGE_VISIBILITY: crate::helpers::notification_center::Name =
crate::helpers::notification_center::Name("QuickTerminalDidChangeVisibility");
@@ -0,0 +1,40 @@
//! `QuickTerminalPosition.swift`: the `quick-terminal-position` value.
//! The window animation helpers (`setLoaded`, `setInitial`, `animateIn`, ...)
//! are AppKit and belong to MA4 (TODO(MA4)).
/// `enum QuickTerminalPosition: String`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum QuickTerminalPosition {
#[default]
Top,
Bottom,
Left,
Right,
Center,
}
impl QuickTerminalPosition {
/// `init?(rawValue:)`.
#[must_use]
pub fn from_raw_value(s: &str) -> Option<Self> {
match s {
"top" => Some(Self::Top),
"bottom" => Some(Self::Bottom),
"left" => Some(Self::Left),
"right" => Some(Self::Right),
"center" => Some(Self::Center),
_ => None,
}
}
#[must_use]
pub const fn raw_value(self) -> &'static str {
match self {
Self::Top => "top",
Self::Bottom => "bottom",
Self::Left => "left",
Self::Right => "right",
Self::Center => "center",
}
}
}
@@ -0,0 +1,24 @@
//! `QuickTerminalScreen.swift`: the `quick-terminal-screen` value.
//! The `screen` accessor (NSScreen lookup) is AppKit: TODO(MA4).
/// `enum QuickTerminalScreen`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum QuickTerminalScreen {
#[default]
Main,
Mouse,
MenuBar,
}
impl QuickTerminalScreen {
/// `init?(fromGhosttyConfig:)`.
#[must_use]
pub fn from_ghostty_config(s: &str) -> Option<Self> {
match s {
"main" => Some(Self::Main),
"mouse" => Some(Self::Mouse),
"macos-menu-bar" => Some(Self::MenuBar),
_ => None,
}
}
}
@@ -0,0 +1,173 @@
//! `QuickTerminalSize.swift`: the `quick-terminal-size` value.
use libghostty::types::{
ghostty_config_quick_terminal_size_s,
ghostty_quick_terminal_size_s,
quick_terminal_size_tag,
};
use super::QuickTerminalPosition;
/// A width/height in points (`CGSize`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Size2 {
pub width: f64,
pub height: f64,
}
/// `QuickTerminalSize.Size`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Size {
Percentage(f32),
Pixels(u32),
}
impl Size {
/// `init?(from: ghostty_quick_terminal_size_s)`.
#[must_use]
pub fn from_c(c: &ghostty_quick_terminal_size_s) -> Option<Self> {
match c.tag {
quick_terminal_size_tag::PERCENTAGE => {
// SAFETY: `tag` says the `percentage` member is the active one.
#[allow(unsafe_code)]
Some(Self::Percentage(unsafe { c.value.percentage }))
}
quick_terminal_size_tag::PIXELS => {
// SAFETY: `tag` says the `pixels` member is the active one.
#[allow(unsafe_code)]
Some(Self::Pixels(unsafe { c.value.pixels }))
}
// NONE (and anything unknown: Swift asserts and returns nil).
_ => None,
}
}
#[must_use]
pub fn to_pixels(self, parent_dimension: f64) -> f64 {
match self {
Self::Percentage(v) => parent_dimension * f64::from(v) / 100.0,
Self::Pixels(v) => f64::from(v),
}
}
}
/// `struct QuickTerminalSize`.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct QuickTerminalSize {
pub primary: Option<Size>,
pub secondary: Option<Size>,
}
impl QuickTerminalSize {
#[must_use]
pub fn from_c(c: &ghostty_config_quick_terminal_size_s) -> Self {
Self {
primary: Size::from_c(&c.primary),
secondary: Size::from_c(&c.secondary),
}
}
/// An almost direct port of the Zig function `QuickTerminalSize.calculate`.
#[must_use]
pub fn calculate(&self, position: QuickTerminalPosition, screen: Size2) -> Size2 {
let dims = screen;
match position {
QuickTerminalPosition::Left | QuickTerminalPosition::Right => Size2 {
width: self.primary.map_or(400.0, |s| s.to_pixels(dims.width)),
height: self
.secondary
.map_or(dims.height, |s| s.to_pixels(dims.height)),
},
QuickTerminalPosition::Top | QuickTerminalPosition::Bottom => Size2 {
width: self
.secondary
.map_or(dims.width, |s| s.to_pixels(dims.width)),
height: self.primary.map_or(400.0, |s| s.to_pixels(dims.height)),
},
QuickTerminalPosition::Center => {
if dims.width >= dims.height {
// Landscape
Size2 {
width: self.primary.map_or(800.0, |s| s.to_pixels(dims.width)),
height: self.secondary.map_or(400.0, |s| s.to_pixels(dims.height)),
}
} else {
// Portrait
Size2 {
width: self.secondary.map_or(400.0, |s| s.to_pixels(dims.width)),
height: self.primary.map_or(800.0, |s| s.to_pixels(dims.height)),
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SCREEN: Size2 = Size2 {
width: 1000.0,
height: 600.0,
};
#[test]
fn defaults_per_position() {
let s = QuickTerminalSize::default();
assert_eq!(
s.calculate(QuickTerminalPosition::Top, SCREEN),
Size2 {
width: 1000.0,
height: 400.0
}
);
assert_eq!(
s.calculate(QuickTerminalPosition::Left, SCREEN),
Size2 {
width: 400.0,
height: 600.0
}
);
assert_eq!(
s.calculate(QuickTerminalPosition::Center, SCREEN),
Size2 {
width: 800.0,
height: 400.0
}
);
let portrait = Size2 {
width: 600.0,
height: 1000.0,
};
assert_eq!(
s.calculate(QuickTerminalPosition::Center, portrait),
Size2 {
width: 400.0,
height: 800.0
}
);
}
#[test]
fn explicit_sizes() {
let s = QuickTerminalSize {
primary: Some(Size::Percentage(50.0)),
secondary: Some(Size::Pixels(300)),
};
assert_eq!(
s.calculate(QuickTerminalPosition::Bottom, SCREEN),
Size2 {
width: 300.0,
height: 300.0
}
);
assert_eq!(
s.calculate(QuickTerminalPosition::Right, SCREEN),
Size2 {
width: 500.0,
height: 300.0
}
);
}
}
@@ -0,0 +1,37 @@
//! `QuickTerminalSpaceBehavior.swift`: the `quick-terminal-space-behavior` value.
/// `enum QuickTerminalSpaceBehavior`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum QuickTerminalSpaceBehavior {
Remain,
#[default]
Move,
}
impl QuickTerminalSpaceBehavior {
/// `init?(fromGhosttyConfig:)`.
#[must_use]
pub fn from_ghostty_config(s: &str) -> Option<Self> {
match s {
"move" => Some(Self::Move),
"remain" => Some(Self::Remain),
_ => None,
}
}
}
#[cfg(target_vendor = "apple")]
impl QuickTerminalSpaceBehavior {
/// `collectionBehavior`.
#[must_use]
pub fn collection_behavior(self) -> objc2_app_kit::NSWindowCollectionBehavior {
use objc2_app_kit::NSWindowCollectionBehavior as B;
let common = B::IgnoresCycle | B::FullScreenAuxiliary;
match self {
// We want this to move the window to the active space.
Self::Move => B::CanJoinAllSpaces | common,
// We want this to remain the window in the current space.
Self::Remain => B::MoveToActiveSpace | common,
}
}
}
@@ -0,0 +1,41 @@
//! `Sources/Features/Secure Input` (owner: MA4).
//! MA1 provides the `SecureInput.shared` surface `AppDelegate` uses
//! (`global`, `enabled`). TODO(MA4): Carbon `EnableSecureEventInput` handling,
//! scoped objects, activation observers and the overlay.
use std::{cell::Cell, rc::Rc};
/// `SecureInput`.
#[derive(Default)]
pub struct SecureInput {
global: Cell<bool>,
enabled: Cell<bool>,
}
thread_local! {
static SHARED: Rc<SecureInput> = Rc::default();
}
impl SecureInput {
/// `shared`.
#[must_use]
pub fn shared() -> Rc<Self> {
SHARED.with(Rc::clone)
}
#[must_use]
pub fn global(&self) -> bool {
self.global.get()
}
/// `global` setter (`didSet { apply() }`). TODO(MA4): apply.
pub fn set_global(&self, v: bool) {
self.global.set(v);
}
/// `enabled`.
#[must_use]
pub fn enabled(&self) -> bool {
self.enabled.get()
}
}
@@ -0,0 +1,29 @@
//! `Sources/Features/Services` (owner: MA4): `ServiceProvider`
//! (`NSApp.servicesProvider`, the "New Ghostty Tab/Window Here" services).
#![allow(unsafe_code)]
use objc2::{MainThreadOnly, define_class, msg_send, rc::Retained};
use objc2_foundation::NSObject;
define_class!(
// SAFETY: NSObject has no subclassing requirements; no Drop.
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "ServiceProvider"]
pub struct ServiceProvider;
impl ServiceProvider {
// TODO(MA4): openTab:userData:error: and openWindow:userData:error:
// (ServiceProvider.swift) once the terminal controllers exist.
}
);
impl ServiceProvider {
#[must_use]
pub fn new() -> Retained<Self> {
let this = Self::alloc(crate::helpers::main_thread());
// SAFETY: NSObject's `init`.
unsafe { msg_send![this, init] }
}
}
@@ -0,0 +1,56 @@
//! `Sources/Features/Settings` (owner: MA4).
//!
//! MA1 provides the `ConfigurationErrorsController` surface that
//! `AppDelegate.ghosttyConfigDidChange` drives. TODO(MA4): the window
//! (ConfigurationErrors.xib + SwiftUI view), SettingsView.
use std::cell::RefCell;
/// `ConfigurationErrorsController.sharedInstance` (data model only).
#[derive(Default)]
pub struct ConfigurationErrorsController {
errors: RefCell<Vec<String>>,
window_visible: std::cell::Cell<bool>,
}
thread_local! {
static SHARED: std::rc::Rc<ConfigurationErrorsController> = std::rc::Rc::default();
}
impl ConfigurationErrorsController {
/// `sharedInstance`.
#[must_use]
pub fn shared_instance() -> std::rc::Rc<Self> {
SHARED.with(std::rc::Rc::clone)
}
/// `errors` getter.
#[must_use]
pub fn errors(&self) -> Vec<String> {
self.errors.borrow().clone()
}
/// `errors` setter: with no errors the window closes (`didSet`).
pub fn set_errors(&self, errors: Vec<String>) {
let empty = errors.is_empty();
*self.errors.borrow_mut() = errors;
if empty {
self.window_visible.set(false);
}
}
/// `window == nil || !window!.isVisible`.
#[must_use]
pub fn is_window_visible(&self) -> bool {
self.window_visible.get()
}
/// `showWindow(_:)`. TODO(MA4)
pub fn show_window(&self) {
log::warn!(
"TODO(MA4): ConfigurationErrorsController.showWindow ({} errors)",
self.errors.borrow().len()
);
self.window_visible.set(true);
}
}
@@ -0,0 +1 @@
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
@@ -0,0 +1,178 @@
//! `Sources/Features/Terminal`: terminal windows and their controllers.
//!
//! TODO(MA3): this file is the *foundation stub* written by MA1 so that
//! `Ghostty.App`'s action glue and the app delegate compile. It declares the
//! classes with the API surface they use, logs `TODO(MA3)` and returns
//! conservative values. MA3 replaces every body with the real port of
//! `BaseTerminalController.swift`, `TerminalController.swift`,
//! `Window Styles/TerminalWindow.swift` etc., keeping these signatures.
#![cfg(target_vendor = "apple")]
// Stubs: bodies do not use `self` yet.
#![allow(clippy::unused_self)]
use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use log::warn;
use objc2::{DefinedClass, MainThreadOnly, define_class, rc::Retained};
use objc2_app_kit::{NSResponder, NSWindow, NSWindowController};
use objc2_foundation::NSObject;
use crate::ghostty::{
app::App,
package::SplitFocusDirection,
surface_configuration::SurfaceConfiguration,
surface_view::SurfaceView,
};
/// Instance variables of [`BaseTerminalController`].
#[derive(Default)]
pub struct BaseTerminalControllerIvars {
/// `bell` (TODO(MA3): published, drives the title bell indicator).
pub bell: Cell<bool>,
/// `titleOverride`.
pub title_override: RefCell<Option<String>>,
}
define_class!(
/// `BaseTerminalController`: the base class of all terminal window controllers.
// SAFETY: NSWindowController has no subclassing requirements we violate;
// no Drop impl.
#[unsafe(super(NSWindowController, NSResponder, NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "GhosttyBaseTerminalController"]
#[ivars = BaseTerminalControllerIvars]
pub struct BaseTerminalController;
);
impl BaseTerminalController {
/// `surfaceTree.isSplit`.
#[must_use]
pub fn surface_tree_is_split(&self) -> bool {
warn!("TODO(MA3): BaseTerminalController::surface_tree_is_split");
false
}
/// `surfaceTree.focusTarget(for:from:) != nil` for the node holding `view`
/// (App.gotoSplit's performability check).
#[must_use]
pub fn focus_target_exists(
&self,
_direction: SplitFocusDirection,
_view: &SurfaceView,
) -> bool {
warn!("TODO(MA3): BaseTerminalController::focus_target_exists");
false
}
/// `surfaceTree` as a flat list of its surface views (iterating the tree).
#[must_use]
pub fn surface_tree(&self) -> Vec<Retained<SurfaceView>> {
warn!("TODO(MA3): BaseTerminalController::surface_tree");
Vec::new()
}
/// `focusedSurface`.
#[must_use]
pub fn focused_surface(&self) -> Option<Retained<SurfaceView>> {
warn!("TODO(MA3): BaseTerminalController::focused_surface");
None
}
/// `toggleBackgroundOpacity()`.
pub fn toggle_background_opacity(&self) {
warn!("TODO(MA3): BaseTerminalController::toggle_background_opacity");
}
/// `promptTabTitle()`.
pub fn prompt_tab_title(&self) {
warn!("TODO(MA3): BaseTerminalController::prompt_tab_title");
}
/// `titleOverride = ...`.
pub fn set_title_override(&self, title: Option<String>) {
*self.ivars().title_override.borrow_mut() = title;
warn!("TODO(MA3): BaseTerminalController::set_title_override (title not applied)");
}
/// `bell`.
#[must_use]
pub fn bell(&self) -> bool {
self.ivars().bell.get()
}
/// `relabelTabs()`.
pub fn relabel_tabs(&self) {
warn!("TODO(MA3): BaseTerminalController::relabel_tabs");
}
}
define_class!(
/// `TerminalController`: the controller of a regular terminal window.
// SAFETY: as above.
#[unsafe(super(BaseTerminalController, NSWindowController, NSResponder, NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "GhosttyTerminalController"]
#[ivars = ()]
pub struct TerminalController;
);
impl TerminalController {
/// `TerminalController.all`: every terminal controller, in creation order.
#[must_use]
pub fn all() -> Vec<Retained<TerminalController>> {
Vec::new()
}
/// `TerminalController.newWindow(_:withBaseConfig:)`.
#[must_use]
pub fn new_window(
_ghostty: &Rc<App>,
_base_config: Option<&SurfaceConfiguration>,
) -> Option<Retained<TerminalController>> {
warn!("TODO(MA3): TerminalController::new_window");
None
}
/// `TerminalController.newTab(_:from:withBaseConfig:)`.
#[must_use]
pub fn new_tab(
_ghostty: &Rc<App>,
_from: Option<&NSWindow>,
_base_config: Option<&SurfaceConfiguration>,
) -> Option<Retained<TerminalController>> {
warn!("TODO(MA3): TerminalController::new_tab");
None
}
/// `TerminalController.preferredParent`.
#[must_use]
pub fn preferred_parent() -> Option<Retained<TerminalController>> {
None
}
/// `TerminalController.closeAllWindows()`.
pub fn close_all_windows() {
warn!("TODO(MA3): TerminalController::close_all_windows");
}
}
/// `TerminalWindow.defaultLevelKey`.
pub const DEFAULT_LEVEL_KEY: &str = "TerminalDefaultLevel";
define_class!(
/// `TerminalWindow`: the NSWindow subclass of terminal windows.
// SAFETY: as above.
#[unsafe(super(NSWindow, NSResponder, NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "GhosttyTerminalWindow"]
#[ivars = ()]
pub struct TerminalWindow;
);
impl TerminalWindow {
/// `TerminalWindow.defaultLevelKey`.
pub const DEFAULT_LEVEL_KEY: &'static str = DEFAULT_LEVEL_KEY;
}
@@ -0,0 +1,38 @@
//! `Sources/Features/Update` (owner: MA5; Sparkle via the ObjC runtime).
//!
//! MA1 only provides the `UpdateController` surface `AppDelegate` calls.
//! TODO(MA5): everything (UpdateController, driver, view model, pill/popover UI).
use std::cell::Cell;
/// `UpdateController` (Sparkle's `SPUUpdater` wrapper). `updater` settings are
/// stored so `ghosttyConfigDidChange` can apply them once MA5 wires Sparkle.
#[derive(Default)]
pub struct UpdateController {
pub automatically_checks_for_updates: Cell<bool>,
pub automatically_downloads_updates: Cell<bool>,
installing: Cell<bool>,
}
impl UpdateController {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// `startUpdater()`. TODO(MA5)
pub fn start_updater(&self) {
log::debug!("TODO(MA5): UpdateController.startUpdater");
}
/// `checkForUpdates()`. TODO(MA5)
pub fn check_for_updates(&self) {
log::warn!("TODO(MA5): UpdateController.checkForUpdates");
}
/// `isInstalling`.
#[must_use]
pub fn is_installing(&self) -> bool {
self.installing.get()
}
}
+358
View File
@@ -0,0 +1,358 @@
//! `Ghostty.Action` (`Ghostty.Action.swift`): Swift-side value types for the
//! payloads of `ghostty_action_s`.
#![allow(unsafe_code, clippy::wildcard_imports)]
use std::ffi::{CStr, c_int};
use super::capi::{
consts::*,
ghostty_action_color_change_s,
ghostty_action_key_table_s,
ghostty_action_move_tab_s,
ghostty_action_open_url_s,
ghostty_action_progress_report_s,
ghostty_action_scrollbar_s,
ghostty_action_start_search_s,
};
use crate::helpers::color::Color;
/// `Ghostty.Action.ColorChange`
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ColorChange {
pub kind: ColorChangeKind,
pub color: Color,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorChangeKind {
Foreground,
Background,
Cursor,
Palette { index: u8 },
}
impl ColorChange {
#[must_use]
pub fn from_c(c: &ghostty_action_color_change_s) -> Self {
let kind = match c.kind {
GHOSTTY_ACTION_COLOR_KIND_FOREGROUND => ColorChangeKind::Foreground,
GHOSTTY_ACTION_COLOR_KIND_BACKGROUND => ColorChangeKind::Background,
GHOSTTY_ACTION_COLOR_KIND_CURSOR => ColorChangeKind::Cursor,
other => ColorChangeKind::Palette { index: other as u8 },
};
Self {
kind,
color: Color::from_rgb8(c.r, c.g, c.b),
}
}
}
/// `Ghostty.Action.MoveTab`
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MoveTab {
pub amount: isize,
}
impl MoveTab {
#[must_use]
pub fn from_c(c: &ghostty_action_move_tab_s) -> Self {
Self { amount: c.amount }
}
}
/// `Ghostty.Action.OpenURL`
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpenUrl {
pub kind: OpenUrlKind,
pub url: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OpenUrlKind {
Unknown,
Text,
Html,
}
impl OpenUrlKind {
#[must_use]
pub fn from_c(c: c_int) -> Self {
match c {
GHOSTTY_ACTION_OPEN_URL_KIND_TEXT => Self::Text,
GHOSTTY_ACTION_OPEN_URL_KIND_HTML => Self::Html,
_ => Self::Unknown,
}
}
}
impl OpenUrl {
/// `init(c:)`.
///
/// # Safety
/// A non-null `c.url` must point to `c.len` readable bytes.
#[must_use]
pub unsafe fn from_c(c: &ghostty_action_open_url_s) -> Self {
let url = if c.url.is_null() {
String::new()
} else {
// SAFETY: per the contract.
let data = unsafe { std::slice::from_raw_parts(c.url.cast::<u8>(), c.len) };
String::from_utf8(data.to_vec()).unwrap_or_default()
};
Self {
kind: OpenUrlKind::from_c(c.kind),
url,
}
}
}
/// `Ghostty.Action.ProgressReport`
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProgressReport {
pub state: ProgressReportState,
pub progress: Option<u8>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProgressReportState {
Remove,
Set,
Error,
Indeterminate,
Pause,
}
impl ProgressReportState {
#[must_use]
pub fn from_c(c: c_int) -> Self {
match c {
GHOSTTY_PROGRESS_STATE_SET => Self::Set,
GHOSTTY_PROGRESS_STATE_ERROR => Self::Error,
GHOSTTY_PROGRESS_STATE_INDETERMINATE => Self::Indeterminate,
GHOSTTY_PROGRESS_STATE_PAUSE => Self::Pause,
// GHOSTTY_PROGRESS_STATE_REMOVE and anything unknown.
_ => Self::Remove,
}
}
}
impl ProgressReport {
#[must_use]
pub fn from_c(c: &ghostty_action_progress_report_s) -> Self {
Self {
state: ProgressReportState::from_c(c.state),
progress: u8::try_from(c.progress).ok(),
}
}
}
/// `Ghostty.Action.Scrollbar`
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Scrollbar {
pub total: u64,
pub offset: u64,
pub len: u64,
}
impl Scrollbar {
#[must_use]
pub fn from_c(c: &ghostty_action_scrollbar_s) -> Self {
Self {
total: c.total,
offset: c.offset,
len: c.len,
}
}
}
/// `Ghostty.Action.StartSearch`
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StartSearch {
pub needle: Option<String>,
}
impl StartSearch {
/// `init(c:)`.
///
/// # Safety
/// A non-null `c.needle` must be NUL terminated.
#[must_use]
pub unsafe fn from_c(c: &ghostty_action_start_search_s) -> Self {
let needle = if c.needle.is_null() {
None
} else {
// SAFETY: per the contract.
Some(
unsafe { CStr::from_ptr(c.needle) }
.to_string_lossy()
.into_owned(),
)
};
Self { needle }
}
}
/// `Ghostty.Action.PromptTitle`
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PromptTitle {
Surface,
Tab,
}
impl PromptTitle {
#[must_use]
pub fn from_c(c: c_int) -> Self {
if c == GHOSTTY_PROMPT_TITLE_TAB {
Self::Tab
} else {
Self::Surface
}
}
}
/// `Ghostty.Action.KeyTable`
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum KeyTable {
Activate { name: String },
Deactivate,
DeactivateAll,
}
impl KeyTable {
/// `init?(c:)`.
///
/// # Safety
/// For the activate tag, `c.value.activate.name` must point to `len` bytes.
#[must_use]
pub unsafe fn from_c(c: &ghostty_action_key_table_s) -> Option<Self> {
match c.tag {
GHOSTTY_KEY_TABLE_ACTIVATE => {
// SAFETY: the tag selects the `activate` member; pointer per the contract.
let (ptr, len) = unsafe { (c.value.activate.name, c.value.activate.len) };
let name = if ptr.is_null() {
String::new()
} else {
// SAFETY: per the contract.
let data = unsafe { std::slice::from_raw_parts(ptr.cast::<u8>(), len) };
String::from_utf8(data.to_vec()).unwrap_or_default()
};
Some(Self::Activate { name })
}
GHOSTTY_KEY_TABLE_DEACTIVATE => Some(Self::Deactivate),
GHOSTTY_KEY_TABLE_DEACTIVATE_ALL => Some(Self::DeactivateAll),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ghostty::capi::{ghostty_action_key_table_activate_s, ghostty_action_key_table_u};
#[test]
fn color_change_kinds() {
let c = |kind| ghostty_action_color_change_s {
kind,
r: 255,
g: 0,
b: 51,
};
assert_eq!(
ColorChange::from_c(&c(GHOSTTY_ACTION_COLOR_KIND_FOREGROUND)).kind,
ColorChangeKind::Foreground
);
assert_eq!(
ColorChange::from_c(&c(GHOSTTY_ACTION_COLOR_KIND_CURSOR)).kind,
ColorChangeKind::Cursor
);
let p = ColorChange::from_c(&c(7));
assert_eq!(p.kind, ColorChangeKind::Palette { index: 7 });
assert_eq!(p.color, Color::new(1.0, 0.0, 0.2));
}
#[test]
fn progress_report() {
let r = ProgressReport::from_c(&ghostty_action_progress_report_s {
state: GHOSTTY_PROGRESS_STATE_SET,
progress: 40,
});
assert_eq!(
r,
ProgressReport {
state: ProgressReportState::Set,
progress: Some(40)
}
);
let r = ProgressReport::from_c(&ghostty_action_progress_report_s {
state: GHOSTTY_PROGRESS_STATE_INDETERMINATE,
progress: -1,
});
assert_eq!(r.progress, None);
assert_eq!(ProgressReportState::from_c(99), ProgressReportState::Remove);
}
#[test]
fn open_url_and_search() {
let url = "file:///tmp/a";
let c = ghostty_action_open_url_s {
kind: GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
url: url.as_ptr().cast(),
len: url.len(),
};
// SAFETY: `url` outlives the call.
let o = unsafe { OpenUrl::from_c(&c) };
assert_eq!(
o,
OpenUrl {
kind: OpenUrlKind::Text,
url: url.into()
}
);
let s = ghostty_action_start_search_s {
needle: c"abc".as_ptr(),
};
// SAFETY: static C string.
assert_eq!(
unsafe { StartSearch::from_c(&s) }.needle.as_deref(),
Some("abc")
);
let s = ghostty_action_start_search_s {
needle: std::ptr::null(),
};
// SAFETY: null handled.
assert_eq!(unsafe { StartSearch::from_c(&s) }.needle, None);
}
#[test]
fn key_table() {
let name = "vim";
let c = ghostty_action_key_table_s {
tag: GHOSTTY_KEY_TABLE_ACTIVATE,
value: ghostty_action_key_table_u {
activate: ghostty_action_key_table_activate_s {
name: name.as_ptr().cast(),
len: 3,
},
},
};
// SAFETY: `name` outlives the call.
assert_eq!(
unsafe { KeyTable::from_c(&c) },
Some(KeyTable::Activate { name: "vim".into() })
);
let c = ghostty_action_key_table_s {
tag: GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
..c
};
// SAFETY: no pointer is read.
assert_eq!(
unsafe { KeyTable::from_c(&c) },
Some(KeyTable::DeactivateAll)
);
assert_eq!(
PromptTitle::from_c(GHOSTTY_PROMPT_TITLE_TAB),
PromptTitle::Tab
);
}
}
+2111
View File
@@ -0,0 +1,2111 @@
//! `Ghostty.App` (`Ghostty.App.swift`): the app-global libghostty state.
//!
//! Owns the `ghostty_app_t`, the app configuration and the runtime callbacks
//! libghostty uses to talk to the host (wakeup, action, clipboard, close
//! surface). In the Swift app these callbacks are C function pointers into
//! `static func`s of `Ghostty.App`; here they are `extern "C" fn`s in this
//! module passed in the very same `ghostty_runtime_config_s`.
//!
//! Threading: all state is main-thread only (`Rc`/`RefCell`/`Published`).
//! The single exception is libghostty's `wakeup` callback which may be invoked
//! from any thread: it only captures the *address* of the App allocation and
//! hops to the main queue, where the live App is re-resolved through a
//! main-thread registry ([`REGISTRY`]) before it is touched.
//!
//! `Ghostty.Delegate` / `GhosttyAppDelegate` (protocol with
//! `findSurface(forUUID:)`) is not a trait here: the only conformer is the
//! `AppDelegate` class, held weakly by [`App::set_delegate`].
#![allow(unsafe_code)]
// The App operations mirror Swift instance methods (some do not use `self`).
#![allow(clippy::unused_self)]
// `consts` is the generated list of header enumerators; the notification
// module is the Swift `Notification.Name` namespace.
#![allow(clippy::wildcard_imports)]
// The C action structs are passed by reference like the Swift handlers do, and
// the surface handles are opaque pointers owned by libghostty that callers get
// from libghostty (they are never dereferenced here).
#![allow(clippy::trivially_copy_pass_by_ref, clippy::not_unsafe_ptr_arg_deref)]
use std::{
cell::{Cell, RefCell},
collections::HashMap,
ffi::{CStr, CString, c_char, c_int, c_void},
ptr::NonNull,
rc::{Rc, Weak as RcWeak},
time::Duration,
};
use block2::RcBlock;
use dispatch2::{DispatchQueue, MainThreadBound};
use log::{debug, error, info, warn};
use objc2::{
AnyThread,
MainThreadMarker,
rc::{Retained, Weak},
};
use objc2_app_kit::{
NSAlert,
NSAlertStyle,
NSApplication,
NSApplicationDidBecomeActiveNotification,
NSApplicationDidResignActiveNotification,
NSFloatingWindowLevel,
NSNormalWindowLevel,
NSPasteboard,
NSPasteboardTypeString,
NSTextInputContextKeyboardSelectionDidChangeNotification,
NSWindow,
NSWorkspace,
NSWorkspaceOpenConfiguration,
};
use objc2_foundation::{NSArray, NSSize, NSString, NSURL, NSUUID};
use objc2_user_notifications::{
UNAuthorizationOptions,
UNAuthorizationStatus,
UNNotification,
UNNotificationResponse,
UNNotificationSettings,
UNUserNotificationCenter,
};
use crate::{
app::app_delegate::AppDelegate,
features::terminal::{BaseTerminalController, TerminalWindow},
ghostty::{
action::{
ColorChange,
KeyTable,
MoveTab,
OpenUrl,
OpenUrlKind,
ProgressReport,
ProgressReportState,
PromptTitle,
Scrollbar,
StartSearch,
},
capi::{
consts::*,
ghostty_action_s,
ghostty_app_free,
ghostty_app_keyboard_changed,
ghostty_app_needs_confirm_quit,
ghostty_app_new,
ghostty_app_set_focus,
ghostty_app_t,
ghostty_app_tick,
ghostty_app_update_config,
ghostty_app_userdata,
ghostty_clipboard_content_s,
ghostty_config_open_path,
ghostty_runtime_config_s,
ghostty_surface_app,
ghostty_surface_binding_action,
ghostty_surface_complete_clipboard_request,
ghostty_surface_inherited_config,
ghostty_surface_request_close,
ghostty_surface_split,
ghostty_surface_split_equalize,
ghostty_surface_split_focus,
ghostty_surface_split_resize,
ghostty_surface_t,
ghostty_surface_update_config,
ghostty_surface_userdata,
ghostty_target_s,
},
config::Config,
duration_format,
fullscreen_mode,
input,
notification::*,
package::{
AllocatedString,
ClipboardContent,
ClipboardRequest,
SetFloatWindow,
SetSecureInput,
SplitFocusDirection,
SplitResizeDirection,
USER_NOTIFICATION_ACTION_SHOW,
},
surface_configuration::SurfaceConfiguration,
surface_view::{SearchState, SurfaceView, move_focus},
},
helpers::{
extensions::{
ns_notification::{NsObserver, observe},
ns_pasteboard::{self, NSPasteboardExt, pasteboard_type_from_mime},
ns_workspace::NSWorkspaceExt,
},
notification_center::{Name, NotificationCenter, Sender, UserInfo},
published::Published,
},
};
// MARK: Main-thread helpers
/// `DispatchQueue.main.async { ... }` for non-`Send` closures. Must be called
/// from the main thread (the closure also runs there).
pub fn dispatch_main_async(f: impl FnOnce() + 'static) {
let mtm = crate::helpers::main_thread();
let bound = MainThreadBound::new(f, mtm);
DispatchQueue::main().exec_async(move || {
let mtm = MainThreadMarker::new().expect("main queue runs on the main thread");
(bound.into_inner(mtm))();
});
}
// MARK: Registry
thread_local! {
/// Live Apps by the address of their allocation (the `userdata` handed to
/// libghostty). Main thread only.
static REGISTRY: RefCell<HashMap<usize, RcWeak<App>>> = RefCell::new(HashMap::new());
}
fn registered(addr: usize) -> Option<Rc<App>> {
REGISTRY.with(|r| r.borrow().get(&addr).and_then(RcWeak::upgrade))
}
// MARK: App
/// `Ghostty.App.Readiness`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Readiness {
Loading,
Error,
Ready,
}
/// `Ghostty.App.FontSizeModification`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FontSizeModification {
Increase(i32),
Decrease(i32),
Reset,
}
/// `Ghostty.App`.
pub struct App {
/// Optional delegate (`weak var delegate`).
delegate: RefCell<Option<Weak<AppDelegate>>>,
/// The readiness value of the state.
readiness: Published<Readiness>,
/// The global app configuration. This defines the app level configuration
/// plus any behavior for new windows, tabs, etc. Note that when creating a
/// new window, it may inherit some configuration (i.e. font size) from the
/// previously focused window. This would override this.
config: Published<Rc<Config>>,
/// Preferred config file than the default ones.
config_path: Option<String>,
/// The ghostty app instance. We only have one of these for the entire app.
handle: Cell<ghostty_app_t>,
/// The AppKit observers (removed on drop).
observers: RefCell<Vec<NsObserver>>,
}
impl App {
/// `init(configPath:)`.
#[must_use]
pub fn new(config_path: Option<&str>) -> Rc<App> {
// Initialize the global configuration.
let config = Config::new(config_path, true);
let loaded = config.loaded();
let this = Rc::new(App {
delegate: RefCell::new(None),
readiness: Published::new(Readiness::Loading),
config: Published::new(Rc::new(config)),
config_path: config_path.map(str::to_owned),
handle: Cell::new(std::ptr::null_mut()),
observers: RefCell::new(Vec::new()),
});
if !loaded {
this.readiness.set(Readiness::Error);
return this;
}
// The userdata handed to libghostty is the address of this allocation.
let userdata = Rc::as_ptr(&this) as *mut c_void;
REGISTRY.with(|r| {
r.borrow_mut()
.insert(userdata as usize, Rc::downgrade(&this))
});
// Create our "runtime" config. The "runtime" is the configuration that
// ghostty uses to interface with the application runtime environment.
let runtime_cfg = ghostty_runtime_config_s {
userdata,
supports_selection_clipboard: true,
wakeup_cb: Some(wakeup_cb),
action_cb: Some(action_cb),
read_clipboard_cb: Some(read_clipboard_cb),
confirm_read_clipboard_cb: Some(confirm_read_clipboard_cb),
write_clipboard_cb: Some(write_clipboard_cb),
close_surface_cb: Some(close_surface_cb),
};
// Create the ghostty app.
// SAFETY: valid runtime config and config handle; the callbacks above
// are sound for the userdata we pass.
let app = unsafe { ghostty_app_new(&raw const runtime_cfg, this.config().as_ptr()) };
if app.is_null() {
error!("ghostty_app_new failed");
this.readiness.set(Readiness::Error);
return this;
}
this.handle.set(app);
// Set our initial focus state.
let mtm = crate::helpers::main_thread();
// SAFETY: valid app handle.
unsafe { ghostty_app_set_focus(app, NSApplication::sharedApplication(mtm).isActive()) };
let mut observers = Vec::new();
let weak = Rc::downgrade(&this);
// Called when the selected keyboard changes. We have to notify Ghostty
// so that it can reload the keyboard mapping for input.
// SAFETY: statics provided by AppKit.
let kb_name = unsafe { NSTextInputContextKeyboardSelectionDidChangeNotification };
observers.push(observe(kb_name, None, {
let weak = weak.clone();
move |_| {
if let Some(this) = weak.upgrade()
&& let Some(app) = this.app_ptr()
{
// SAFETY: valid app handle.
unsafe { ghostty_app_keyboard_changed(app) };
}
}
}));
// Called when the app becomes active.
// SAFETY: statics provided by AppKit.
let active = unsafe { NSApplicationDidBecomeActiveNotification };
observers.push(observe(active, None, {
let weak = weak.clone();
move |_| {
if let Some(this) = weak.upgrade()
&& let Some(app) = this.app_ptr()
{
// SAFETY: valid app handle.
unsafe { ghostty_app_set_focus(app, true) };
}
}
}));
// Called when the app becomes inactive.
// SAFETY: statics provided by AppKit.
let resign = unsafe { NSApplicationDidResignActiveNotification };
observers.push(observe(resign, None, {
move |_| {
if let Some(this) = weak.upgrade()
&& let Some(app) = this.app_ptr()
{
// SAFETY: valid app handle.
unsafe { ghostty_app_set_focus(app, false) };
}
}
}));
*this.observers.borrow_mut() = observers;
this.readiness.set(Readiness::Ready);
this
}
// MARK: State
/// `delegate = ...`.
pub fn set_delegate(&self, delegate: Weak<AppDelegate>) {
*self.delegate.borrow_mut() = Some(delegate);
}
fn delegate(&self) -> Option<Retained<AppDelegate>> {
self.delegate.borrow().as_ref().and_then(Weak::load)
}
/// `readiness`.
#[must_use]
pub fn readiness(&self) -> Readiness {
self.readiness.get()
}
/// `$readiness`.
#[must_use]
pub fn readiness_published(&self) -> Published<Readiness> {
self.readiness.clone()
}
/// `config`.
#[must_use]
pub fn config(&self) -> Rc<Config> {
self.config.get()
}
/// `$config`.
#[must_use]
pub fn config_published(&self) -> Published<Rc<Config>> {
self.config.clone()
}
/// `app` (the `ghostty_app_t`), if created.
#[must_use]
pub fn app_ptr(&self) -> Option<ghostty_app_t> {
let p = self.handle.get();
if p.is_null() { None } else { Some(p) }
}
/// True if we need to confirm before quitting.
#[must_use]
pub fn needs_confirm_quit(&self) -> bool {
match self.app_ptr() {
// SAFETY: valid app handle.
Some(app) => unsafe { ghostty_app_needs_confirm_quit(app) },
None => false,
}
}
// MARK: App Operations
/// `appTick()`.
pub fn app_tick(&self) {
let Some(app) = self.app_ptr() else { return };
// SAFETY: valid app handle.
unsafe { ghostty_app_tick(app) };
}
/// `static func openConfig()`.
pub fn open_config() {
// SAFETY: returns an owned string freed by `AllocatedString`.
let s = AllocatedString::new(ghostty_config_open_path()).string();
if s.is_empty() {
return;
}
// Deviation: Swift passes `fileURL.count` (characters) as the byte
// length, truncating URLs with non-ASCII paths; we use the full URL.
let file_url = NSURL::fileURLWithPath(&NSString::from_str(&s));
let url = file_url
.absoluteString()
.map(|s| s.to_string())
.unwrap_or_default();
let _ = open_url(&OpenUrl {
kind: OpenUrlKind::Text,
url,
});
}
/// `reloadConfig(soft:)`: reload the configuration.
pub fn reload_config(&self, soft: bool) {
let Some(app) = self.app_ptr() else { return };
// Soft updates just call with our existing config.
if soft {
// SAFETY: valid handles.
unsafe { ghostty_app_update_config(app, self.config().as_ptr()) };
return;
}
// Hard or full updates have to reload the full configuration.
let new_config = Config::new(self.config_path.as_deref(), true);
if !new_config.loaded() {
warn!("failed to reload configuration");
return;
}
// SAFETY: valid handles.
unsafe { ghostty_app_update_config(app, new_config.as_ptr()) };
// applied config will be updated in `config_change`.
}
/// `reloadConfig(surface:soft:)`.
pub fn reload_config_surface(&self, surface: ghostty_surface_t, soft: bool) {
// Soft updates just call with our existing config.
if soft {
// SAFETY: valid handles.
unsafe { ghostty_surface_update_config(surface, self.config().as_ptr()) };
return;
}
// Hard or full updates have to reload the full configuration.
// NOTE: We never set this on self.config because this is a
// surface-only config. We free it after the call.
let new_config = Config::new(self.config_path.as_deref(), true);
if !new_config.loaded() {
warn!("failed to reload configuration");
return;
}
// SAFETY: valid handles.
unsafe { ghostty_surface_update_config(surface, new_config.as_ptr()) };
}
/// Request that the given surface is closed. This will trigger the full
/// normal surface close event cycle which will call our close surface
/// callback.
pub fn request_close(&self, surface: ghostty_surface_t) {
// SAFETY: valid surface handle (caller).
unsafe { ghostty_surface_request_close(surface) };
}
fn binding_action(surface: ghostty_surface_t, action: &str) {
// SAFETY: valid surface handle; pointer/length describe `action`.
if !unsafe { ghostty_surface_binding_action(surface, action.as_ptr().cast(), action.len()) }
{
warn!("action failed action={action}");
}
}
/// `newTab(surface:)`.
pub fn new_tab(&self, surface: ghostty_surface_t) {
Self::binding_action(surface, "new_tab");
}
/// `newWindow(surface:)`.
pub fn new_window(&self, surface: ghostty_surface_t) {
Self::binding_action(surface, "new_window");
}
/// `split(surface:direction:)` (`ghostty_action_split_direction_e`).
pub fn split(&self, surface: ghostty_surface_t, direction: c_int) {
// SAFETY: valid surface handle.
unsafe { ghostty_surface_split(surface, direction) };
}
/// `splitMoveFocus(surface:direction:)`.
pub fn split_move_focus(&self, surface: ghostty_surface_t, direction: SplitFocusDirection) {
// SAFETY: valid surface handle.
unsafe { ghostty_surface_split_focus(surface, direction.to_native()) };
}
/// `splitResize(surface:direction:amount:)`.
pub fn split_resize(
&self,
surface: ghostty_surface_t,
direction: SplitResizeDirection,
amount: u16,
) {
// SAFETY: valid surface handle.
unsafe { ghostty_surface_split_resize(surface, direction.to_native(), amount) };
}
/// `splitEqualize(surface:)`.
pub fn split_equalize(&self, surface: ghostty_surface_t) {
// SAFETY: valid surface handle.
unsafe { ghostty_surface_split_equalize(surface) };
}
/// `splitToggleZoom(surface:)`.
pub fn split_toggle_zoom(&self, surface: ghostty_surface_t) {
Self::binding_action(surface, "toggle_split_zoom");
}
/// `toggleFullscreen(surface:)`.
pub fn toggle_fullscreen(&self, surface: ghostty_surface_t) {
Self::binding_action(surface, "toggle_fullscreen");
}
/// `changeFontSize(surface:_:)`.
pub fn change_font_size(&self, surface: ghostty_surface_t, change: FontSizeModification) {
let action = match change {
FontSizeModification::Increase(amount) => format!("increase_font_size:{amount}"),
FontSizeModification::Decrease(amount) => format!("decrease_font_size:{amount}"),
FontSizeModification::Reset => "reset_font_size".to_owned(),
};
Self::binding_action(surface, &action);
}
/// `toggleTerminalInspector(surface:)`.
pub fn toggle_terminal_inspector(&self, surface: ghostty_surface_t) {
Self::binding_action(surface, "inspector:toggle");
}
/// `resetTerminal(surface:)`.
pub fn reset_terminal(&self, surface: ghostty_surface_t) {
Self::binding_action(surface, "reset");
}
// MARK: User Notifications
/// Determine if a given notification should be presented to the user when
/// Ghostty is running in the foreground.
#[must_use]
pub fn should_present_notification(&self, notification: &UNNotification) -> bool {
// SAFETY: plain property getters.
let user_info = notification.request().content().userInfo();
// We always require the notification to be attached to a surface.
let Some(surface) =
user_info_surface(&user_info).and_then(|uuid| self.delegate()?.find_surface(&uuid))
else {
return false;
};
let Some(window) = surface.window() else {
return false;
};
// If we don't require focus then we're good!
let require_focus = user_info
.objectForKey(&NSString::from_str("requireFocus"))
.and_then(|v| v.downcast::<objc2_foundation::NSNumber>().ok())
.is_none_or(|n| n.boolValue());
if !require_focus {
return true;
}
!window.isKeyWindow() || !surface.focused()
}
/// Handle a received user notification. This is called when a user
/// notification is clicked or dismissed by the user.
pub fn handle_user_notification(&self, response: &UNNotificationResponse) {
let notification = response.notification();
let user_info = notification.request().content().userInfo();
let action_identifier = response.actionIdentifier().to_string();
let Some(surface) =
user_info_surface(&user_info).and_then(|uuid| self.delegate()?.find_surface(&uuid))
else {
return;
};
// SAFETY: statics provided by UserNotifications.
let (default_id, dismiss_id) = unsafe {
(
objc2_user_notifications::UNNotificationDefaultActionIdentifier.to_string(),
objc2_user_notifications::UNNotificationDismissActionIdentifier.to_string(),
)
};
if action_identifier == default_id || action_identifier == USER_NOTIFICATION_ACTION_SHOW {
// The user clicked on a notification
surface.handle_user_notification(&notification, true);
} else if action_identifier == dismiss_id {
// The user dismissed the notification
surface.handle_user_notification(&notification, false);
}
}
}
impl Drop for App {
fn drop(&mut self) {
// `app = nil` -> the didSet frees the old value.
let app = self.handle.replace(std::ptr::null_mut());
if !app.is_null() {
// SAFETY: we own the handle.
unsafe { ghostty_app_free(app) };
}
REGISTRY.with(|r| {
r.borrow_mut().remove(&(std::ptr::from_ref(self) as usize));
});
// The observers are removed by their own Drop.
}
}
fn user_info_surface(user_info: &objc2_foundation::NSDictionary) -> Option<Retained<NSUUID>> {
let s = user_info
.objectForKey(&NSString::from_str("surface"))?
.downcast::<NSString>()
.ok()?;
NSUUID::initWithUUIDString(NSUUID::alloc(), &s)
}
// MARK: Pointer helpers
/// `Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()`
/// (retained for the duration of the use).
fn surface_userdata(userdata: *mut c_void) -> Option<Retained<SurfaceView>> {
// SAFETY: the userdata of every surface (and of the surface callbacks) is
// the address of a live `SurfaceView`, set when the surface was created.
unsafe { Retained::retain(userdata.cast::<SurfaceView>()) }
}
/// `surfaceView(from: surface)`.
fn surface_view(surface: ghostty_surface_t) -> Option<Retained<SurfaceView>> {
// SAFETY: valid surface handle (from libghostty's target).
let ud = unsafe { ghostty_surface_userdata(surface) };
if ud.is_null() {
None
} else {
surface_userdata(ud)
}
}
/// `appState(fromView:)`.
fn app_state(view: &SurfaceView) -> Option<Rc<App>> {
let surface = view.surface()?;
// SAFETY: valid surface handle.
let app = unsafe { ghostty_surface_app(surface) };
if app.is_null() {
return None;
}
app_from_handle(app)
}
/// `Unmanaged<App>.fromOpaque(ghostty_app_userdata(app)).takeUnretainedValue()`.
fn app_from_handle(app: ghostty_app_t) -> Option<Rc<App>> {
// SAFETY: valid app handle.
let ud = unsafe { ghostty_app_userdata(app) };
if ud.is_null() {
None
} else {
registered(ud as usize)
}
}
/// The sender of notifications posted for `view` (`object: surfaceView`).
fn sender(view: &Retained<SurfaceView>) -> Sender {
Sender::new(Retained::as_ptr(view) as usize, view.clone())
}
fn post(name: Name, view: Option<&Retained<SurfaceView>>, user_info: UserInfo) {
NotificationCenter::default_center().post(name, view.map(sender), user_info);
}
fn app_delegate() -> Option<Retained<AppDelegate>> {
AppDelegate::shared(crate::helpers::main_thread())
}
fn base_controller(view: &SurfaceView) -> Option<Retained<BaseTerminalController>> {
view.window()?
.windowController()?
.downcast::<BaseTerminalController>()
.ok()
}
fn tab_count(view: &SurfaceView) -> usize {
view.window()
.and_then(|w| w.tabGroup())
.map_or(0, |g| g.windows().count())
}
/// Resolve an action target to a surface view for handlers that "do nothing
/// with an app target". Logs like the Swift `Ghostty.logger.warning`.
fn surface_target(what: &str, target: &ghostty_target_s) -> Option<Retained<SurfaceView>> {
match target.tag {
GHOSTTY_TARGET_APP => {
warn!("{what} does nothing with an app target");
None
}
GHOSTTY_TARGET_SURFACE => {
// SAFETY: the tag selects the `surface` member.
let surface = unsafe { target.target.surface };
if surface.is_null() {
None
} else {
surface_view(surface)
}
}
_ => {
error!("unknown action target={}", target.tag);
None
}
}
}
fn c_str(p: *const c_char) -> Option<String> {
if p.is_null() {
return None;
}
// SAFETY: libghostty passes NUL terminated strings.
unsafe { CStr::from_ptr(p) }
.to_str()
.ok()
.map(str::to_owned)
}
// MARK: Callbacks
unsafe extern "C" fn wakeup_cb(userdata: *mut c_void) {
// Wakeup can be called from any thread so we schedule the app tick from
// the main thread. There is probably some improvements we can make to
// coalesce multiple ticks but I don't think it matters from a performance
// standpoint since we don't do this much.
let addr = userdata as usize;
DispatchQueue::main().exec_async(move || {
if let Some(app) = registered(addr) {
app.app_tick();
}
});
}
unsafe extern "C" fn close_surface_cb(userdata: *mut c_void, process_alive: bool) {
let Some(surface) = surface_userdata(userdata) else {
return;
};
post(
GHOSTTY_CLOSE_SURFACE,
Some(&surface),
UserInfo::new().with("process_alive", process_alive),
);
}
unsafe extern "C" fn read_clipboard_cb(
userdata: *mut c_void,
location: c_int,
state: *mut c_void,
) -> bool {
let Some(view) = surface_userdata(userdata) else {
return false;
};
let Some(surface) = view.surface() else {
return false;
};
// Get our pasteboard
let Some(pasteboard) = ns_pasteboard::ghostty(location) else {
return false;
};
// Return false if there is no text-like clipboard content so performable
// paste bindings can pass through to the terminal.
let Some(str) = pasteboard.get_opinionated_string_contents() else {
return false;
};
complete_clipboard_request(surface, &str, state, false);
true
}
unsafe extern "C" fn confirm_read_clipboard_cb(
userdata: *mut c_void,
string: *const c_char,
state: *mut c_void,
request: c_int,
) {
let Some(surface) = surface_userdata(userdata) else {
return;
};
let Some(value) = c_str(string) else { return };
let Some(request) = ClipboardRequest::from_request(request) else {
return;
};
post(
CONFIRM_CLIPBOARD,
Some(&surface),
UserInfo::new()
.with(CONFIRM_CLIPBOARD_STR_KEY, value)
// The opaque request state pointer, as an address.
.with(CONFIRM_CLIPBOARD_STATE_KEY, state as usize)
.with(CONFIRM_CLIPBOARD_REQUEST_KEY, request),
);
}
/// `static func completeClipboardRequest(_:data:state:confirmed:)`.
pub fn complete_clipboard_request(
surface: ghostty_surface_t,
data: &str,
state: *mut c_void,
confirmed: bool,
) {
let end = data.find('\0').unwrap_or(data.len());
let c = CString::new(&data.as_bytes()[..end]).unwrap_or_default();
// SAFETY: valid surface; the string lives for the call; `state` is the
// request state libghostty gave us.
unsafe { ghostty_surface_complete_clipboard_request(surface, c.as_ptr(), state, confirmed) };
}
unsafe extern "C" fn write_clipboard_cb(
userdata: *mut c_void,
location: c_int,
content: *const ghostty_clipboard_content_s,
len: usize,
confirm: bool,
) {
let Some(surface) = surface_userdata(userdata) else {
return;
};
let Some(pasteboard) = ns_pasteboard::ghostty(location) else {
return;
};
if content.is_null() || len == 0 {
return;
}
// Convert the C array to a Rust vector.
// SAFETY: libghostty passes `len` valid entries.
let slice = unsafe { std::slice::from_raw_parts(content, len) };
// SAFETY: entries hold NUL terminated strings or null.
let contents: Vec<ClipboardContent> = slice
.iter()
.filter_map(|c| unsafe { ClipboardContent::from_c(c) })
.collect();
if contents.is_empty() {
return;
}
// Assert there is only one text/plain entry. For security reasons we need
// to guarantee this for now since our confirmation dialog only shows one.
debug_assert!(
contents.iter().filter(|c| c.mime == "text/plain").count() <= 1,
"clipboard contents should have at most one text/plain entry"
);
if !confirm {
// Declare all types
let types: Vec<Retained<NSString>> = contents
.iter()
.map(|c| pasteboard_type_from_mime(&c.mime))
.collect();
let types_array = NSArray::from_retained_slice(&types);
// SAFETY: plain pasteboard API calls with valid arguments.
unsafe { pasteboard.declareTypes_owner(&types_array, None) };
// Set data for each type
for (item, ty) in contents.iter().zip(&types) {
let _ = pasteboard.setString_forType(&NSString::from_str(&item.data), ty);
}
return;
}
// For confirmation, use the text/plain content if it exists
let Some(text_plain) = contents.iter().find(|c| c.mime == "text/plain") else {
return;
};
post(
CONFIRM_CLIPBOARD,
Some(&surface),
UserInfo::new()
.with(CONFIRM_CLIPBOARD_STR_KEY, text_plain.data.clone())
.with(
CONFIRM_CLIPBOARD_REQUEST_KEY,
ClipboardRequest::Osc52Write(Some(Retained::as_ptr(&pasteboard) as usize)),
),
);
}
// MARK: Actions
unsafe extern "C" fn action_cb(
app: ghostty_app_t,
target: ghostty_target_s,
action: ghostty_action_s,
) -> bool {
action_dispatch(app, &target, &action)
}
fn action_dispatch(
app: ghostty_app_t,
target: &ghostty_target_s,
action: &ghostty_action_s,
) -> bool {
// Make sure it a target we understand so all our action handlers can assert
match target.tag {
GHOSTTY_TARGET_APP | GHOSTTY_TARGET_SURFACE => {}
other => {
warn!("unknown action target={other}");
return false;
}
}
// SAFETY (all union reads below): `action.tag` selects the active member.
let v = &action.action;
// Action dispatch
unsafe {
match action.tag {
GHOSTTY_ACTION_QUIT => quit(),
GHOSTTY_ACTION_NEW_WINDOW => new_window(target),
GHOSTTY_ACTION_NEW_TAB => new_tab(target),
GHOSTTY_ACTION_NEW_SPLIT => new_split(target, v.new_split),
GHOSTTY_ACTION_CLOSE_TAB => close_tab(target, v.close_tab_mode),
GHOSTTY_ACTION_CLOSE_WINDOW => close_window(target),
GHOSTTY_ACTION_TOGGLE_FULLSCREEN => toggle_fullscreen(target, v.toggle_fullscreen),
GHOSTTY_ACTION_MOVE_TAB => return move_tab(target, &v.move_tab),
GHOSTTY_ACTION_GOTO_TAB => return goto_tab(target, v.goto_tab),
GHOSTTY_ACTION_GOTO_SPLIT => return goto_split(target, v.goto_split),
GHOSTTY_ACTION_GOTO_WINDOW => return goto_window(v.goto_window),
GHOSTTY_ACTION_RESIZE_SPLIT => return resize_split(target, &v.resize_split),
GHOSTTY_ACTION_EQUALIZE_SPLITS => equalize_splits(target),
GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM => return toggle_split_zoom(target),
GHOSTTY_ACTION_INSPECTOR => control_inspector(target, v.inspector),
GHOSTTY_ACTION_RENDER_INSPECTOR => render_inspector(target),
GHOSTTY_ACTION_DESKTOP_NOTIFICATION => {
show_desktop_notification_action(target, &v.desktop_notification);
}
GHOSTTY_ACTION_SET_TITLE => set_title(target, &v.set_title),
GHOSTTY_ACTION_SET_TAB_TITLE => return set_tab_title(target, &v.set_tab_title),
GHOSTTY_ACTION_PROMPT_TITLE => return prompt_title(target, v.prompt_title),
GHOSTTY_ACTION_PWD => pwd_changed(target, &v.pwd),
GHOSTTY_ACTION_OPEN_CONFIG => App::open_config(),
GHOSTTY_ACTION_FLOAT_WINDOW => toggle_float_window(target, v.float_window),
GHOSTTY_ACTION_SECURE_INPUT => toggle_secure_input(target, v.secure_input),
GHOSTTY_ACTION_MOUSE_SHAPE => set_mouse_shape(target, v.mouse_shape),
GHOSTTY_ACTION_MOUSE_VISIBILITY => set_mouse_visibility(target, v.mouse_visibility),
GHOSTTY_ACTION_MOUSE_OVER_LINK => set_mouse_over_link(target, &v.mouse_over_link),
GHOSTTY_ACTION_INITIAL_SIZE => set_initial_size(target, &v.initial_size),
GHOSTTY_ACTION_RESET_WINDOW_SIZE => reset_window_size(target),
GHOSTTY_ACTION_CELL_SIZE => set_cell_size(target, &v.cell_size),
GHOSTTY_ACTION_RENDERER_HEALTH => renderer_health(target, v.renderer_health),
GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE => toggle_command_palette(target),
GHOSTTY_ACTION_TOGGLE_MAXIMIZE => toggle_maximize(target),
GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL => toggle_quick_terminal(),
GHOSTTY_ACTION_TOGGLE_VISIBILITY => toggle_visibility(),
GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY => toggle_background_opacity(target),
GHOSTTY_ACTION_KEY_SEQUENCE => key_sequence(target, &v.key_sequence),
GHOSTTY_ACTION_KEY_TABLE => key_table(target, &v.key_table),
GHOSTTY_ACTION_PROGRESS_REPORT => progress_report(target, &v.progress_report),
GHOSTTY_ACTION_CONFIG_CHANGE => config_change(app, target, &v.config_change),
GHOSTTY_ACTION_RELOAD_CONFIG => config_reload(app, target, &v.reload_config),
GHOSTTY_ACTION_COLOR_CHANGE => color_change(target, &v.color_change),
GHOSTTY_ACTION_RING_BELL => ring_bell(target),
GHOSTTY_ACTION_READONLY => set_readonly(target, v.readonly),
GHOSTTY_ACTION_CHECK_FOR_UPDATES => check_for_updates(),
GHOSTTY_ACTION_OPEN_URL => return open_url(&OpenUrl::from_c(&v.open_url)),
GHOSTTY_ACTION_UNDO => return undo(target),
GHOSTTY_ACTION_REDO => return redo(target),
GHOSTTY_ACTION_SCROLLBAR => scrollbar(target, &v.scrollbar),
GHOSTTY_ACTION_CLOSE_ALL_WINDOWS => close_all_windows(),
GHOSTTY_ACTION_START_SEARCH => start_search(target, &v.start_search),
GHOSTTY_ACTION_END_SEARCH => end_search(target),
GHOSTTY_ACTION_SEARCH_TOTAL => search_total(target, v.search_total.total),
GHOSTTY_ACTION_SEARCH_SELECTED => search_selected(target, v.search_selected.selected),
GHOSTTY_ACTION_COMMAND_FINISHED => command_finished(target, &v.command_finished),
GHOSTTY_ACTION_PRESENT_TERMINAL => return present_terminal(target),
GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW
| GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS
| GHOSTTY_ACTION_SIZE_LIMIT
| GHOSTTY_ACTION_QUIT_TIMER
| GHOSTTY_ACTION_SHOW_CHILD_EXITED => {
info!("known but unimplemented action action={}", action.tag);
return false;
}
GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD => return copy_title_to_clipboard(target),
other => {
warn!("unknown action action={other}");
return false;
}
}
}
// If we reached here then we assume performed since all unknown actions
// are captured in the switch and return false.
true
}
fn quit() {
// We want to quit, start that process.
NSApplication::sharedApplication(crate::helpers::main_thread()).terminate(None);
}
fn check_for_updates() {
if let Some(delegate) = app_delegate() {
delegate.check_for_updates();
}
}
/// `openURL(_:)`.
pub fn open_url(action: &OpenUrl) -> bool {
// If the URL doesn't have a valid scheme we assume its a file path. The URL
// initializer will gladly take invalid URLs (e.g. plain file paths) and turn
// them into schema-less URLs, but these won't open properly in text editors.
// See: https://github.com/ghostty-org/ghostty/issues/8763
let url = match NSURL::URLWithString(&NSString::from_str(&action.url)) {
Some(candidate) if candidate.scheme().is_some() => candidate,
_ => {
// Expand ~ to the user's home directory so that file paths like
// ~/Documents/file.txt resolve correctly.
let expanded = NSString::from_str(&action.url).stringByStandardizingPath();
NSURL::fileURLWithPath(&expanded)
}
};
match action.kind {
OpenUrlKind::Text => {
// Open with the default editor for `*.ghostty` file or just system text editor
let ext = url
.pathExtension()
.map(|e| e.to_string())
.unwrap_or_default();
let workspace = NSWorkspace::sharedWorkspace();
let editor = workspace
.default_application_url_for_extension(&ext)
.or_else(|| workspace.default_text_editor());
if let Some(text_editor) = editor {
let urls = NSArray::from_slice(&[&*url]);
workspace.openURLs_withApplicationAtURL_configuration_completionHandler(
&urls,
&text_editor,
&NSWorkspaceOpenConfiguration::configuration(),
None,
);
return true;
}
}
// The extension will be HTML and we do the right thing automatically.
OpenUrlKind::Html | OpenUrlKind::Unknown => {}
}
// Open with the default application for the URL
NSWorkspace::sharedWorkspace().openURL(&url);
true
}
fn undo_manager_for(
target: &ghostty_target_s,
) -> Option<Retained<objc2_foundation::NSUndoManager>> {
match target.tag {
GHOSTTY_TARGET_APP => app_delegate().map(|d| Retained::into_super(d.undo_manager())),
GHOSTTY_TARGET_SURFACE => {
// SAFETY: the tag selects the `surface` member.
let surface = unsafe { target.target.surface };
if surface.is_null() {
return None;
}
surface_view(surface)?.undoManager()
}
_ => None,
}
}
fn undo(target: &ghostty_target_s) -> bool {
let Some(um) = undo_manager_for(target) else {
return false;
};
if !um.canUndo() {
return false;
}
um.undo();
true
}
fn redo(target: &ghostty_target_s) -> bool {
let Some(um) = undo_manager_for(target) else {
return false;
};
if !um.canRedo() {
return false;
}
um.redo();
true
}
/// Shared by new_window/new_tab/new_split.
fn inherited_config(surface: ghostty_surface_t, context: c_int) -> SurfaceConfiguration {
// SAFETY: valid surface; the returned strings live as long as the surface.
unsafe { SurfaceConfiguration::from_c(&ghostty_surface_inherited_config(surface, context)) }
}
fn surface_of(target: &ghostty_target_s) -> Option<ghostty_surface_t> {
// SAFETY: only called for targets tagged as surface.
let s = unsafe { target.target.surface };
if s.is_null() { None } else { Some(s) }
}
fn new_window(target: &ghostty_target_s) {
match target.tag {
GHOSTTY_TARGET_APP => post(GHOSTTY_NEW_WINDOW, None, UserInfo::new()),
GHOSTTY_TARGET_SURFACE => {
let Some(surface) = surface_of(target) else {
return;
};
let Some(view) = surface_view(surface) else {
return;
};
post(
GHOSTTY_NEW_WINDOW,
Some(&view),
UserInfo::new().with(
NEW_SURFACE_CONFIG_KEY,
inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_WINDOW),
),
);
}
_ => debug_assert!(false),
}
}
fn new_tab(target: &ghostty_target_s) {
match target.tag {
GHOSTTY_TARGET_APP => post(GHOSTTY_NEW_TAB, None, UserInfo::new()),
GHOSTTY_TARGET_SURFACE => {
let Some(surface) = surface_of(target) else {
return;
};
let Some(view) = surface_view(surface) else {
return;
};
let Some(state) = app_state(&view) else {
return;
};
if !state.config().window_decorations() {
let alert = NSAlert::new(crate::helpers::main_thread());
alert.setMessageText(&NSString::from_str("Tabs are disabled"));
alert.setInformativeText(&NSString::from_str(
"Enable window decorations to use tabs",
));
alert.addButtonWithTitle(&NSString::from_str("OK"));
alert.setAlertStyle(NSAlertStyle::Warning);
let _ = alert.runModal();
return;
}
post(
GHOSTTY_NEW_TAB,
Some(&view),
UserInfo::new().with(
NEW_SURFACE_CONFIG_KEY,
inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_TAB),
),
);
}
_ => debug_assert!(false),
}
}
fn new_split(target: &ghostty_target_s, direction: c_int) {
match target.tag {
GHOSTTY_TARGET_APP => {
// New split does nothing with an app target
warn!("new split does nothing with an app target");
}
GHOSTTY_TARGET_SURFACE => {
let Some(surface) = surface_of(target) else {
return;
};
let Some(view) = surface_view(surface) else {
return;
};
post(
GHOSTTY_NEW_SPLIT,
Some(&view),
UserInfo::new().with("direction", direction).with(
NEW_SURFACE_CONFIG_KEY,
inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT),
),
);
}
_ => debug_assert!(false),
}
}
fn present_terminal(target: &ghostty_target_s) -> bool {
match target.tag {
GHOSTTY_TARGET_APP => false,
GHOSTTY_TARGET_SURFACE => {
let Some(surface) = surface_of(target) else {
return false;
};
let Some(view) = surface_view(surface) else {
return false;
};
post(GHOSTTY_PRESENT_TERMINAL, Some(&view), UserInfo::new());
true
}
_ => false,
}
}
fn close_tab(target: &ghostty_target_s, mode: c_int) {
let Some(view) = surface_target("close tabs", target) else {
return;
};
match mode {
GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS => post(GHOSTTY_CLOSE_TAB, Some(&view), UserInfo::new()),
GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER => {
post(GHOSTTY_CLOSE_OTHER_TABS, Some(&view), UserInfo::new());
}
GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT => {
post(
GHOSTTY_CLOSE_TABS_ON_THE_RIGHT,
Some(&view),
UserInfo::new(),
);
}
_ => debug_assert!(false),
}
}
fn close_window(target: &ghostty_target_s) {
let Some(view) = surface_target("close window", target) else {
return;
};
post(GHOSTTY_CLOSE_WINDOW, Some(&view), UserInfo::new());
}
fn close_all_windows() {
let Some(delegate) = app_delegate() else {
return;
};
delegate.close_all_windows();
}
fn toggle_fullscreen(target: &ghostty_target_s, raw: c_int) {
let Some(view) = surface_target("toggle fullscreen", target) else {
return;
};
let Some(mode) = fullscreen_mode::fullscreen_mode_from_ghostty(raw) else {
warn!("unknown fullscreen mode raw={raw}");
return;
};
post(
GHOSTTY_TOGGLE_FULLSCREEN,
Some(&view),
UserInfo::new().with(FULLSCREEN_MODE_KEY, mode),
);
}
fn toggle_command_palette(target: &ghostty_target_s) {
let Some(view) = surface_target("toggle command palette", target) else {
return;
};
post(
GHOSTTY_COMMAND_PALETTE_DID_TOGGLE,
Some(&view),
UserInfo::new(),
);
}
fn toggle_maximize(target: &ghostty_target_s) {
let Some(view) = surface_target("toggle maximize", target) else {
return;
};
post(GHOSTTY_MAXIMIZE_DID_TOGGLE, Some(&view), UserInfo::new());
}
fn toggle_visibility() {
let Some(delegate) = app_delegate() else {
return;
};
delegate.toggle_visibility();
}
fn ring_bell(target: &ghostty_target_s) {
// Technically we could still request app attention here but there are no
// known cases where the bell is rang with an app target so I think its
// better to warn.
let Some(view) = surface_target("ring bell", target) else {
return;
};
post(GHOSTTY_BELL_DID_RING, Some(&view), UserInfo::new());
}
fn set_readonly(target: &ghostty_target_s, v: c_int) {
let Some(view) = surface_target("set readonly", target) else {
return;
};
post(
GHOSTTY_DID_CHANGE_READONLY,
Some(&view),
UserInfo::new().with(READONLY_KEY, v == GHOSTTY_READONLY_ON),
);
}
fn move_tab(
target: &ghostty_target_s,
mv: &crate::ghostty::capi::ghostty_action_move_tab_s,
) -> bool {
let Some(view) = surface_target("move tab", target) else {
return false;
};
// See goto_tab for notes on this check.
if tab_count(&view) <= 1 {
return false;
}
post(
GHOSTTY_MOVE_TAB,
Some(&view),
UserInfo::new().with(GHOSTTY_MOVE_TAB_KEY, MoveTab::from_c(mv)),
);
true
}
fn goto_tab(target: &ghostty_target_s, tab: c_int) -> bool {
let Some(view) = surface_target("goto tab", target) else {
return false;
};
// Similar to goto_split (see comment there) about our performability, we
// should make this more accurate later.
if tab_count(&view) <= 1 {
return false;
}
post(
GHOSTTY_GOTO_TAB,
Some(&view),
UserInfo::new().with(GOTO_TAB_KEY, tab),
);
true
}
fn goto_split(target: &ghostty_target_s, direction: c_int) -> bool {
let Some(view) = surface_target("goto split", target) else {
return false;
};
let Some(controller) = base_controller(&view) else {
return false;
};
// If the window has no splits, the action is not performable
if !controller.surface_tree_is_split() {
return false;
}
// Convert the C API direction to our type
let Some(split_direction) = SplitFocusDirection::from_direction(direction) else {
return false;
};
// Check if a split actually exists in the target direction before
// returning true. This ensures performable keybinds only consume the key
// event when we actually perform navigation.
if !controller.focus_target_exists(split_direction, &view) {
return false;
}
// We have a valid target, post the notification to perform the navigation
post(
GHOSTTY_FOCUS_SPLIT,
Some(&view),
UserInfo::new().with(SPLIT_DIRECTION_KEY, split_direction),
);
true
}
fn goto_window(direction: c_int) -> bool {
let mtm = crate::helpers::main_thread();
// Collect candidate windows: visible terminal windows that are either
// standalone or the currently selected tab in their tab group. This treats
// each native tab group as a single "window" for navigation purposes,
// since goto_tab handles per-tab navigation.
let candidates: Vec<Retained<NSWindow>> = NSApplication::sharedApplication(mtm)
.windows()
.iter()
.filter(|window| {
let is_terminal = window
.windowController()
.is_some_and(|c| c.downcast::<BaseTerminalController>().is_ok());
if !is_terminal || !window.isVisible() || window.isMiniaturized() {
return false;
}
// For native tabs, only include the selected tab in each group
if let Some(group) = window.tabGroup()
&& group
.selectedWindow()
.is_some_and(|s| Retained::as_ptr(&s) != Retained::as_ptr(window))
{
return false;
}
true
})
.collect();
// Need at least two windows to navigate between
if candidates.len() <= 1 {
return false;
}
// Find starting index from the current key/main window
let start_index = candidates
.iter()
.position(|w| w.isKeyWindow())
.or_else(|| candidates.iter().position(|w| w.isMainWindow()))
.unwrap_or(0);
let step: isize = match direction {
GHOSTTY_GOTO_WINDOW_NEXT => 1,
GHOSTTY_GOTO_WINDOW_PREVIOUS => -1,
_ => return false,
};
// Iterate with wrap-around until we find a valid window or return to start
let count = candidates.len() as isize;
let mut index = (start_index as isize + step + count) % count;
while index != start_index as isize {
let candidate = &candidates[index as usize];
if candidate.isVisible() && !candidate.isMiniaturized() {
candidate.makeKeyAndOrderFront(None);
// Also focus the terminal surface within the window
if let Some(controller) = candidate
.windowController()
.and_then(|c| c.downcast::<BaseTerminalController>().ok())
{
if let Some(surface) = controller.focused_surface() {
move_focus(&surface, None, None);
}
}
return true;
}
index = (index + step + count) % count;
}
false
}
fn resize_split(
target: &ghostty_target_s,
resize: &crate::ghostty::capi::ghostty_action_resize_split_s,
) -> bool {
let Some(view) = surface_target("resize split", target) else {
return false;
};
let Some(controller) = base_controller(&view) else {
return false;
};
// If the window has no splits, the action is not performable
if !controller.surface_tree_is_split() {
return false;
}
let Some(direction) = SplitResizeDirection::from_direction(resize.direction) else {
return false;
};
post(
DID_RESIZE_SPLIT,
Some(&view),
UserInfo::new()
.with(RESIZE_SPLIT_DIRECTION_KEY, direction)
.with(RESIZE_SPLIT_AMOUNT_KEY, resize.amount),
);
true
}
fn equalize_splits(target: &ghostty_target_s) {
let Some(view) = surface_target("equalize splits", target) else {
return;
};
post(DID_EQUALIZE_SPLITS, Some(&view), UserInfo::new());
}
fn toggle_split_zoom(target: &ghostty_target_s) -> bool {
let Some(view) = surface_target("toggle split zoom", target) else {
return false;
};
let Some(controller) = base_controller(&view) else {
return false;
};
// If the window has no splits, the action is not performable
if !controller.surface_tree_is_split() {
return false;
}
post(DID_TOGGLE_SPLIT_ZOOM, Some(&view), UserInfo::new());
true
}
fn control_inspector(target: &ghostty_target_s, mode: c_int) {
let Some(view) = surface_target("toggle inspector", target) else {
return;
};
post(
DID_CONTROL_INSPECTOR,
Some(&view),
UserInfo::new().with("mode", mode),
);
}
fn show_desktop_notification_action(
target: &ghostty_target_s,
n: &crate::ghostty::capi::ghostty_action_desktop_notification_s,
) {
let Some(view) = surface_target("desktop notification", target) else {
return;
};
let Some(title) = c_str(n.title) else { return };
let Some(body) = c_str(n.body) else { return };
show_desktop_notification(&view, &title, &body, true);
}
fn show_desktop_notification(
view: &Retained<SurfaceView>,
title: &str,
body: &str,
require_focus: bool,
) {
let center = UNUserNotificationCenter::currentNotificationCenter();
let request_done = RcBlock::new(
|_granted: objc2::runtime::Bool, error: *mut objc2_foundation::NSError| {
// SAFETY: null or a valid NSError for the call.
if let Some(error) = unsafe { error.as_ref() } {
error!(
"Error while requesting notification authorization: {}",
error.localizedDescription()
);
}
},
);
center.requestAuthorizationWithOptions_completionHandler(
UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound,
&request_done,
);
// The settings callback runs on a background thread; hop to the main
// queue before touching the view (Swift calls the view directly).
let mtm = crate::helpers::main_thread();
let bound = std::sync::Arc::new(MainThreadBound::new(
(Weak::from_retained(view), title.to_owned(), body.to_owned()),
mtm,
));
let handler = RcBlock::new(move |settings: NonNull<UNNotificationSettings>| {
// SAFETY: valid settings object for the call.
if unsafe { settings.as_ref().authorizationStatus() } != UNAuthorizationStatus::Authorized {
return;
}
let bound = bound.clone();
DispatchQueue::main().exec_async(move || {
let mtm = MainThreadMarker::new().expect("main queue");
let (weak, title, body) = bound.get(mtm);
if let Some(view) = weak.load() {
view.show_user_notification(title, body, require_focus);
}
});
});
center.getNotificationSettingsWithCompletionHandler(&handler);
}
fn command_finished(
target: &ghostty_target_s,
v: &crate::ghostty::capi::ghostty_action_command_finished_s,
) {
use crate::ghostty::config_types::{NotifyOnCommandFinish, NotifyOnCommandFinishAction};
let Some(view) = surface_target("command finished", target) else {
return;
};
// Determine if we even care about command finish notifications
let Some(config) = app_delegate().map(|d| d.ghostty().config()) else {
return;
};
match config.notify_on_command_finish() {
NotifyOnCommandFinish::Never => return,
NotifyOnCommandFinish::Unfocused => {
if view.focused() {
return;
}
}
NotifyOnCommandFinish::Always => {}
}
// Determine if the command was slow enough
let duration = Duration::from_nanos(v.duration);
if duration < config.notify_on_command_finish_after() {
return;
}
let actions = config.notify_on_command_finish_action();
if actions.contains(NotifyOnCommandFinishAction::BELL) {
post(GHOSTTY_BELL_DID_RING, Some(&view), UserInfo::new());
}
if actions.contains(NotifyOnCommandFinishAction::NOTIFY) {
let title = match v.exit_code.cmp(&0) {
std::cmp::Ordering::Less => "Command Finished",
std::cmp::Ordering::Equal => "Command Succeeded",
std::cmp::Ordering::Greater => "Command Failed",
};
let formatted = duration_format::format_duration_abbreviated(duration);
let body = if v.exit_code < 0 {
format!("Command took {formatted}.")
} else {
format!(
"Command took {formatted} and exited with code {}.",
v.exit_code
)
};
show_desktop_notification(&view, title, &body, false);
}
}
fn toggle_float_window(target: &ghostty_target_s, mode_raw: c_int) {
let Some(mode) = SetFloatWindow::from_c(mode_raw) else {
return;
};
let Some(view) = surface_target("toggle float window", target) else {
return;
};
let Some(window) = view.window() else { return };
let Ok(window) = window.downcast::<TerminalWindow>() else {
return;
};
// SAFETY: plain constants.
let (floating, normal) = (NSFloatingWindowLevel, NSNormalWindowLevel);
match mode {
SetFloatWindow::On => window.setLevel(floating),
SetFloatWindow::Off => window.setLevel(normal),
SetFloatWindow::Toggle => window.setLevel(if window.level() == floating {
normal
} else {
floating
}),
}
if let Some(delegate) = app_delegate() {
delegate.sync_float_on_top_menu(Some(&window));
}
}
fn toggle_background_opacity(target: &ghostty_target_s) {
let Some(view) = surface_target("toggle background opacity", target) else {
return;
};
let Some(controller) = base_controller(&view) else {
return;
};
controller.toggle_background_opacity();
}
fn toggle_secure_input(target: &ghostty_target_s, mode_raw: c_int) {
let Some(mode) = SetSecureInput::from_c(mode_raw) else {
return;
};
match target.tag {
GHOSTTY_TARGET_APP => {
let Some(delegate) = app_delegate() else {
return;
};
delegate.set_secure_input(mode);
}
GHOSTTY_TARGET_SURFACE => {
let Some(surface) = surface_of(target) else {
return;
};
let Some(view) = surface_view(surface) else {
return;
};
let Some(state) = app_state(&view) else {
return;
};
if !state.config().auto_secure_input() {
return;
}
match mode {
SetSecureInput::On => view.set_password_input(true),
SetSecureInput::Off => view.set_password_input(false),
SetSecureInput::Toggle => view.set_password_input(!view.password_input()),
}
}
_ => debug_assert!(false),
}
}
fn toggle_quick_terminal() {
let Some(delegate) = app_delegate() else {
return;
};
delegate.toggle_quick_terminal();
}
fn set_title(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_set_title_s) {
let Some(view) = surface_target("set title", target) else {
return;
};
let Some(title) = c_str(v.title) else { return };
view.set_title(&title);
}
fn set_tab_title(
target: &ghostty_target_s,
v: &crate::ghostty::capi::ghostty_action_set_title_s,
) -> bool {
let Some(view) = surface_target("set tab title", target) else {
return false;
};
let Some(title) = c_str(v.title) else {
return false;
};
let title_override = if title.is_empty() { None } else { Some(title) };
let Some(controller) = base_controller(&view) else {
return false;
};
controller.set_title_override(title_override);
true
}
fn copy_title_to_clipboard(target: &ghostty_target_s) -> bool {
if target.tag != GHOSTTY_TARGET_SURFACE {
return false;
}
let Some(surface) = surface_of(target) else {
return false;
};
let Some(view) = surface_view(surface) else {
return false;
};
let title = view.title();
if title.is_empty() {
return false;
}
let pasteboard = NSPasteboard::generalPasteboard();
pasteboard.clearContents();
// SAFETY: valid constant.
let _ = pasteboard.setString_forType(&NSString::from_str(&title), unsafe {
NSPasteboardTypeString
});
true
}
fn prompt_title(target: &ghostty_target_s, v: c_int) -> bool {
match PromptTitle::from_c(v) {
PromptTitle::Surface => match target.tag {
GHOSTTY_TARGET_APP => {
warn!("set title prompt does nothing with an app target");
false
}
GHOSTTY_TARGET_SURFACE => {
let Some(surface) = surface_of(target) else {
return false;
};
let Some(view) = surface_view(surface) else {
return false;
};
view.prompt_title();
true
}
_ => false,
},
PromptTitle::Tab => match target.tag {
GHOSTTY_TARGET_APP => {
let app = NSApplication::sharedApplication(crate::helpers::main_thread());
let Some(window) = app.mainWindow().or_else(|| app.keyWindow()) else {
return false;
};
let Some(controller) = window
.windowController()
.and_then(|c| c.downcast::<BaseTerminalController>().ok())
else {
return false;
};
controller.prompt_tab_title();
true
}
GHOSTTY_TARGET_SURFACE => {
let Some(surface) = surface_of(target) else {
return false;
};
let Some(view) = surface_view(surface) else {
return false;
};
let Some(controller) = base_controller(&view) else {
return false;
};
controller.prompt_tab_title();
true
}
_ => false,
},
}
}
fn pwd_changed(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_pwd_s) {
let Some(view) = surface_target("pwd change", target) else {
return;
};
let Some(pwd) = c_str(v.pwd) else { return };
view.set_pwd(Some(pwd));
}
fn set_mouse_shape(target: &ghostty_target_s, shape: c_int) {
let Some(view) = surface_target("set mouse shape", target) else {
return;
};
view.set_cursor_shape(shape);
}
fn set_mouse_visibility(target: &ghostty_target_s, v: c_int) {
let Some(view) = surface_target("set mouse visibility", target) else {
return;
};
match v {
GHOSTTY_MOUSE_VISIBLE => view.set_cursor_visibility(true),
GHOSTTY_MOUSE_HIDDEN => view.set_cursor_visibility(false),
_ => {}
}
}
fn set_mouse_over_link(
target: &ghostty_target_s,
v: &crate::ghostty::capi::ghostty_action_mouse_over_link_s,
) {
let Some(view) = surface_target("mouse over link", target) else {
return;
};
if v.len == 0 || v.url.is_null() {
view.set_hover_url(None);
return;
}
// SAFETY: libghostty passes `len` valid bytes.
let buffer = unsafe { std::slice::from_raw_parts(v.url.cast::<u8>(), v.len) };
view.set_hover_url(String::from_utf8(buffer.to_vec()).ok());
}
fn set_initial_size(
target: &ghostty_target_s,
v: &crate::ghostty::capi::ghostty_action_initial_size_s,
) {
let Some(view) = surface_target("initial size", target) else {
return;
};
view.set_initial_size(Some(NSSize::new(f64::from(v.width), f64::from(v.height))));
}
fn reset_window_size(target: &ghostty_target_s) {
let Some(view) = surface_target("reset window size", target) else {
return;
};
post(GHOSTTY_RESET_WINDOW_SIZE, Some(&view), UserInfo::new());
}
fn set_cell_size(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_cell_size_s) {
let Some(view) = surface_target("cell size", target) else {
return;
};
let backing_size = NSSize::new(f64::from(v.width), f64::from(v.height));
let weak = Weak::from_retained(&view);
dispatch_main_async(move || {
let Some(view) = weak.load() else { return };
let size = view.convert_size_from_backing(backing_size);
view.set_cell_size(size);
});
}
fn render_inspector(target: &ghostty_target_s) {
let Some(view) = surface_target("render inspector", target) else {
return;
};
post(INSPECTOR_NEEDS_DISPLAY, Some(&view), UserInfo::new());
}
fn renderer_health(target: &ghostty_target_s, v: c_int) {
let Some(view) = surface_target("renderer health", target) else {
return;
};
post(
DID_UPDATE_RENDERER_HEALTH,
Some(&view),
UserInfo::new().with("health", v),
);
}
fn key_sequence(
target: &ghostty_target_s,
v: &crate::ghostty::capi::ghostty_action_key_sequence_s,
) {
let Some(view) = surface_target("key sequence", target) else {
return;
};
if v.active {
post(
DID_CONTINUE_KEY_SEQUENCE,
Some(&view),
UserInfo::new().with(KEY_SEQUENCE_KEY, input::keyboard_shortcut(&v.trigger)),
);
} else {
post(DID_END_KEY_SEQUENCE, Some(&view), UserInfo::new());
}
}
fn key_table(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_key_table_s) {
let Some(view) = surface_target("key table", target) else {
return;
};
// SAFETY: the C struct comes straight from libghostty.
let Some(action) = (unsafe { KeyTable::from_c(v) }) else {
return;
};
post(
DID_CHANGE_KEY_TABLE,
Some(&view),
UserInfo::new().with(KEY_TABLE_KEY, action),
);
}
fn progress_report(
target: &ghostty_target_s,
v: &crate::ghostty::capi::ghostty_action_progress_report_s,
) {
let Some(view) = surface_target("progress report", target) else {
return;
};
let Some(config) = app_delegate().map(|d| d.ghostty().config()) else {
return;
};
if !config.progress_style() {
debug!("progress_report action blocked by config");
let weak = Weak::from_retained(&view);
dispatch_main_async(move || {
if let Some(view) = weak.load() {
view.set_progress_report(None);
}
});
return;
}
let report = ProgressReport::from_c(v);
let weak = Weak::from_retained(&view);
dispatch_main_async(move || {
let Some(view) = weak.load() else { return };
if report.state == ProgressReportState::Remove {
view.set_progress_report(None);
} else {
view.set_progress_report(Some(report));
}
});
}
fn scrollbar(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_scrollbar_s) {
let Some(view) = surface_target("scrollbar", target) else {
return;
};
post(
GHOSTTY_DID_UPDATE_SCROLLBAR,
Some(&view),
UserInfo::new().with(SCROLLBAR_KEY, Scrollbar::from_c(v)),
);
}
fn start_search(
target: &ghostty_target_s,
v: &crate::ghostty::capi::ghostty_action_start_search_s,
) {
let Some(view) = surface_target("start_search", target) else {
return;
};
// SAFETY: the needle is null or a valid C string.
let start = unsafe { StartSearch::from_c(v) };
let weak = Weak::from_retained(&view);
dispatch_main_async(move || {
let Some(view) = weak.load() else { return };
if let Some(search_state) = view.search_state() {
if let Some(needle) = start.needle.as_ref().filter(|n| !n.is_empty()) {
search_state.needle.set(needle.clone());
}
} else {
view.set_search_state(Some(Rc::new(SearchState::new(&start))));
}
post(GHOSTTY_SEARCH_FOCUS, Some(&view), UserInfo::new());
});
}
fn end_search(target: &ghostty_target_s) {
let Some(view) = surface_target("end_search", target) else {
return;
};
let weak = Weak::from_retained(&view);
dispatch_main_async(move || {
if let Some(view) = weak.load() {
view.set_search_state(None);
}
});
}
fn search_total(target: &ghostty_target_s, total: isize) {
let Some(view) = surface_target("search_total", target) else {
return;
};
let total = usize::try_from(total).ok();
let weak = Weak::from_retained(&view);
dispatch_main_async(move || {
if let Some(state) = weak.load().and_then(|v| v.search_state()) {
state.total.set(total);
}
});
}
fn search_selected(target: &ghostty_target_s, selected: isize) {
let Some(view) = surface_target("search_selected", target) else {
return;
};
let selected = usize::try_from(selected).ok();
let weak = Weak::from_retained(&view);
dispatch_main_async(move || {
if let Some(state) = weak.load().and_then(|v| v.search_state()) {
state.selected.set(selected);
}
});
}
fn config_reload(
app: ghostty_app_t,
target: &ghostty_target_s,
v: &crate::ghostty::capi::ghostty_action_reload_config_s,
) {
info!("config reload notification");
let Some(ghostty) = app_from_handle(app) else {
return;
};
match target.tag {
GHOSTTY_TARGET_APP => ghostty.reload_config(v.soft),
GHOSTTY_TARGET_SURFACE => {
let Some(surface) = surface_of(target) else {
return;
};
ghostty.reload_config_surface(surface, v.soft);
}
_ => debug_assert!(false),
}
}
fn config_change(
app: ghostty_app_t,
target: &ghostty_target_s,
v: &crate::ghostty::capi::ghostty_action_config_change_s,
) {
info!("config change notification");
// Clone the config so we own the memory. It'd be nicer to not have to do
// this but since we async send the config out below we have to own the
// lifetime. A future improvement might be to add reference counting to
// config or something so apprt's do not have to do this.
// SAFETY: `v.config` is valid for the duration of the callback.
let config = Rc::new(unsafe { Config::clone_from_ptr(v.config) });
match target.tag {
GHOSTTY_TARGET_APP => {
// Notify the world that the app config changed
post(
GHOSTTY_CONFIG_DID_CHANGE,
None,
UserInfo::new().with(GHOSTTY_CONFIG_CHANGE_KEY, config.clone()),
);
// We also REPLACE our app-level config when this happens. This lets
// all the various things that depend on this but are still theme
// specific such as split border color work.
let Some(ghostty) = app_from_handle(app) else {
return;
};
ghostty.config.set(config);
}
GHOSTTY_TARGET_SURFACE => {
let Some(surface) = surface_of(target) else {
return;
};
let Some(view) = surface_view(surface) else {
return;
};
post(
GHOSTTY_CONFIG_DID_CHANGE,
Some(&view),
UserInfo::new().with(GHOSTTY_CONFIG_CHANGE_KEY, config),
);
}
_ => debug_assert!(false),
}
}
fn color_change(
target: &ghostty_target_s,
change: &crate::ghostty::capi::ghostty_action_color_change_s,
) {
let Some(view) = surface_target("color change", target) else {
return;
};
post(
GHOSTTY_COLOR_DID_CHANGE,
Some(&view),
UserInfo::new().with(GHOSTTY_COLOR_CHANGE_KEY, ColorChange::from_c(change)),
);
}
@@ -0,0 +1,503 @@
//! GENERATED by scripts/gen-macos-capi-consts.py from include/ghostty.h; do not edit.
//!
//! The enumerators of every C enum of the header (all `int`).
#![allow(missing_docs)]
use std::ffi::c_int;
// ghostty_platform_e
pub const GHOSTTY_PLATFORM_INVALID: c_int = 0;
pub const GHOSTTY_PLATFORM_MACOS: c_int = 1;
pub const GHOSTTY_PLATFORM_IOS: c_int = 2;
// ghostty_clipboard_e
pub const GHOSTTY_CLIPBOARD_STANDARD: c_int = 0;
pub const GHOSTTY_CLIPBOARD_SELECTION: c_int = 1;
// ghostty_clipboard_request_e
pub const GHOSTTY_CLIPBOARD_REQUEST_PASTE: c_int = 0;
pub const GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: c_int = 1;
pub const GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: c_int = 2;
// ghostty_input_mouse_state_e
pub const GHOSTTY_MOUSE_RELEASE: c_int = 0;
pub const GHOSTTY_MOUSE_PRESS: c_int = 1;
// ghostty_input_mouse_button_e
pub const GHOSTTY_MOUSE_UNKNOWN: c_int = 0;
pub const GHOSTTY_MOUSE_LEFT: c_int = 1;
pub const GHOSTTY_MOUSE_RIGHT: c_int = 2;
pub const GHOSTTY_MOUSE_MIDDLE: c_int = 3;
pub const GHOSTTY_MOUSE_FOUR: c_int = 4;
pub const GHOSTTY_MOUSE_FIVE: c_int = 5;
pub const GHOSTTY_MOUSE_SIX: c_int = 6;
pub const GHOSTTY_MOUSE_SEVEN: c_int = 7;
pub const GHOSTTY_MOUSE_EIGHT: c_int = 8;
pub const GHOSTTY_MOUSE_NINE: c_int = 9;
pub const GHOSTTY_MOUSE_TEN: c_int = 10;
pub const GHOSTTY_MOUSE_ELEVEN: c_int = 11;
// ghostty_input_mouse_momentum_e
pub const GHOSTTY_MOUSE_MOMENTUM_NONE: c_int = 0;
pub const GHOSTTY_MOUSE_MOMENTUM_BEGAN: c_int = 1;
pub const GHOSTTY_MOUSE_MOMENTUM_STATIONARY: c_int = 2;
pub const GHOSTTY_MOUSE_MOMENTUM_CHANGED: c_int = 3;
pub const GHOSTTY_MOUSE_MOMENTUM_ENDED: c_int = 4;
pub const GHOSTTY_MOUSE_MOMENTUM_CANCELLED: c_int = 5;
pub const GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN: c_int = 6;
// ghostty_color_scheme_e
pub const GHOSTTY_COLOR_SCHEME_LIGHT: c_int = 0;
pub const GHOSTTY_COLOR_SCHEME_DARK: c_int = 1;
// ghostty_input_mods_e
pub const GHOSTTY_MODS_NONE: c_int = 0;
pub const GHOSTTY_MODS_SHIFT: c_int = 1;
pub const GHOSTTY_MODS_CTRL: c_int = 2;
pub const GHOSTTY_MODS_ALT: c_int = 4;
pub const GHOSTTY_MODS_SUPER: c_int = 8;
pub const GHOSTTY_MODS_CAPS: c_int = 16;
pub const GHOSTTY_MODS_NUM: c_int = 32;
pub const GHOSTTY_MODS_SHIFT_RIGHT: c_int = 64;
pub const GHOSTTY_MODS_CTRL_RIGHT: c_int = 128;
pub const GHOSTTY_MODS_ALT_RIGHT: c_int = 256;
pub const GHOSTTY_MODS_SUPER_RIGHT: c_int = 512;
// ghostty_binding_flags_e
pub const GHOSTTY_BINDING_FLAGS_CONSUMED: c_int = 1;
pub const GHOSTTY_BINDING_FLAGS_ALL: c_int = 2;
pub const GHOSTTY_BINDING_FLAGS_GLOBAL: c_int = 4;
pub const GHOSTTY_BINDING_FLAGS_PERFORMABLE: c_int = 8;
// ghostty_input_action_e
pub const GHOSTTY_ACTION_RELEASE: c_int = 0;
pub const GHOSTTY_ACTION_PRESS: c_int = 1;
pub const GHOSTTY_ACTION_REPEAT: c_int = 2;
// ghostty_input_key_e
pub const GHOSTTY_KEY_UNIDENTIFIED: c_int = 0;
pub const GHOSTTY_KEY_BACKQUOTE: c_int = 1;
pub const GHOSTTY_KEY_BACKSLASH: c_int = 2;
pub const GHOSTTY_KEY_BRACKET_LEFT: c_int = 3;
pub const GHOSTTY_KEY_BRACKET_RIGHT: c_int = 4;
pub const GHOSTTY_KEY_COMMA: c_int = 5;
pub const GHOSTTY_KEY_DIGIT_0: c_int = 6;
pub const GHOSTTY_KEY_DIGIT_1: c_int = 7;
pub const GHOSTTY_KEY_DIGIT_2: c_int = 8;
pub const GHOSTTY_KEY_DIGIT_3: c_int = 9;
pub const GHOSTTY_KEY_DIGIT_4: c_int = 10;
pub const GHOSTTY_KEY_DIGIT_5: c_int = 11;
pub const GHOSTTY_KEY_DIGIT_6: c_int = 12;
pub const GHOSTTY_KEY_DIGIT_7: c_int = 13;
pub const GHOSTTY_KEY_DIGIT_8: c_int = 14;
pub const GHOSTTY_KEY_DIGIT_9: c_int = 15;
pub const GHOSTTY_KEY_EQUAL: c_int = 16;
pub const GHOSTTY_KEY_INTL_BACKSLASH: c_int = 17;
pub const GHOSTTY_KEY_INTL_RO: c_int = 18;
pub const GHOSTTY_KEY_INTL_YEN: c_int = 19;
pub const GHOSTTY_KEY_A: c_int = 20;
pub const GHOSTTY_KEY_B: c_int = 21;
pub const GHOSTTY_KEY_C: c_int = 22;
pub const GHOSTTY_KEY_D: c_int = 23;
pub const GHOSTTY_KEY_E: c_int = 24;
pub const GHOSTTY_KEY_F: c_int = 25;
pub const GHOSTTY_KEY_G: c_int = 26;
pub const GHOSTTY_KEY_H: c_int = 27;
pub const GHOSTTY_KEY_I: c_int = 28;
pub const GHOSTTY_KEY_J: c_int = 29;
pub const GHOSTTY_KEY_K: c_int = 30;
pub const GHOSTTY_KEY_L: c_int = 31;
pub const GHOSTTY_KEY_M: c_int = 32;
pub const GHOSTTY_KEY_N: c_int = 33;
pub const GHOSTTY_KEY_O: c_int = 34;
pub const GHOSTTY_KEY_P: c_int = 35;
pub const GHOSTTY_KEY_Q: c_int = 36;
pub const GHOSTTY_KEY_R: c_int = 37;
pub const GHOSTTY_KEY_S: c_int = 38;
pub const GHOSTTY_KEY_T: c_int = 39;
pub const GHOSTTY_KEY_U: c_int = 40;
pub const GHOSTTY_KEY_V: c_int = 41;
pub const GHOSTTY_KEY_W: c_int = 42;
pub const GHOSTTY_KEY_X: c_int = 43;
pub const GHOSTTY_KEY_Y: c_int = 44;
pub const GHOSTTY_KEY_Z: c_int = 45;
pub const GHOSTTY_KEY_MINUS: c_int = 46;
pub const GHOSTTY_KEY_PERIOD: c_int = 47;
pub const GHOSTTY_KEY_QUOTE: c_int = 48;
pub const GHOSTTY_KEY_SEMICOLON: c_int = 49;
pub const GHOSTTY_KEY_SLASH: c_int = 50;
pub const GHOSTTY_KEY_ALT_LEFT: c_int = 51;
pub const GHOSTTY_KEY_ALT_RIGHT: c_int = 52;
pub const GHOSTTY_KEY_BACKSPACE: c_int = 53;
pub const GHOSTTY_KEY_CAPS_LOCK: c_int = 54;
pub const GHOSTTY_KEY_CONTEXT_MENU: c_int = 55;
pub const GHOSTTY_KEY_CONTROL_LEFT: c_int = 56;
pub const GHOSTTY_KEY_CONTROL_RIGHT: c_int = 57;
pub const GHOSTTY_KEY_ENTER: c_int = 58;
pub const GHOSTTY_KEY_META_LEFT: c_int = 59;
pub const GHOSTTY_KEY_META_RIGHT: c_int = 60;
pub const GHOSTTY_KEY_SHIFT_LEFT: c_int = 61;
pub const GHOSTTY_KEY_SHIFT_RIGHT: c_int = 62;
pub const GHOSTTY_KEY_SPACE: c_int = 63;
pub const GHOSTTY_KEY_TAB: c_int = 64;
pub const GHOSTTY_KEY_CONVERT: c_int = 65;
pub const GHOSTTY_KEY_KANA_MODE: c_int = 66;
pub const GHOSTTY_KEY_NON_CONVERT: c_int = 67;
pub const GHOSTTY_KEY_DELETE: c_int = 68;
pub const GHOSTTY_KEY_END: c_int = 69;
pub const GHOSTTY_KEY_HELP: c_int = 70;
pub const GHOSTTY_KEY_HOME: c_int = 71;
pub const GHOSTTY_KEY_INSERT: c_int = 72;
pub const GHOSTTY_KEY_PAGE_DOWN: c_int = 73;
pub const GHOSTTY_KEY_PAGE_UP: c_int = 74;
pub const GHOSTTY_KEY_ARROW_DOWN: c_int = 75;
pub const GHOSTTY_KEY_ARROW_LEFT: c_int = 76;
pub const GHOSTTY_KEY_ARROW_RIGHT: c_int = 77;
pub const GHOSTTY_KEY_ARROW_UP: c_int = 78;
pub const GHOSTTY_KEY_NUM_LOCK: c_int = 79;
pub const GHOSTTY_KEY_NUMPAD_0: c_int = 80;
pub const GHOSTTY_KEY_NUMPAD_1: c_int = 81;
pub const GHOSTTY_KEY_NUMPAD_2: c_int = 82;
pub const GHOSTTY_KEY_NUMPAD_3: c_int = 83;
pub const GHOSTTY_KEY_NUMPAD_4: c_int = 84;
pub const GHOSTTY_KEY_NUMPAD_5: c_int = 85;
pub const GHOSTTY_KEY_NUMPAD_6: c_int = 86;
pub const GHOSTTY_KEY_NUMPAD_7: c_int = 87;
pub const GHOSTTY_KEY_NUMPAD_8: c_int = 88;
pub const GHOSTTY_KEY_NUMPAD_9: c_int = 89;
pub const GHOSTTY_KEY_NUMPAD_ADD: c_int = 90;
pub const GHOSTTY_KEY_NUMPAD_BACKSPACE: c_int = 91;
pub const GHOSTTY_KEY_NUMPAD_CLEAR: c_int = 92;
pub const GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY: c_int = 93;
pub const GHOSTTY_KEY_NUMPAD_COMMA: c_int = 94;
pub const GHOSTTY_KEY_NUMPAD_DECIMAL: c_int = 95;
pub const GHOSTTY_KEY_NUMPAD_DIVIDE: c_int = 96;
pub const GHOSTTY_KEY_NUMPAD_ENTER: c_int = 97;
pub const GHOSTTY_KEY_NUMPAD_EQUAL: c_int = 98;
pub const GHOSTTY_KEY_NUMPAD_MEMORY_ADD: c_int = 99;
pub const GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR: c_int = 100;
pub const GHOSTTY_KEY_NUMPAD_MEMORY_RECALL: c_int = 101;
pub const GHOSTTY_KEY_NUMPAD_MEMORY_STORE: c_int = 102;
pub const GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT: c_int = 103;
pub const GHOSTTY_KEY_NUMPAD_MULTIPLY: c_int = 104;
pub const GHOSTTY_KEY_NUMPAD_PAREN_LEFT: c_int = 105;
pub const GHOSTTY_KEY_NUMPAD_PAREN_RIGHT: c_int = 106;
pub const GHOSTTY_KEY_NUMPAD_SUBTRACT: c_int = 107;
pub const GHOSTTY_KEY_NUMPAD_SEPARATOR: c_int = 108;
pub const GHOSTTY_KEY_NUMPAD_UP: c_int = 109;
pub const GHOSTTY_KEY_NUMPAD_DOWN: c_int = 110;
pub const GHOSTTY_KEY_NUMPAD_RIGHT: c_int = 111;
pub const GHOSTTY_KEY_NUMPAD_LEFT: c_int = 112;
pub const GHOSTTY_KEY_NUMPAD_BEGIN: c_int = 113;
pub const GHOSTTY_KEY_NUMPAD_HOME: c_int = 114;
pub const GHOSTTY_KEY_NUMPAD_END: c_int = 115;
pub const GHOSTTY_KEY_NUMPAD_INSERT: c_int = 116;
pub const GHOSTTY_KEY_NUMPAD_DELETE: c_int = 117;
pub const GHOSTTY_KEY_NUMPAD_PAGE_UP: c_int = 118;
pub const GHOSTTY_KEY_NUMPAD_PAGE_DOWN: c_int = 119;
pub const GHOSTTY_KEY_ESCAPE: c_int = 120;
pub const GHOSTTY_KEY_F1: c_int = 121;
pub const GHOSTTY_KEY_F2: c_int = 122;
pub const GHOSTTY_KEY_F3: c_int = 123;
pub const GHOSTTY_KEY_F4: c_int = 124;
pub const GHOSTTY_KEY_F5: c_int = 125;
pub const GHOSTTY_KEY_F6: c_int = 126;
pub const GHOSTTY_KEY_F7: c_int = 127;
pub const GHOSTTY_KEY_F8: c_int = 128;
pub const GHOSTTY_KEY_F9: c_int = 129;
pub const GHOSTTY_KEY_F10: c_int = 130;
pub const GHOSTTY_KEY_F11: c_int = 131;
pub const GHOSTTY_KEY_F12: c_int = 132;
pub const GHOSTTY_KEY_F13: c_int = 133;
pub const GHOSTTY_KEY_F14: c_int = 134;
pub const GHOSTTY_KEY_F15: c_int = 135;
pub const GHOSTTY_KEY_F16: c_int = 136;
pub const GHOSTTY_KEY_F17: c_int = 137;
pub const GHOSTTY_KEY_F18: c_int = 138;
pub const GHOSTTY_KEY_F19: c_int = 139;
pub const GHOSTTY_KEY_F20: c_int = 140;
pub const GHOSTTY_KEY_F21: c_int = 141;
pub const GHOSTTY_KEY_F22: c_int = 142;
pub const GHOSTTY_KEY_F23: c_int = 143;
pub const GHOSTTY_KEY_F24: c_int = 144;
pub const GHOSTTY_KEY_F25: c_int = 145;
pub const GHOSTTY_KEY_FN: c_int = 146;
pub const GHOSTTY_KEY_FN_LOCK: c_int = 147;
pub const GHOSTTY_KEY_PRINT_SCREEN: c_int = 148;
pub const GHOSTTY_KEY_SCROLL_LOCK: c_int = 149;
pub const GHOSTTY_KEY_PAUSE: c_int = 150;
pub const GHOSTTY_KEY_BROWSER_BACK: c_int = 151;
pub const GHOSTTY_KEY_BROWSER_FAVORITES: c_int = 152;
pub const GHOSTTY_KEY_BROWSER_FORWARD: c_int = 153;
pub const GHOSTTY_KEY_BROWSER_HOME: c_int = 154;
pub const GHOSTTY_KEY_BROWSER_REFRESH: c_int = 155;
pub const GHOSTTY_KEY_BROWSER_SEARCH: c_int = 156;
pub const GHOSTTY_KEY_BROWSER_STOP: c_int = 157;
pub const GHOSTTY_KEY_EJECT: c_int = 158;
pub const GHOSTTY_KEY_LAUNCH_APP_1: c_int = 159;
pub const GHOSTTY_KEY_LAUNCH_APP_2: c_int = 160;
pub const GHOSTTY_KEY_LAUNCH_MAIL: c_int = 161;
pub const GHOSTTY_KEY_MEDIA_PLAY_PAUSE: c_int = 162;
pub const GHOSTTY_KEY_MEDIA_SELECT: c_int = 163;
pub const GHOSTTY_KEY_MEDIA_STOP: c_int = 164;
pub const GHOSTTY_KEY_MEDIA_TRACK_NEXT: c_int = 165;
pub const GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS: c_int = 166;
pub const GHOSTTY_KEY_POWER: c_int = 167;
pub const GHOSTTY_KEY_SLEEP: c_int = 168;
pub const GHOSTTY_KEY_AUDIO_VOLUME_DOWN: c_int = 169;
pub const GHOSTTY_KEY_AUDIO_VOLUME_MUTE: c_int = 170;
pub const GHOSTTY_KEY_AUDIO_VOLUME_UP: c_int = 171;
pub const GHOSTTY_KEY_WAKE_UP: c_int = 172;
pub const GHOSTTY_KEY_COPY: c_int = 173;
pub const GHOSTTY_KEY_CUT: c_int = 174;
pub const GHOSTTY_KEY_PASTE: c_int = 175;
// ghostty_input_trigger_tag_e
pub const GHOSTTY_TRIGGER_PHYSICAL: c_int = 0;
pub const GHOSTTY_TRIGGER_UNICODE: c_int = 1;
pub const GHOSTTY_TRIGGER_CATCH_ALL: c_int = 2;
// ghostty_build_mode_e
pub const GHOSTTY_BUILD_MODE_DEBUG: c_int = 0;
pub const GHOSTTY_BUILD_MODE_RELEASE_SAFE: c_int = 1;
pub const GHOSTTY_BUILD_MODE_RELEASE_FAST: c_int = 2;
pub const GHOSTTY_BUILD_MODE_RELEASE_SMALL: c_int = 3;
// ghostty_point_tag_e
pub const GHOSTTY_POINT_ACTIVE: c_int = 0;
pub const GHOSTTY_POINT_VIEWPORT: c_int = 1;
pub const GHOSTTY_POINT_SCREEN: c_int = 2;
pub const GHOSTTY_POINT_SURFACE: c_int = 3;
// ghostty_point_coord_e
pub const GHOSTTY_POINT_COORD_EXACT: c_int = 0;
pub const GHOSTTY_POINT_COORD_TOP_LEFT: c_int = 1;
pub const GHOSTTY_POINT_COORD_BOTTOM_RIGHT: c_int = 2;
// ghostty_surface_context_e
pub const GHOSTTY_SURFACE_CONTEXT_WINDOW: c_int = 0;
pub const GHOSTTY_SURFACE_CONTEXT_TAB: c_int = 1;
pub const GHOSTTY_SURFACE_CONTEXT_SPLIT: c_int = 2;
// ghostty_quick_terminal_size_tag_e
pub const GHOSTTY_QUICK_TERMINAL_SIZE_NONE: c_int = 0;
pub const GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE: c_int = 1;
pub const GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS: c_int = 2;
// ghostty_config_fullscreen_e
pub const GHOSTTY_CONFIG_FULLSCREEN_FALSE: c_int = 0;
pub const GHOSTTY_CONFIG_FULLSCREEN_TRUE: c_int = 1;
pub const GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE: c_int = 2;
pub const GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU: c_int = 3;
pub const GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH: c_int = 4;
// ghostty_target_tag_e
pub const GHOSTTY_TARGET_APP: c_int = 0;
pub const GHOSTTY_TARGET_SURFACE: c_int = 1;
// ghostty_action_split_direction_e
pub const GHOSTTY_SPLIT_DIRECTION_RIGHT: c_int = 0;
pub const GHOSTTY_SPLIT_DIRECTION_DOWN: c_int = 1;
pub const GHOSTTY_SPLIT_DIRECTION_LEFT: c_int = 2;
pub const GHOSTTY_SPLIT_DIRECTION_UP: c_int = 3;
// ghostty_action_goto_split_e
pub const GHOSTTY_GOTO_SPLIT_PREVIOUS: c_int = 0;
pub const GHOSTTY_GOTO_SPLIT_NEXT: c_int = 1;
pub const GHOSTTY_GOTO_SPLIT_UP: c_int = 2;
pub const GHOSTTY_GOTO_SPLIT_LEFT: c_int = 3;
pub const GHOSTTY_GOTO_SPLIT_DOWN: c_int = 4;
pub const GHOSTTY_GOTO_SPLIT_RIGHT: c_int = 5;
// ghostty_action_goto_window_e
pub const GHOSTTY_GOTO_WINDOW_PREVIOUS: c_int = 0;
pub const GHOSTTY_GOTO_WINDOW_NEXT: c_int = 1;
// ghostty_action_resize_split_direction_e
pub const GHOSTTY_RESIZE_SPLIT_UP: c_int = 0;
pub const GHOSTTY_RESIZE_SPLIT_DOWN: c_int = 1;
pub const GHOSTTY_RESIZE_SPLIT_LEFT: c_int = 2;
pub const GHOSTTY_RESIZE_SPLIT_RIGHT: c_int = 3;
// ghostty_action_goto_tab_e
pub const GHOSTTY_GOTO_TAB_PREVIOUS: c_int = -1;
pub const GHOSTTY_GOTO_TAB_NEXT: c_int = -2;
pub const GHOSTTY_GOTO_TAB_LAST: c_int = -3;
// ghostty_action_fullscreen_e
pub const GHOSTTY_FULLSCREEN_NATIVE: c_int = 0;
pub const GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE: c_int = 1;
pub const GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU: c_int = 2;
pub const GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH: c_int = 3;
// ghostty_action_float_window_e
pub const GHOSTTY_FLOAT_WINDOW_ON: c_int = 0;
pub const GHOSTTY_FLOAT_WINDOW_OFF: c_int = 1;
pub const GHOSTTY_FLOAT_WINDOW_TOGGLE: c_int = 2;
// ghostty_action_secure_input_e
pub const GHOSTTY_SECURE_INPUT_ON: c_int = 0;
pub const GHOSTTY_SECURE_INPUT_OFF: c_int = 1;
pub const GHOSTTY_SECURE_INPUT_TOGGLE: c_int = 2;
// ghostty_action_inspector_e
pub const GHOSTTY_INSPECTOR_TOGGLE: c_int = 0;
pub const GHOSTTY_INSPECTOR_SHOW: c_int = 1;
pub const GHOSTTY_INSPECTOR_HIDE: c_int = 2;
// ghostty_action_quit_timer_e
pub const GHOSTTY_QUIT_TIMER_START: c_int = 0;
pub const GHOSTTY_QUIT_TIMER_STOP: c_int = 1;
// ghostty_action_readonly_e
pub const GHOSTTY_READONLY_OFF: c_int = 0;
pub const GHOSTTY_READONLY_ON: c_int = 1;
// ghostty_action_prompt_title_e
pub const GHOSTTY_PROMPT_TITLE_SURFACE: c_int = 0;
pub const GHOSTTY_PROMPT_TITLE_TAB: c_int = 1;
// ghostty_action_mouse_shape_e
pub const GHOSTTY_MOUSE_SHAPE_DEFAULT: c_int = 0;
pub const GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: c_int = 1;
pub const GHOSTTY_MOUSE_SHAPE_HELP: c_int = 2;
pub const GHOSTTY_MOUSE_SHAPE_POINTER: c_int = 3;
pub const GHOSTTY_MOUSE_SHAPE_PROGRESS: c_int = 4;
pub const GHOSTTY_MOUSE_SHAPE_WAIT: c_int = 5;
pub const GHOSTTY_MOUSE_SHAPE_CELL: c_int = 6;
pub const GHOSTTY_MOUSE_SHAPE_CROSSHAIR: c_int = 7;
pub const GHOSTTY_MOUSE_SHAPE_TEXT: c_int = 8;
pub const GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: c_int = 9;
pub const GHOSTTY_MOUSE_SHAPE_ALIAS: c_int = 10;
pub const GHOSTTY_MOUSE_SHAPE_COPY: c_int = 11;
pub const GHOSTTY_MOUSE_SHAPE_MOVE: c_int = 12;
pub const GHOSTTY_MOUSE_SHAPE_NO_DROP: c_int = 13;
pub const GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: c_int = 14;
pub const GHOSTTY_MOUSE_SHAPE_GRAB: c_int = 15;
pub const GHOSTTY_MOUSE_SHAPE_GRABBING: c_int = 16;
pub const GHOSTTY_MOUSE_SHAPE_ALL_SCROLL: c_int = 17;
pub const GHOSTTY_MOUSE_SHAPE_COL_RESIZE: c_int = 18;
pub const GHOSTTY_MOUSE_SHAPE_ROW_RESIZE: c_int = 19;
pub const GHOSTTY_MOUSE_SHAPE_N_RESIZE: c_int = 20;
pub const GHOSTTY_MOUSE_SHAPE_E_RESIZE: c_int = 21;
pub const GHOSTTY_MOUSE_SHAPE_S_RESIZE: c_int = 22;
pub const GHOSTTY_MOUSE_SHAPE_W_RESIZE: c_int = 23;
pub const GHOSTTY_MOUSE_SHAPE_NE_RESIZE: c_int = 24;
pub const GHOSTTY_MOUSE_SHAPE_NW_RESIZE: c_int = 25;
pub const GHOSTTY_MOUSE_SHAPE_SE_RESIZE: c_int = 26;
pub const GHOSTTY_MOUSE_SHAPE_SW_RESIZE: c_int = 27;
pub const GHOSTTY_MOUSE_SHAPE_EW_RESIZE: c_int = 28;
pub const GHOSTTY_MOUSE_SHAPE_NS_RESIZE: c_int = 29;
pub const GHOSTTY_MOUSE_SHAPE_NESW_RESIZE: c_int = 30;
pub const GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE: c_int = 31;
pub const GHOSTTY_MOUSE_SHAPE_ZOOM_IN: c_int = 32;
pub const GHOSTTY_MOUSE_SHAPE_ZOOM_OUT: c_int = 33;
// ghostty_action_mouse_visibility_e
pub const GHOSTTY_MOUSE_VISIBLE: c_int = 0;
pub const GHOSTTY_MOUSE_HIDDEN: c_int = 1;
// ghostty_action_renderer_health_e
pub const GHOSTTY_RENDERER_HEALTH_HEALTHY: c_int = 0;
pub const GHOSTTY_RENDERER_HEALTH_UNHEALTHY: c_int = 1;
// ghostty_action_key_table_tag_e
pub const GHOSTTY_KEY_TABLE_ACTIVATE: c_int = 0;
pub const GHOSTTY_KEY_TABLE_DEACTIVATE: c_int = 1;
pub const GHOSTTY_KEY_TABLE_DEACTIVATE_ALL: c_int = 2;
// ghostty_action_color_kind_e
pub const GHOSTTY_ACTION_COLOR_KIND_FOREGROUND: c_int = -1;
pub const GHOSTTY_ACTION_COLOR_KIND_BACKGROUND: c_int = -2;
pub const GHOSTTY_ACTION_COLOR_KIND_CURSOR: c_int = -3;
// ghostty_action_open_url_kind_e
pub const GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN: c_int = 0;
pub const GHOSTTY_ACTION_OPEN_URL_KIND_TEXT: c_int = 1;
pub const GHOSTTY_ACTION_OPEN_URL_KIND_HTML: c_int = 2;
// ghostty_action_close_tab_mode_e
pub const GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS: c_int = 0;
pub const GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER: c_int = 1;
pub const GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT: c_int = 2;
// ghostty_action_progress_report_state_e
pub const GHOSTTY_PROGRESS_STATE_REMOVE: c_int = 0;
pub const GHOSTTY_PROGRESS_STATE_SET: c_int = 1;
pub const GHOSTTY_PROGRESS_STATE_ERROR: c_int = 2;
pub const GHOSTTY_PROGRESS_STATE_INDETERMINATE: c_int = 3;
pub const GHOSTTY_PROGRESS_STATE_PAUSE: c_int = 4;
// ghostty_action_tag_e
pub const GHOSTTY_ACTION_QUIT: c_int = 0;
pub const GHOSTTY_ACTION_NEW_WINDOW: c_int = 1;
pub const GHOSTTY_ACTION_NEW_TAB: c_int = 2;
pub const GHOSTTY_ACTION_CLOSE_TAB: c_int = 3;
pub const GHOSTTY_ACTION_NEW_SPLIT: c_int = 4;
pub const GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: c_int = 5;
pub const GHOSTTY_ACTION_TOGGLE_MAXIMIZE: c_int = 6;
pub const GHOSTTY_ACTION_TOGGLE_FULLSCREEN: c_int = 7;
pub const GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: c_int = 8;
pub const GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: c_int = 9;
pub const GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: c_int = 10;
pub const GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE: c_int = 11;
pub const GHOSTTY_ACTION_TOGGLE_VISIBILITY: c_int = 12;
pub const GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY: c_int = 13;
pub const GHOSTTY_ACTION_MOVE_TAB: c_int = 14;
pub const GHOSTTY_ACTION_GOTO_TAB: c_int = 15;
pub const GHOSTTY_ACTION_GOTO_SPLIT: c_int = 16;
pub const GHOSTTY_ACTION_GOTO_WINDOW: c_int = 17;
pub const GHOSTTY_ACTION_RESIZE_SPLIT: c_int = 18;
pub const GHOSTTY_ACTION_EQUALIZE_SPLITS: c_int = 19;
pub const GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: c_int = 20;
pub const GHOSTTY_ACTION_PRESENT_TERMINAL: c_int = 21;
pub const GHOSTTY_ACTION_SIZE_LIMIT: c_int = 22;
pub const GHOSTTY_ACTION_RESET_WINDOW_SIZE: c_int = 23;
pub const GHOSTTY_ACTION_INITIAL_SIZE: c_int = 24;
pub const GHOSTTY_ACTION_CELL_SIZE: c_int = 25;
pub const GHOSTTY_ACTION_SCROLLBAR: c_int = 26;
pub const GHOSTTY_ACTION_RENDER: c_int = 27;
pub const GHOSTTY_ACTION_INSPECTOR: c_int = 28;
pub const GHOSTTY_ACTION_SHOW_GTK_INSPECTOR: c_int = 29;
pub const GHOSTTY_ACTION_RENDER_INSPECTOR: c_int = 30;
pub const GHOSTTY_ACTION_DESKTOP_NOTIFICATION: c_int = 31;
pub const GHOSTTY_ACTION_SET_TITLE: c_int = 32;
pub const GHOSTTY_ACTION_SET_TAB_TITLE: c_int = 33;
pub const GHOSTTY_ACTION_PROMPT_TITLE: c_int = 34;
pub const GHOSTTY_ACTION_PWD: c_int = 35;
pub const GHOSTTY_ACTION_MOUSE_SHAPE: c_int = 36;
pub const GHOSTTY_ACTION_MOUSE_VISIBILITY: c_int = 37;
pub const GHOSTTY_ACTION_MOUSE_OVER_LINK: c_int = 38;
pub const GHOSTTY_ACTION_RENDERER_HEALTH: c_int = 39;
pub const GHOSTTY_ACTION_OPEN_CONFIG: c_int = 40;
pub const GHOSTTY_ACTION_QUIT_TIMER: c_int = 41;
pub const GHOSTTY_ACTION_FLOAT_WINDOW: c_int = 42;
pub const GHOSTTY_ACTION_SECURE_INPUT: c_int = 43;
pub const GHOSTTY_ACTION_KEY_SEQUENCE: c_int = 44;
pub const GHOSTTY_ACTION_KEY_TABLE: c_int = 45;
pub const GHOSTTY_ACTION_COLOR_CHANGE: c_int = 46;
pub const GHOSTTY_ACTION_RELOAD_CONFIG: c_int = 47;
pub const GHOSTTY_ACTION_CONFIG_CHANGE: c_int = 48;
pub const GHOSTTY_ACTION_CLOSE_WINDOW: c_int = 49;
pub const GHOSTTY_ACTION_RING_BELL: c_int = 50;
pub const GHOSTTY_ACTION_UNDO: c_int = 51;
pub const GHOSTTY_ACTION_REDO: c_int = 52;
pub const GHOSTTY_ACTION_CHECK_FOR_UPDATES: c_int = 53;
pub const GHOSTTY_ACTION_OPEN_URL: c_int = 54;
pub const GHOSTTY_ACTION_SHOW_CHILD_EXITED: c_int = 55;
pub const GHOSTTY_ACTION_PROGRESS_REPORT: c_int = 56;
pub const GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD: c_int = 57;
pub const GHOSTTY_ACTION_COMMAND_FINISHED: c_int = 58;
pub const GHOSTTY_ACTION_START_SEARCH: c_int = 59;
pub const GHOSTTY_ACTION_END_SEARCH: c_int = 60;
pub const GHOSTTY_ACTION_SEARCH_TOTAL: c_int = 61;
pub const GHOSTTY_ACTION_SEARCH_SELECTED: c_int = 62;
pub const GHOSTTY_ACTION_READONLY: c_int = 63;
pub const GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD: c_int = 64;
// ghostty_ipc_target_tag_e
pub const GHOSTTY_IPC_TARGET_CLASS: c_int = 0;
pub const GHOSTTY_IPC_TARGET_DETECT: c_int = 1;
// ghostty_ipc_action_tag_e
pub const GHOSTTY_IPC_ACTION_NEW_WINDOW: c_int = 0;
@@ -0,0 +1,22 @@
//! The libghostty C API as seen by the app.
//!
//! The Swift app imports `ghostty.h` (module `GhosttyKit`). The Rust app links
//! `ghostty-lib` and calls the very same entry points as Rust functions: the
//! exported `extern "C"` functions keep their C names and signatures, and
//! `types` holds the `#[repr(C)]` structs/unions of the header. [`consts`] holds
//! the enumerators (`GHOSTTY_ACTION_QUIT`, ...), generated from the header by
//! `scripts/gen-macos-capi-consts.py`.
//!
//! The functions are `unsafe` (raw handles); the safe wrappers are the types
//! in `ghostty::{app, config, surface}`.
pub mod consts;
pub use libghostty::{config_capi::*, embedded::*, main_c::*, types::*};
/// Length of `s` in bytes as the `usize` the `*_len` parameters of the C API
/// take (`UInt(s.lengthOfBytes(using: .utf8))`).
#[must_use]
pub fn utf8_len(s: &str) -> usize {
s.len()
}
@@ -0,0 +1,89 @@
//! `Ghostty.Command` (`Ghostty.Command.swift`): `ghostty_command_s`.
#![allow(unsafe_code)]
use std::ffi::{CStr, c_char};
use super::capi::ghostty_command_s;
/// A command is fully self-contained (owned strings).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Command {
/// The title of the command.
pub title: String,
/// Human-friendly description of what this command will do.
pub description: String,
/// The full action that must be performed to invoke this command.
pub action: String,
/// Only the key portion of the action so you can compare action types, e.g. `goto_split`
/// instead of `goto_split:left`.
pub action_key: String,
}
impl Command {
/// Unsupported action keys, because they either don't make sense in the context of our
/// target platform or they just aren't implemented yet.
pub const UNSUPPORTED_ACTION_KEYS: [&'static str; 3] = [
"toggle_tab_overview",
"toggle_window_decorations",
"show_gtk_inspector",
];
/// True if this can be performed on this target.
#[must_use]
pub fn is_supported(&self) -> bool {
!Self::UNSUPPORTED_ACTION_KEYS.contains(&self.action_key.as_str())
}
/// `init(cValue:)`.
///
/// # Safety
/// All four pointers of `c` must be NUL terminated strings (or null,
/// which gives an empty string).
#[must_use]
pub unsafe fn from_c(c: &ghostty_command_s) -> Self {
Self {
title: unsafe { cstr(c.title) },
description: unsafe { cstr(c.description) },
action: unsafe { cstr(c.action) },
action_key: unsafe { cstr(c.action_key) },
}
}
}
/// `String(cString:)`.
unsafe fn cstr(p: *const c_char) -> String {
if p.is_null() {
return String::new();
}
// SAFETY: per the contract of `Command::from_c`.
unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_c_and_supported() {
let c = ghostty_command_s {
action_key: c"goto_split".as_ptr(),
action: c"goto_split:left".as_ptr(),
title: c"Focus Left".as_ptr(),
description: c"desc".as_ptr(),
};
// SAFETY: static C strings.
let cmd = unsafe { Command::from_c(&c) };
assert_eq!(cmd.title, "Focus Left");
assert_eq!(cmd.action, "goto_split:left");
assert!(cmd.is_supported());
let c2 = ghostty_command_s {
action_key: c"toggle_tab_overview".as_ptr(),
..c
};
// SAFETY: as above.
assert!(!unsafe { Command::from_c(&c2) }.is_supported());
}
}
+1368
View File
@@ -0,0 +1,1368 @@
//! `Ghostty.Config` (`Ghostty.Config.swift`): maps to a `ghostty_config_t` and
//! the various operations on that.
//!
//! The Swift class is an `ObservableObject`; nothing in the app observes its
//! (never mutated after init) state, so this is a plain owner of the C handle.
//! Share it with `Rc<Config>`.
//!
//! Deviations (recorded in `docs/rewrite/notes/macos-app-MA1.md`):
//! * `background_color` falls back to a fixed light gray where Swift uses the
//! dynamic `NSColor.windowBackgroundColor`; use [`Config::background_color_opt`]
//! to detect the fallback.
//! * `window_fullscreen` / `window_fullscreen_mode` return the raw config
//! strings (`FullscreenMode` belongs to `helpers::fullscreen`).
//! TODO(MA3): convert to `FullscreenMode`.
#![allow(unsafe_code)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::assert_is_empty))]
use std::{
cell::Cell,
ffi::{CStr, CString, c_char, c_int, c_uint, c_void},
time::Duration,
};
use super::{
capi::{
self,
ghostty_config_color_list_s,
ghostty_config_color_s,
ghostty_config_command_list_s,
ghostty_config_path_s,
ghostty_config_quick_terminal_size_s,
ghostty_config_t,
},
command::Command,
config_types::{
AutoUpdate,
AutoUpdateChannel,
BackgroundBlur,
BellFeatures,
ConfigPath,
MacDockDropBehavior,
MacHidden,
MacOSIcon,
MacOSIconFrame,
MacOSTitlebarProxyIcon,
MacOSTitlebarStyle,
MacOSWindowButtons,
MacShortcuts,
NotifyOnCommandFinish,
NotifyOnCommandFinishAction,
ResizeOverlay,
ResizeOverlayPosition,
Scrollbar,
SplitPreserveZoom,
WindowDecoration,
},
input,
};
use crate::{
features::quick_terminal::{
QuickTerminalPosition,
QuickTerminalScreen,
QuickTerminalSize,
QuickTerminalSpaceBehavior,
},
helpers::{app_info::is_running_in_xcode, color::Color, keyboard_shortcut::KeyboardShortcut},
};
/// Maps to a `ghostty_config_t` and the various operations on that.
pub struct Config {
/// The underlying C pointer to the Ghostty config structure (null = nil).
/// This should never be accessed directly. Any operations on this should
/// be called from the functions on this or another class.
config: Cell<ghostty_config_t>,
}
impl Drop for Config {
fn drop(&mut self) {
let old = self.config.replace(std::ptr::null_mut());
if !old.is_null() {
// SAFETY: we own the handle and nothing uses it afterwards.
unsafe { capi::ghostty_config_free(old) };
}
}
}
impl Config {
/// `init(config:)`: takes ownership of `config` (may be null).
#[must_use]
pub fn from_ptr(config: ghostty_config_t) -> Self {
Self {
config: Cell::new(config),
}
}
/// `init(at:finalize:)`.
///
/// * `path`: An optional preferred config file path. Pass `None` to load the default configuration files.
/// * `finalize`: Whether to finalize the configuration to populate default values.
#[must_use]
pub fn new(path: Option<&str>, finalize: bool) -> Self {
Self::from_ptr(Self::load_config(path, finalize))
}
/// `init(clone:)`.
///
/// # Safety
/// `config` must be a valid config handle.
#[must_use]
pub unsafe fn clone_from_ptr(config: ghostty_config_t) -> Self {
// SAFETY: per the contract.
Self::from_ptr(unsafe { capi::ghostty_config_clone(config) })
}
/// The raw handle (for `ghostty_app_update_config` and friends). Owned by
/// `self`; null when not loaded.
#[must_use]
pub fn as_ptr(&self) -> ghostty_config_t {
self.config.get()
}
/// True if the configuration is loaded
#[must_use]
pub fn loaded(&self) -> bool {
!self.config.get().is_null()
}
/// Return the errors found while loading the configuration.
#[must_use]
pub fn errors(&self) -> Vec<String> {
let cfg = self.config.get();
if cfg.is_null() {
return Vec::new();
}
let mut diags = Vec::new();
// SAFETY: `cfg` is a valid handle owned by `self`.
let count = unsafe { capi::ghostty_config_diagnostics_count(cfg) };
for i in 0..count {
// SAFETY: as above; the message lives as long as the config.
let diag = unsafe { capi::ghostty_config_get_diagnostic(cfg, i) };
diags.push(cstr_to_string(diag.message));
}
diags
}
/// Initializes a new configuration and loads all the values.
///
/// * `path`: An optional preferred config file path. Pass `None` to load the default configuration files.
/// * `finalize`: Whether to finalize the configuration to populate default values.
#[must_use]
pub fn load_config(path: Option<&str>, finalize: bool) -> ghostty_config_t {
// Initialize the global configuration.
let cfg = capi::ghostty_config_new();
if cfg.is_null() {
log::error!("ghostty_config_new failed");
return std::ptr::null_mut();
}
// Load our configuration from files, CLI args, and then any referenced files.
// We only do this on macOS because other Apple platforms do not have the
// same filesystem concept. (This crate only targets macOS.)
// SAFETY: `cfg` is the valid handle created above; strings are NUL terminated.
unsafe {
if let Some(path) = path {
match CString::new(path) {
Ok(path) => capi::ghostty_config_load_file(cfg, path.as_ptr()),
Err(_) => log::warn!("config path contains a NUL byte path={path:?}"),
}
} else {
capi::ghostty_config_load_default_files(cfg);
}
// We only load CLI args when not running in Xcode because in Xcode we
// pass some special parameters to control the debugger.
if !is_running_in_xcode() {
capi::ghostty_config_load_cli_args(cfg);
}
capi::ghostty_config_load_recursive_files(cfg);
}
// TODO: we'd probably do some config loading here... for now we'd
// have to do this synchronously. When we support config updating we can do
// this async and update later.
if finalize {
// Finalize will make our defaults available.
// SAFETY: valid handle.
unsafe { capi::ghostty_config_finalize(cfg) };
}
// Log any configuration errors. These will be automatically shown in a
// pop-up window too.
// SAFETY: valid handle.
let diags_count = unsafe { capi::ghostty_config_diagnostics_count(cfg) };
if diags_count > 0 {
log::warn!("config error: {diags_count} configuration errors on reload");
for i in 0..diags_count {
// SAFETY: valid handle; the message lives as long as the config.
let diag = unsafe { capi::ghostty_config_get_diagnostic(cfg, i) };
log::warn!("config error: {}", cstr_to_string(diag.message));
}
}
cfg
}
// MARK: - Keybindings
/// Return the key equivalent for the given action. The action is the name of the action
/// in the Ghostty configuration. For example `keybind = cmd+q=quit` in Ghostty
/// configuration would be "quit" action.
///
/// Returns nil if there is no key equivalent for the given action.
#[must_use]
pub fn keyboard_shortcut(&self, action: &str) -> Option<KeyboardShortcut> {
let cfg = self.config.get();
if cfg.is_null() {
return None;
}
// SAFETY: valid handle; `action` is `len` readable bytes.
let trigger =
unsafe { capi::ghostty_config_trigger(cfg, action.as_ptr().cast(), action.len()) };
input::keyboard_shortcut(&trigger)
}
// MARK: - Raw access
/// `ghostty_config_get(config, &v, key, key.count)`.
fn get_raw<T>(&self, key: &str, out: &mut T) -> bool {
let cfg = self.config.get();
// SAFETY: valid (or null, which the C API tolerates) handle; `out` is
// storage of the key's C type, chosen by the caller per key.
unsafe {
capi::ghostty_config_get(
cfg,
std::ptr::from_mut(out).cast::<c_void>(),
key.as_ptr().cast(),
key.len(),
)
}
}
/// A plain value of C type `T` (`bool`, integer, float): `None` if the key is unsupported/unset.
fn get_value<T>(&self, key: &str, init: T) -> Option<T> {
let mut v = init;
self.get_raw(key, &mut v).then_some(v)
}
/// Bool getter: the value if the lookup works, `init` otherwise.
fn get_bool(&self, key: &str, init: bool) -> bool {
let mut v = init;
let _ = self.get_raw(key, &mut v);
v
}
/// A `?[:0]const u8` / enum-tag string (`UnsafePointer<Int8>?`).
fn get_string(&self, key: &str) -> Option<String> {
let mut v: *const c_char = std::ptr::null();
if !self.get_raw(key, &mut v) || v.is_null() {
return None;
}
Some(cstr_to_string(v))
}
/// A color (`ghostty_config_color_s`).
fn get_color(&self, key: &str) -> Option<ghostty_config_color_s> {
let mut v = ghostty_config_color_s { r: 0, g: 0, b: 0 };
self.get_raw(key, &mut v).then_some(v)
}
// MARK: - Configuration Values
// For all of the configuration values below, see the associated Ghostty documentation for
// details on what each means. We only add documentation if there is a strange conversion
// due to the embedded library and Rust.
#[must_use]
pub fn bell_features(&self) -> BellFeatures {
if !self.loaded() {
return BellFeatures::empty();
}
self.get_value::<c_uint>("bell-features", 0)
.map_or_else(BellFeatures::empty, BellFeatures::from_bits_retain)
}
#[must_use]
pub fn bell_audio_path(&self) -> Option<ConfigPath> {
if !self.loaded() {
return None;
}
let mut v = ghostty_config_path_s {
path: c"".as_ptr(),
optional: false,
};
let key = "bell-audio-path";
if !self.get_raw(key, &mut v) {
return None;
}
let path = cstr_to_string(v.path);
if path.is_empty() {
None
} else {
Some(ConfigPath {
path,
optional: v.optional,
})
}
}
#[must_use]
pub fn bell_audio_volume(&self) -> f32 {
if !self.loaded() {
return 0.5;
}
let mut v: f64 = 0.5;
let _ = self.get_raw("bell-audio-volume", &mut v);
v as f32
}
#[must_use]
pub fn notify_on_command_finish(&self) -> NotifyOnCommandFinish {
if !self.loaded() {
return NotifyOnCommandFinish::Never;
}
self.get_string("notify-on-command-finish")
.and_then(|s| NotifyOnCommandFinish::from_raw_value(&s))
.unwrap_or(NotifyOnCommandFinish::Never)
}
#[must_use]
pub fn notify_on_command_finish_action(&self) -> NotifyOnCommandFinishAction {
let default_value = NotifyOnCommandFinishAction::BELL;
if !self.loaded() {
return default_value;
}
self.get_value::<c_uint>("notify-on-command-finish-action", 0)
.map_or(default_value, NotifyOnCommandFinishAction::from_bits_retain)
}
#[must_use]
pub fn notify_on_command_finish_after(&self) -> Duration {
if !self.loaded() {
return Duration::from_secs(5);
}
let mut v: usize = 0;
let _ = self.get_raw("notify-on-command-finish-after", &mut v);
Duration::from_millis(v as u64)
}
#[must_use]
pub fn split_preserve_zoom(&self) -> SplitPreserveZoom {
if !self.loaded() {
return SplitPreserveZoom::empty();
}
self.get_value::<c_uint>("split-preserve-zoom", 0)
.map_or_else(
SplitPreserveZoom::empty,
SplitPreserveZoom::from_bits_retain,
)
}
#[must_use]
pub fn initial_window(&self) -> bool {
if !self.loaded() {
return true;
}
self.get_bool("initial-window", true)
}
#[must_use]
pub fn should_quit_after_last_window_closed(&self) -> bool {
if !self.loaded() {
return true;
}
self.get_bool("quit-after-last-window-closed", false)
}
#[must_use]
pub fn title(&self) -> Option<String> {
if !self.loaded() {
return None;
}
self.get_string("title")
}
#[must_use]
pub fn window_save_state(&self) -> String {
if !self.loaded() {
return String::new();
}
self.get_string("window-save-state").unwrap_or_default()
}
#[must_use]
pub fn window_position_x(&self) -> Option<i16> {
if !self.loaded() {
return None;
}
self.get_value::<i16>("window-position-x", 0)
}
#[must_use]
pub fn window_position_y(&self) -> Option<i16> {
if !self.loaded() {
return None;
}
self.get_value::<i16>("window-position-y", 0)
}
#[must_use]
pub fn window_new_tab_position(&self) -> String {
if !self.loaded() {
return String::new();
}
self.get_string("window-new-tab-position")
.unwrap_or_default()
}
#[must_use]
pub fn window_decorations(&self) -> bool {
let default_value = true;
if !self.loaded() {
return default_value;
}
self.get_string("window-decoration")
.and_then(|s| WindowDecoration::from_raw_value(&s))
.map_or(default_value, WindowDecoration::enabled)
}
#[must_use]
pub fn window_theme(&self) -> Option<String> {
if !self.loaded() {
return None;
}
self.get_string("window-theme")
}
#[must_use]
pub fn window_step_resize(&self) -> bool {
if !self.loaded() {
return true;
}
self.get_bool("window-step-resize", false)
}
/// The raw `fullscreen` enum config string (`"false"`, `"true"`,
/// `"non-native"`, `"non-native-visible-menu"`, `"non-native-padded-notch"`).
/// Swift returns the fullscreen mode if fullscreen is enabled, or nil if
/// disabled; use [`fullscreen_mode_from_config`] for that mapping.
/// TODO(MA3): return `Option<FullscreenMode>` once `helpers::fullscreen` exists.
#[must_use]
pub fn window_fullscreen(&self) -> Option<String> {
if !self.loaded() {
return None;
}
self.get_string("fullscreen")
}
/// The raw `macos-non-native-fullscreen` config string (`"false"`, `"true"`,
/// `"visible-menu"`, `"padded-notch"`); Swift's default is native
/// (`"false"`). Controls the fullscreen mode for toggle actions (keybindings).
/// TODO(MA3): return `FullscreenMode`.
#[must_use]
pub fn window_fullscreen_mode(&self) -> String {
let default_value = "false".to_string();
if !self.loaded() {
return default_value;
}
self.get_string("macos-non-native-fullscreen")
.unwrap_or(default_value)
}
#[must_use]
pub fn window_title_font_family(&self) -> Option<String> {
if !self.loaded() {
return None;
}
self.get_string("window-title-font-family")
}
#[must_use]
pub fn macos_window_buttons(&self) -> MacOSWindowButtons {
let default_value = MacOSWindowButtons::Visible;
if !self.loaded() {
return default_value;
}
self.get_string("macos-window-buttons")
.and_then(|s| MacOSWindowButtons::from_raw_value(&s))
.unwrap_or(default_value)
}
#[must_use]
pub fn macos_titlebar_style(&self) -> MacOSTitlebarStyle {
let default_value = MacOSTitlebarStyle::Transparent;
if !self.loaded() {
return default_value;
}
self.get_string("macos-titlebar-style")
.and_then(|s| MacOSTitlebarStyle::from_raw_value(&s))
.unwrap_or(default_value)
}
#[must_use]
pub fn macos_titlebar_proxy_icon(&self) -> MacOSTitlebarProxyIcon {
let default_value = MacOSTitlebarProxyIcon::Visible;
if !self.loaded() {
return default_value;
}
self.get_string("macos-titlebar-proxy-icon")
.and_then(|s| MacOSTitlebarProxyIcon::from_raw_value(&s))
.unwrap_or(default_value)
}
#[must_use]
pub fn macos_dock_drop_behavior(&self) -> MacDockDropBehavior {
let default_value = MacDockDropBehavior::NewTab;
if !self.loaded() {
return default_value;
}
self.get_string("macos-dock-drop-behavior")
.and_then(|s| MacDockDropBehavior::from_raw_value(&s))
.unwrap_or(default_value)
}
#[must_use]
pub fn macos_window_shadow(&self) -> bool {
if !self.loaded() {
return false;
}
self.get_bool("macos-window-shadow", false)
}
#[must_use]
pub fn macos_icon(&self) -> MacOSIcon {
let default_value = MacOSIcon::Official;
if !self.loaded() {
return default_value;
}
self.get_string("macos-icon")
.and_then(|s| MacOSIcon::from_raw_value(&s))
.unwrap_or(default_value)
}
#[must_use]
pub fn macos_custom_icon(&self) -> String {
let default_value = expanding_tilde_in_path("~/.config/ghostty/Ghostty.icns");
if !self.loaded() {
return default_value;
}
match self.get_string("macos-custom-icon") {
Some(path) => expanding_tilde_in_path(&path),
None => default_value,
}
}
#[must_use]
pub fn macos_icon_frame(&self) -> MacOSIconFrame {
let default_value = MacOSIconFrame::Aluminum;
if !self.loaded() {
return default_value;
}
self.get_string("macos-icon-frame")
.and_then(|s| MacOSIconFrame::from_raw_value(&s))
.unwrap_or(default_value)
}
/// `macosIconGhostColor` (an `OSColor?` in Swift; AppKit conversion in `helpers::extensions`).
#[must_use]
pub fn macos_icon_ghost_color(&self) -> Option<Color> {
if !self.loaded() {
return None;
}
self.get_color("macos-icon-ghost-color")
.map(|c| Color::from_rgb8(c.r, c.g, c.b))
}
/// `macosIconScreenColor` (an `[OSColor]?` in Swift).
#[must_use]
pub fn macos_icon_screen_color(&self) -> Option<Vec<Color>> {
if !self.loaded() {
return None;
}
let mut v = ghostty_config_color_list_s {
colors: std::ptr::null(),
len: 0,
};
if !self.get_raw("macos-icon-screen-color", &mut v) || v.len == 0 || v.colors.is_null() {
return None;
}
// SAFETY: the C API guarantees `len` colors at `colors`, valid while the config lives.
let buffer = unsafe { std::slice::from_raw_parts(v.colors, v.len) };
Some(
buffer
.iter()
.map(|c| Color::from_rgb8(c.r, c.g, c.b))
.collect(),
)
}
#[must_use]
pub fn macos_hidden(&self) -> MacHidden {
if !self.loaded() {
return MacHidden::Never;
}
self.get_string("macos-hidden")
.and_then(|s| MacHidden::from_raw_value(&s))
.unwrap_or(MacHidden::Never)
}
#[must_use]
pub fn focus_follows_mouse(&self) -> bool {
if !self.loaded() {
return false;
}
self.get_bool("focus-follows-mouse", false)
}
/// `background` when the lookup works.
#[must_use]
pub fn background_color_opt(&self) -> Option<Color> {
self.get_color("background")
.map(|c| Color::from_rgb8(c.r, c.g, c.b))
}
/// `backgroundColor`. Swift falls back to `NSColor.windowBackgroundColor`
/// (dynamic); here a fixed light gray stands in.
#[must_use]
pub fn background_color(&self) -> Color {
self.background_color_opt()
.unwrap_or(Color::new(0.925, 0.925, 0.925))
}
#[must_use]
pub fn background_opacity(&self) -> f64 {
if !self.loaded() {
return 1.0;
}
let mut v: f64 = 1.0;
let _ = self.get_raw("background-opacity", &mut v);
v
}
#[must_use]
pub fn background_blur(&self) -> BackgroundBlur {
if !self.loaded() {
return BackgroundBlur::Disabled;
}
let mut v: i16 = 0;
let _ = self.get_raw("background-blur", &mut v);
BackgroundBlur::from_c_value(v)
}
#[must_use]
pub fn unfocused_split_opacity(&self) -> f64 {
if !self.loaded() {
return 1.0;
}
let mut opacity: f64 = 0.85;
let _ = self.get_raw("unfocused-split-opacity", &mut opacity);
1.0 - opacity
}
#[must_use]
pub fn unfocused_split_fill(&self) -> Color {
if !self.loaded() {
return Color::WHITE;
}
let mut color = ghostty_config_color_s { r: 0, g: 0, b: 0 };
if !self.get_raw("unfocused-split-fill", &mut color) {
let _ = self.get_raw("background", &mut color);
}
Color::from_rgb8(color.r, color.g, color.b)
}
#[must_use]
pub fn split_divider_color(&self) -> Color {
let background_color = self.background_color();
let is_light_background = luminance(background_color) > 0.5;
let new_color = if is_light_background {
darken(background_color, 0.08)
} else {
darken(background_color, 0.4)
};
if !self.loaded() {
return new_color;
}
match self.get_color("split-divider-color") {
Some(c) => Color::from_rgb8(c.r, c.g, c.b),
None => new_color,
}
}
// MARK: Quick terminal
#[must_use]
pub fn quick_terminal_position(&self) -> QuickTerminalPosition {
if !self.loaded() {
return QuickTerminalPosition::Top;
}
self.get_string("quick-terminal-position")
.and_then(|s| QuickTerminalPosition::from_raw_value(&s))
.unwrap_or(QuickTerminalPosition::Top)
}
#[must_use]
pub fn quick_terminal_screen(&self) -> QuickTerminalScreen {
if !self.loaded() {
return QuickTerminalScreen::Main;
}
self.get_string("quick-terminal-screen")
.and_then(|s| QuickTerminalScreen::from_ghostty_config(&s))
.unwrap_or(QuickTerminalScreen::Main)
}
#[must_use]
pub fn quick_terminal_animation_duration(&self) -> f64 {
if !self.loaded() {
return 0.2;
}
let mut v: f64 = 0.2;
let _ = self.get_raw("quick-terminal-animation-duration", &mut v);
v
}
#[must_use]
pub fn quick_terminal_auto_hide(&self) -> bool {
if !self.loaded() {
return true;
}
self.get_bool("quick-terminal-autohide", true)
}
#[must_use]
pub fn quick_terminal_space_behavior(&self) -> QuickTerminalSpaceBehavior {
if !self.loaded() {
return QuickTerminalSpaceBehavior::Move;
}
self.get_string("quick-terminal-space-behavior")
.and_then(|s| QuickTerminalSpaceBehavior::from_ghostty_config(&s))
.unwrap_or(QuickTerminalSpaceBehavior::Move)
}
#[must_use]
pub fn quick_terminal_size(&self) -> QuickTerminalSize {
if !self.loaded() {
return QuickTerminalSize::default();
}
// SAFETY: an all-zero value is a valid `none`/`none` size.
let mut v: ghostty_config_quick_terminal_size_s = unsafe { std::mem::zeroed() };
if !self.get_raw("quick-terminal-size", &mut v) {
return QuickTerminalSize::default();
}
QuickTerminalSize::from_c(&v)
}
#[must_use]
pub fn resize_overlay(&self) -> ResizeOverlay {
if !self.loaded() {
return ResizeOverlay::AfterFirst;
}
self.get_string("resize-overlay")
.and_then(|s| ResizeOverlay::from_raw_value(&s))
.unwrap_or(ResizeOverlay::AfterFirst)
}
#[must_use]
pub fn resize_overlay_position(&self) -> ResizeOverlayPosition {
let default_value = ResizeOverlayPosition::Center;
if !self.loaded() {
return default_value;
}
self.get_string("resize-overlay-position")
.and_then(|s| ResizeOverlayPosition::from_raw_value(&s))
.unwrap_or(default_value)
}
#[must_use]
pub fn resize_overlay_duration(&self) -> usize {
if !self.loaded() {
return 1000;
}
let mut v: usize = 0;
let _ = self.get_raw("resize-overlay-duration", &mut v);
v
}
#[must_use]
pub fn undo_timeout(&self) -> Duration {
if !self.loaded() {
return Duration::from_secs(5);
}
let mut v: usize = 0;
let _ = self.get_raw("undo-timeout", &mut v);
Duration::from_millis(v as u64)
}
#[must_use]
pub fn auto_update(&self) -> Option<AutoUpdate> {
if !self.loaded() {
return None;
}
self.get_string("auto-update")
.and_then(|s| AutoUpdate::from_raw_value(&s))
}
#[must_use]
pub fn auto_update_channel(&self) -> AutoUpdateChannel {
let default_value = AutoUpdateChannel::Stable;
if !self.loaded() {
return default_value;
}
self.get_string("auto-update-channel")
.and_then(|s| AutoUpdateChannel::from_raw_value(&s))
.unwrap_or(default_value)
}
#[must_use]
pub fn auto_secure_input(&self) -> bool {
if !self.loaded() {
return true;
}
self.get_bool("macos-auto-secure-input", false)
}
#[must_use]
pub fn secure_input_indication(&self) -> bool {
if !self.loaded() {
return true;
}
self.get_bool("macos-secure-input-indication", false)
}
#[must_use]
pub fn macos_applescript(&self) -> bool {
if !self.loaded() {
return true;
}
self.get_bool("macos-applescript", false)
}
#[must_use]
pub fn maximize(&self) -> bool {
if !self.loaded() {
return true;
}
self.get_bool("maximize", false)
}
#[must_use]
pub fn macos_shortcuts(&self) -> MacShortcuts {
let default_value = MacShortcuts::Ask;
if !self.loaded() {
return default_value;
}
self.get_string("macos-shortcuts")
.and_then(|s| MacShortcuts::from_raw_value(&s))
.unwrap_or(default_value)
}
#[must_use]
pub fn scrollbar(&self) -> Scrollbar {
let default_value = Scrollbar::System;
if !self.loaded() {
return default_value;
}
self.get_string("scrollbar")
.and_then(|s| Scrollbar::from_raw_value(&s))
.unwrap_or(default_value)
}
#[must_use]
pub fn command_palette_entries(&self) -> Vec<Command> {
if !self.loaded() {
return Vec::new();
}
let mut v = ghostty_config_command_list_s {
commands: std::ptr::null(),
len: 0,
};
if !self.get_raw("command-palette-entry", &mut v) || v.len == 0 || v.commands.is_null() {
return Vec::new();
}
// SAFETY: the C API guarantees `len` commands at `commands`, valid while the config lives.
let buffer = unsafe { std::slice::from_raw_parts(v.commands, v.len) };
// SAFETY: the command strings are NUL terminated.
buffer
.iter()
.map(|c| unsafe { Command::from_c(c) })
.collect()
}
#[must_use]
pub fn progress_style(&self) -> bool {
if !self.loaded() {
return true;
}
self.get_bool("progress-style", true)
}
}
/// The mapping `Ghostty.Config.windowFullscreen` applies to the `fullscreen`
/// config string: `None` when disabled (or unknown), otherwise the
/// `FullscreenMode` case name (`native`, `nonNative`, `nonNativeVisibleMenu`,
/// `nonNativePaddedNotch`).
/// TODO(MA3): superseded by `FullscreenMode` once `helpers::fullscreen` exists.
#[must_use]
pub fn fullscreen_mode_from_config(raw: &str) -> Option<&'static str> {
match raw {
"true" => Some("native"),
"non-native" => Some("nonNative"),
"non-native-visible-menu" => Some("nonNativeVisibleMenu"),
"non-native-padded-notch" => Some("nonNativePaddedNotch"),
_ => None,
}
}
/// `String(cString:)`.
fn cstr_to_string(p: *const c_char) -> String {
if p.is_null() {
return String::new();
}
// SAFETY: C strings handed out by the library are NUL terminated and live
// at least as long as the call.
unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
}
/// `(path as NSString).expandingTildeInPath` for `~` and `~/...`.
fn expanding_tilde_in_path(path: &str) -> String {
let home = || std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
if path == "~" {
home()
} else if let Some(rest) = path.strip_prefix("~/") {
let home = home();
format!("{}/{rest}", home.trim_end_matches('/'))
} else {
path.to_string()
}
}
/// `OSColor.luminance`.
fn luminance(c: Color) -> f64 {
0.299 * c.red + 0.587 * c.green + 0.114 * c.blue
}
/// `OSColor.darken(by:)`: scales HSB brightness by `1 - amount` (hue and
/// saturation kept), which is the same as scaling the RGB components.
fn darken(c: Color, amount: f64) -> Color {
let k = 1.0 - amount;
let s = |v: f64| (v * k).min(1.0);
Color::new(s(c.red), s(c.green), s(c.blue))
}
// Silence "unused" for the c_int import on some cfgs.
const _: Option<c_int> = None;
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
/// `TemporaryConfig`: a config loaded from a temporary file.
struct TemporaryConfig {
config: Config,
_file: tempfile::NamedTempFile,
}
impl std::ops::Deref for TemporaryConfig {
type Target = Config;
fn deref(&self) -> &Config {
&self.config
}
}
impl TemporaryConfig {
fn new(text: &str) -> Self {
Self::with_finalize(text, true)
}
fn with_finalize(text: &str, finalize: bool) -> Self {
let mut file = tempfile::Builder::new()
.suffix(".ghostty")
.tempfile()
.unwrap();
file.write_all(text.as_bytes()).unwrap();
file.flush().unwrap();
let cfg = Self::load(file.path().to_str().unwrap(), finalize);
Self {
config: Config::from_ptr(cfg),
_file: file,
}
}
/// `Config.loadConfig(at:finalize:)` without the default files / CLI
/// args that the test process would otherwise pick up from the host.
fn load(path: &str, finalize: bool) -> ghostty_config_t {
let path = CString::new(path).unwrap();
let cfg = capi::ghostty_config_new();
// SAFETY: valid handle and NUL terminated path.
unsafe {
capi::ghostty_config_load_file(cfg, path.as_ptr());
capi::ghostty_config_load_recursive_files(cfg);
if finalize {
capi::ghostty_config_finalize(cfg);
}
}
cfg
}
fn optional_auto_update_channel(&self) -> Option<AutoUpdateChannel> {
self.get_string("auto-update-channel")
.and_then(|s| AutoUpdateChannel::from_raw_value(&s))
}
}
// MARK: - Boolean Properties
#[test]
fn initial_window_defaults_to_true() {
assert!(TemporaryConfig::new("").initial_window());
}
#[test]
fn initial_window_set_to_false() {
assert!(!TemporaryConfig::new("initial-window = false").initial_window());
}
#[test]
fn quit_after_last_window_closed_defaults_to_false() {
// The default is platform dependent in the config (true on Linux,
// false on macOS); the Swift test runs on macOS.
assert_eq!(
TemporaryConfig::new("").should_quit_after_last_window_closed(),
!cfg!(target_vendor = "apple")
);
}
#[test]
fn quit_after_last_window_closed_set_to_true() {
assert!(
TemporaryConfig::new("quit-after-last-window-closed = true")
.should_quit_after_last_window_closed()
);
}
#[test]
fn window_step_resize_defaults_to_false() {
assert!(!TemporaryConfig::new("").window_step_resize());
}
#[test]
fn focus_follows_mouse_defaults_to_false() {
assert!(!TemporaryConfig::new("").focus_follows_mouse());
}
#[test]
fn focus_follows_mouse_set_to_true() {
assert!(TemporaryConfig::new("focus-follows-mouse = true").focus_follows_mouse());
}
#[test]
fn window_decorations_defaults_to_true() {
assert!(TemporaryConfig::new("").window_decorations());
}
#[test]
fn window_decorations_none() {
assert!(!TemporaryConfig::new("window-decoration = none").window_decorations());
}
#[test]
fn macos_window_shadow_defaults_to_true() {
assert!(TemporaryConfig::new("").macos_window_shadow());
}
#[test]
fn maximize_defaults_to_false() {
assert!(!TemporaryConfig::new("").maximize());
}
#[test]
fn maximize_set_to_true() {
assert!(TemporaryConfig::new("maximize = true").maximize());
}
// MARK: - String / Optional String Properties
#[test]
fn title_defaults_to_nil() {
assert_eq!(TemporaryConfig::new("").title(), None);
}
#[test]
fn title_set_to_custom_value() {
assert_eq!(
TemporaryConfig::new("title = My Terminal")
.title()
.as_deref(),
Some("My Terminal")
);
}
#[test]
fn window_title_font_family_defaults_to_nil() {
assert_eq!(TemporaryConfig::new("").window_title_font_family(), None);
}
#[test]
fn window_title_font_family_set_to_value() {
assert_eq!(
TemporaryConfig::new("window-title-font-family = Menlo")
.window_title_font_family()
.as_deref(),
Some("Menlo")
);
}
// MARK: - Enum Properties
#[test]
fn macos_titlebar_style_defaults_to_transparent() {
assert_eq!(
TemporaryConfig::new("").macos_titlebar_style(),
MacOSTitlebarStyle::Transparent
);
}
#[test]
fn macos_titlebar_style_values() {
for (raw, expected) in [
("native", MacOSTitlebarStyle::Native),
("transparent", MacOSTitlebarStyle::Transparent),
("tabs", MacOSTitlebarStyle::Tabs),
("hidden", MacOSTitlebarStyle::Hidden),
] {
let config = TemporaryConfig::new(&format!("macos-titlebar-style = {raw}"));
assert_eq!(config.macos_titlebar_style(), expected, "{raw}");
}
}
#[test]
fn resize_overlay_defaults_to_after_first() {
assert_eq!(
TemporaryConfig::new("").resize_overlay(),
ResizeOverlay::AfterFirst
);
}
#[test]
fn resize_overlay_values() {
for (raw, expected) in [
("always", ResizeOverlay::Always),
("never", ResizeOverlay::Never),
("after-first", ResizeOverlay::AfterFirst),
] {
let config = TemporaryConfig::new(&format!("resize-overlay = {raw}"));
assert_eq!(config.resize_overlay(), expected, "{raw}");
}
}
#[test]
fn resize_overlay_position_defaults_to_center() {
assert_eq!(
TemporaryConfig::new("").resize_overlay_position(),
ResizeOverlayPosition::Center
);
}
#[test]
fn macos_icon_defaults_to_official() {
assert_eq!(TemporaryConfig::new("").macos_icon(), MacOSIcon::Official);
}
#[test]
fn macos_icon_frame_defaults_to_aluminum() {
assert_eq!(
TemporaryConfig::new("").macos_icon_frame(),
MacOSIconFrame::Aluminum
);
}
#[test]
fn macos_window_buttons_defaults_to_visible() {
assert_eq!(
TemporaryConfig::new("").macos_window_buttons(),
MacOSWindowButtons::Visible
);
}
#[test]
fn scrollbar_defaults_to_system() {
assert_eq!(TemporaryConfig::new("").scrollbar(), Scrollbar::System);
}
#[test]
fn scrollbar_set_to_never() {
assert_eq!(
TemporaryConfig::new("scrollbar = never").scrollbar(),
Scrollbar::Never
);
}
// MARK: - Numeric Properties
#[test]
fn background_opacity_defaults_to_one() {
assert!((TemporaryConfig::new("").background_opacity() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn background_opacity_set_to_custom() {
let v = TemporaryConfig::new("background-opacity = 0.5").background_opacity();
assert!((v - 0.5).abs() < f64::EPSILON);
}
#[test]
fn window_position_defaults_to_nil() {
let config = TemporaryConfig::new("");
assert_eq!(config.window_position_x(), None);
assert_eq!(config.window_position_y(), None);
}
// MARK: - Config Loading
#[test]
fn loaded_is_true_for_valid_config() {
assert!(TemporaryConfig::new("").loaded());
}
#[test]
fn unfinalized_config_is_loaded() {
assert!(TemporaryConfig::with_finalize("", false).loaded());
}
#[test]
fn default_config_is_loaded() {
let config = TemporaryConfig::new("");
assert!(config.optional_auto_update_channel().is_some()); // release or tip
let config1 = TemporaryConfig::with_finalize("", false);
assert!(config1.optional_auto_update_channel().is_none());
}
#[test]
fn errors_empty_for_valid_config() {
assert!(TemporaryConfig::new("").errors().is_empty());
}
#[test]
fn errors_reported_for_invalid_config() {
assert!(
!TemporaryConfig::new("not-a-real-key = value")
.errors()
.is_empty()
);
}
// MARK: - Multiple Config Lines
#[test]
fn multiple_config_values() {
let config = TemporaryConfig::new(
"initial-window = false\nquit-after-last-window-closed = true\nmaximize = true\nfocus-follows-mouse = true\n",
);
assert!(!config.initial_window());
assert!(config.should_quit_after_last_window_closed());
assert!(config.maximize());
assert!(config.focus_follows_mouse());
}
// MARK: - Extra
#[test]
fn config_new_with_path_loads_the_file() {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(b"title = From Path\n").unwrap();
file.flush().unwrap();
let config = Config::new(file.path().to_str(), true);
assert!(config.loaded());
assert_eq!(config.title().as_deref(), Some("From Path"));
}
#[test]
fn null_config_returns_swift_defaults() {
let c = Config::from_ptr(std::ptr::null_mut());
assert!(!c.loaded());
assert!(c.errors().is_empty());
assert!(c.initial_window());
assert!(c.should_quit_after_last_window_closed());
assert!(c.window_step_resize());
assert_eq!(c.window_save_state(), "");
assert_eq!(c.macos_dock_drop_behavior(), MacDockDropBehavior::NewTab);
assert_eq!(c.notify_on_command_finish_after(), Duration::from_secs(5));
assert_eq!(c.undo_timeout(), Duration::from_secs(5));
assert!((c.unfocused_split_opacity() - 1.0).abs() < f64::EPSILON);
assert_eq!(c.unfocused_split_fill(), Color::WHITE);
assert!(c.keyboard_shortcut("quit").is_none());
assert_eq!(c.resize_overlay_duration(), 1000);
}
#[test]
fn keyboard_shortcut_for_bound_action() {
let config = TemporaryConfig::new("keybind = clear\nkeybind = cmd+shift+y=new_window\n");
let s = config.keyboard_shortcut("new_window").expect("bound");
assert_eq!(s.key.character, 'y');
assert_eq!(
s.modifiers,
crate::helpers::keyboard_shortcut::EventModifiers::COMMAND
| crate::helpers::keyboard_shortcut::EventModifiers::SHIFT
);
assert!(config.keyboard_shortcut("quit").is_none());
}
#[test]
fn bell_features_and_command_finish_flags() {
let config = TemporaryConfig::new(
"bell-features = system,attention\nnotify-on-command-finish-action = bell,notify\nnotify-on-command-finish-after = 2s\n",
);
assert!(
config
.bell_features()
.contains(BellFeatures::SYSTEM | BellFeatures::ATTENTION)
);
assert_eq!(
config.notify_on_command_finish_action(),
NotifyOnCommandFinishAction::BELL | NotifyOnCommandFinishAction::NOTIFY
);
assert_eq!(
config.notify_on_command_finish_after(),
Duration::from_secs(2)
);
}
#[test]
fn colors_and_divider() {
let config = TemporaryConfig::new("background = #ffffff\nsplit-divider-color = #102030\n");
assert_eq!(config.background_color(), Color::WHITE);
assert_eq!(
config.split_divider_color(),
Color::from_rgb8(0x10, 0x20, 0x30)
);
let derived = TemporaryConfig::new("background = #000000\n");
assert_eq!(derived.split_divider_color(), Color::new(0.0, 0.0, 0.0));
let light = TemporaryConfig::new("background = #ffffff\n");
let d = light.split_divider_color();
assert!((d.red - 0.92).abs() < 1e-9);
}
#[test]
fn fullscreen_raw_mapping() {
assert_eq!(fullscreen_mode_from_config("true"), Some("native"));
assert_eq!(fullscreen_mode_from_config("false"), None);
let c = TemporaryConfig::new("");
assert_eq!(c.window_fullscreen().as_deref(), Some("false"));
}
#[test]
fn tilde_expansion() {
assert_eq!(expanding_tilde_in_path("/abs"), "/abs");
assert!(!expanding_tilde_in_path("~/x").starts_with('~'));
}
}
@@ -0,0 +1,393 @@
//! The configuration types of `Ghostty.ConfigTypes.swift` and the enums /
//! option sets declared in the `Ghostty.Config` extension of
//! `Ghostty.Config.swift` (and the small config enums of
//! `GhosttyPackage.swift`).
//!
//! Swift `enum X: String` with `init?(rawValue:)` becomes a Rust enum with
//! `from_raw_value(&str) -> Option<Self>` and `raw_value()`.
use bitflags::bitflags;
/// Declares a Swift `enum Name: String` with explicit raw values.
macro_rules! string_enum {
($(#[$meta:meta])* $name:ident { $($(#[$vmeta:meta])* $variant:ident = $raw:literal),+ $(,)? }) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum $name {
$($(#[$vmeta])* $variant),+
}
impl $name {
/// `init?(rawValue:)`.
#[must_use]
pub fn from_raw_value(raw: &str) -> Option<Self> {
match raw {
$($raw => Some(Self::$variant),)+
_ => None,
}
}
/// `rawValue`.
#[must_use]
pub const fn raw_value(self) -> &'static str {
match self {
$(Self::$variant => $raw),+
}
}
}
};
}
/// A configuration path value that may be optional or required.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfigPath {
pub path: String,
pub optional: bool,
}
string_enum! {
/// macos-icon
MacOSIcon {
Official = "official",
Blueprint = "blueprint",
Chalkboard = "chalkboard",
Glass = "glass",
Holographic = "holographic",
Microchip = "microchip",
Paper = "paper",
Retro = "retro",
Xray = "xray",
Custom = "custom",
CustomStyle = "custom-style",
}
}
impl MacOSIcon {
/// Bundled asset name for built-in icons
#[must_use]
pub const fn asset_name(self) -> Option<&'static str> {
match self {
Self::Official | Self::Custom | Self::CustomStyle => None,
Self::Blueprint => Some("BlueprintImage"),
Self::Chalkboard => Some("ChalkboardImage"),
Self::Microchip => Some("MicrochipImage"),
Self::Glass => Some("GlassImage"),
Self::Holographic => Some("HolographicImage"),
Self::Paper => Some("PaperImage"),
Self::Retro => Some("RetroImage"),
Self::Xray => Some("XrayImage"),
}
}
}
string_enum! {
/// macos-icon-frame
MacOSIconFrame {
Aluminum = "aluminum",
Beige = "beige",
Plastic = "plastic",
Chrome = "chrome",
}
}
string_enum! {
AutoUpdate {
Off = "off",
Check = "check",
Download = "download",
}
}
/// Background blur configuration that maps from the C API values.
/// Positive values represent blur radius, special negative values
/// represent macOS-specific glass effects.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BackgroundBlur {
Disabled,
Radius(i64),
MacosGlassRegular,
MacosGlassClear,
}
impl BackgroundBlur {
/// `init(fromCValue:)`. The Swift code gates the glass cases on
/// `#available(macOS 26.0, *)` and falls back to `.disabled`;
/// `glass_available` is that check (use [`BackgroundBlur::from_c_value`]
/// for the runtime one).
#[must_use]
pub const fn from_c_value_with(value: i16, glass_available: bool) -> Self {
match value {
0 => Self::Disabled,
-1 => {
if glass_available {
Self::MacosGlassRegular
} else {
Self::Disabled
}
}
-2 => {
if glass_available {
Self::MacosGlassClear
} else {
Self::Disabled
}
}
_ => Self::Radius(value as i64),
}
}
/// `init(fromCValue:)` using the running OS version for the glass check.
#[must_use]
pub fn from_c_value(value: i16) -> Self {
Self::from_c_value_with(value, macos_26_available())
}
#[must_use]
pub const fn is_enabled(self) -> bool {
!matches!(self, Self::Disabled)
}
/// Returns true if this is a macOS glass style (regular or clear).
#[must_use]
pub const fn is_glass_style(self) -> bool {
matches!(self, Self::MacosGlassRegular | Self::MacosGlassClear)
}
/// Returns the blur radius if applicable, nil for glass effects.
#[must_use]
pub const fn radius(self) -> Option<i64> {
match self {
Self::Radius(r) => Some(r),
_ => None,
}
}
}
/// `#available(macOS 26.0, *)`.
#[cfg(target_vendor = "apple")]
fn macos_26_available() -> bool {
use objc2_foundation::{NSOperatingSystemVersion, NSProcessInfo};
NSProcessInfo::processInfo().isOperatingSystemAtLeastVersion(NSOperatingSystemVersion {
majorVersion: 26,
minorVersion: 0,
patchVersion: 0,
})
}
#[cfg(not(target_vendor = "apple"))]
fn macos_26_available() -> bool {
false
}
bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub struct BellFeatures: u32 {
const SYSTEM = 1 << 0;
const AUDIO = 1 << 1;
const ATTENTION = 1 << 2;
const TITLE = 1 << 3;
const BORDER = 1 << 4;
}
}
bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub struct SplitPreserveZoom: u32 {
const NAVIGATION = 1 << 0;
}
}
string_enum! {
MacDockDropBehavior {
NewTab = "new-tab",
NewWindow = "new-window",
}
}
string_enum! {
MacHidden {
Never = "never",
Always = "always",
}
}
string_enum! {
MacShortcuts {
Allow = "allow",
Deny = "deny",
Ask = "ask",
}
}
string_enum! {
Scrollbar {
System = "system",
Never = "never",
}
}
string_enum! {
ResizeOverlay {
Always = "always",
Never = "never",
AfterFirst = "after-first",
}
}
string_enum! {
ResizeOverlayPosition {
Center = "center",
TopLeft = "top-left",
TopCenter = "top-center",
TopRight = "top-right",
BottomLeft = "bottom-left",
BottomCenter = "bottom-center",
BottomRight = "bottom-right",
}
}
impl ResizeOverlayPosition {
#[must_use]
pub const fn top(self) -> bool {
matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight)
}
#[must_use]
pub const fn bottom(self) -> bool {
matches!(
self,
Self::BottomLeft | Self::BottomCenter | Self::BottomRight
)
}
#[must_use]
pub const fn left(self) -> bool {
matches!(self, Self::TopLeft | Self::BottomLeft)
}
#[must_use]
pub const fn right(self) -> bool {
matches!(self, Self::TopRight | Self::BottomRight)
}
}
string_enum! {
WindowDecoration {
None = "none",
Client = "client",
Server = "server",
Auto = "auto",
}
}
impl WindowDecoration {
#[must_use]
pub const fn enabled(self) -> bool {
!matches!(self, Self::None)
}
}
string_enum! {
NotifyOnCommandFinish {
Never = "never",
Unfocused = "unfocused",
Always = "always",
}
}
bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub struct NotifyOnCommandFinishAction: u32 {
const BELL = 1 << 0;
const NOTIFY = 1 << 1;
}
}
string_enum! {
MacOSTitlebarStyle {
Native = "native",
Transparent = "transparent",
Tabs = "tabs",
Hidden = "hidden",
}
}
impl MacOSTitlebarStyle {
/// `MacOSTitlebarStyle.default`.
pub const DEFAULT: Self = Self::Transparent;
}
string_enum! {
/// Enum for the macos-window-buttons config option
MacOSWindowButtons {
Visible = "visible",
Hidden = "hidden",
}
}
string_enum! {
/// Enum for the macos-titlebar-proxy-icon config option
MacOSTitlebarProxyIcon {
Visible = "visible",
Hidden = "hidden",
}
}
string_enum! {
/// Enum for auto-update-channel config option
AutoUpdateChannel {
Tip = "tip",
Stable = "stable",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_values_round_trip() {
assert_eq!(
ResizeOverlay::from_raw_value("after-first"),
Some(ResizeOverlay::AfterFirst)
);
assert_eq!(ResizeOverlay::AfterFirst.raw_value(), "after-first");
assert_eq!(
MacOSIcon::from_raw_value("custom-style"),
Some(MacOSIcon::CustomStyle)
);
assert_eq!(MacOSIcon::from_raw_value("nope"), None);
assert_eq!(MacOSIcon::Xray.asset_name(), Some("XrayImage"));
assert_eq!(MacOSIcon::Official.asset_name(), None);
}
#[test]
fn background_blur() {
assert_eq!(
BackgroundBlur::from_c_value_with(0, true),
BackgroundBlur::Disabled
);
assert_eq!(
BackgroundBlur::from_c_value_with(-1, true),
BackgroundBlur::MacosGlassRegular
);
assert_eq!(
BackgroundBlur::from_c_value_with(-2, true),
BackgroundBlur::MacosGlassClear
);
assert_eq!(
BackgroundBlur::from_c_value_with(-2, false),
BackgroundBlur::Disabled
);
let b = BackgroundBlur::from_c_value_with(20, false);
assert_eq!(b.radius(), Some(20));
assert!(b.is_enabled() && !b.is_glass_style());
}
#[test]
fn overlay_position_edges() {
assert!(ResizeOverlayPosition::TopRight.top() && ResizeOverlayPosition::TopRight.right());
assert!(!ResizeOverlayPosition::Center.top());
assert!(WindowDecoration::Auto.enabled() && !WindowDecoration::None.enabled());
}
}
@@ -0,0 +1,64 @@
//! Duration formatting used by `Ghostty.App.commandFinished` notifications.
//! Portable (and tested) so the formatting rules are checked on every target.
use std::time::Duration;
/// `Duration.formatted(.units(allowed: [.hours, .minutes, .seconds,
/// .milliseconds], width: .abbreviated, fractionalPart: .hide))` in the en_US
/// locale: zero-valued units are hidden, units are joined with ", " and use
/// the abbreviations `hr`, `min`, `sec`, `ms` (e.g. "1 hr, 2 min, 3 sec").
/// A zero duration formats as "0 ms" (the smallest allowed unit). Sub
/// millisecond remainders are hidden (truncated).
///
/// Note: the exact rounding of `fractionalPart: .hide` and non-English locales
/// are Foundation behaviour that cannot be verified off macOS.
#[must_use]
pub fn format_duration_abbreviated(duration: Duration) -> String {
let total_ms = duration.as_millis();
let hours = total_ms / 3_600_000;
let minutes = (total_ms / 60_000) % 60;
let seconds = (total_ms / 1000) % 60;
let millis = total_ms % 1000;
let mut parts = Vec::new();
if hours > 0 {
parts.push(format!("{hours} hr"));
}
if minutes > 0 {
parts.push(format!("{minutes} min"));
}
if seconds > 0 {
parts.push(format!("{seconds} sec"));
}
if millis > 0 || parts.is_empty() {
parts.push(format!("{millis} ms"));
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duration_formatting() {
assert_eq!(format_duration_abbreviated(Duration::from_secs(5)), "5 sec");
assert_eq!(
format_duration_abbreviated(Duration::from_secs(3723)),
"1 hr, 2 min, 3 sec"
);
assert_eq!(
format_duration_abbreviated(Duration::from_millis(1500)),
"1 sec, 500 ms"
);
assert_eq!(
format_duration_abbreviated(Duration::from_secs(60)),
"1 min"
);
assert_eq!(format_duration_abbreviated(Duration::ZERO), "0 ms");
// Sub-millisecond remainders are hidden.
assert_eq!(
format_duration_abbreviated(Duration::from_nanos(2_000_999_999)),
"2 sec"
);
}
}
+20
View File
@@ -0,0 +1,20 @@
//! `Ghostty.Error` (`Ghostty.Error.swift`): possible errors from internal
//! Ghostty calls.
use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Error {
ApiFailed,
}
impl fmt::Display for Error {
/// `localizedStringResource`.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ApiFailed => f.write_str("libghostty API call failed"),
}
}
}
impl std::error::Error for Error {}
+20
View File
@@ -0,0 +1,20 @@
//! `Ghostty.Event.swift`.
use objc2_app_kit::{NSEvent, NSEventModifierFlags};
/// A comparable event.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ComparableKeyEvent {
pub key_code: u16,
pub flags: NSEventModifierFlags,
}
impl ComparableKeyEvent {
#[must_use]
pub fn new(event: &NSEvent) -> Self {
Self {
key_code: event.keyCode(),
flags: event.modifierFlags(),
}
}
}
@@ -0,0 +1,43 @@
//! `Ghostty/FullscreenMode+Extension.swift`.
use super::capi::consts::{
GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE,
GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH,
GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU,
GHOSTTY_FULLSCREEN_NATIVE,
};
use crate::helpers::fullscreen_logic::FullscreenMode;
/// `FullscreenMode.from(ghostty:)`: initialize from a Ghostty fullscreen action.
#[must_use]
pub fn fullscreen_mode_from_ghostty(raw: std::ffi::c_int) -> Option<FullscreenMode> {
match raw {
GHOSTTY_FULLSCREEN_NATIVE => Some(FullscreenMode::Native),
GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE => Some(FullscreenMode::NonNative),
GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU => {
Some(FullscreenMode::NonNativeVisibleMenu)
}
GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH => {
Some(FullscreenMode::NonNativePaddedNotch)
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_all_modes() {
assert_eq!(
fullscreen_mode_from_ghostty(GHOSTTY_FULLSCREEN_NATIVE),
Some(FullscreenMode::Native)
);
assert_eq!(
fullscreen_mode_from_ghostty(GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH),
Some(FullscreenMode::NonNativePaddedNotch)
);
assert_eq!(fullscreen_mode_from_ghostty(-1), None);
}
}
@@ -0,0 +1,334 @@
//! The top of `Ghostty.Input.swift`: keyboard shortcuts from triggers, the
//! `NSEvent.ModifierFlags` <-> `ghostty_input_mods_e` conversions, the
//! `keyToEquivalent` map and `Ghostty.Input.BindingFlags`.
//!
//! TODO(MA2): the rest of `Ghostty.Input.swift` (`KeyEvent`, `MouseButtonEvent`,
//! `MousePosEvent`, `MouseScrollEvent`, `MouseState`, `MouseButton`,
//! `ScrollMods`, `Momentum`, `Mods`, the `Key` enum and the `AppEnum`
//! conformances) belongs to the Surface View port and is added to this module
//! by that agent.
//!
//! Modifier flags are handled as the raw `NSEvent.ModifierFlags.rawValue`
//! (`u64`, `NSUInteger`) so this code is portable and unit tested on every
//! target; the AppKit wrappers pass `flags.bits()`.
#![allow(unsafe_code, clippy::wildcard_imports)]
#![cfg_attr(test, allow(clippy::unwrap_used))]
use bitflags::bitflags;
use super::capi::{consts::*, ghostty_input_key_e, ghostty_input_mods_e, ghostty_input_trigger_s};
use crate::helpers::keyboard_shortcut::{EventModifiers, KeyEquivalent, KeyboardShortcut};
// MARK: NSEvent.ModifierFlags raw values
/// `NSEvent.ModifierFlags.capsLock`
pub const NS_FLAG_CAPS_LOCK: u64 = 1 << 16;
/// `NSEvent.ModifierFlags.shift`
pub const NS_FLAG_SHIFT: u64 = 1 << 17;
/// `NSEvent.ModifierFlags.control`
pub const NS_FLAG_CONTROL: u64 = 1 << 18;
/// `NSEvent.ModifierFlags.option`
pub const NS_FLAG_OPTION: u64 = 1 << 19;
/// `NSEvent.ModifierFlags.command`
pub const NS_FLAG_COMMAND: u64 = 1 << 20;
/// `NX_DEVICERSHIFTKEYMASK`
pub const NX_DEVICERSHIFTKEYMASK: u64 = 0x0000_0004;
/// `NX_DEVICERCTLKEYMASK`
pub const NX_DEVICERCTLKEYMASK: u64 = 0x0000_2000;
/// `NX_DEVICERALTKEYMASK`
pub const NX_DEVICERALTKEYMASK: u64 = 0x0000_0040;
/// `NX_DEVICERCMDKEYMASK`
pub const NX_DEVICERCMDKEYMASK: u64 = 0x0000_0010;
// MARK: Keyboard Shortcuts
/// Return the key equivalent for the given trigger.
///
/// Returns nil if the trigger doesn't have an equivalent KeyboardShortcut. This is possible
/// because Ghostty input triggers are a superset of what can be represented by a macOS
/// KeyboardShortcut. For example, macOS doesn't have any way to represent function keys
/// (F1, F2, ...) with a KeyboardShortcut. This doesn't represent a practical issue because input
/// handling for Ghostty is handled at a lower level (usually). This function should generally only
/// be used for things like NSMenu that only support keyboard shortcuts anyways.
#[must_use]
pub fn keyboard_shortcut(trigger: &ghostty_input_trigger_s) -> Option<KeyboardShortcut> {
let key = match trigger.tag {
GHOSTTY_TRIGGER_PHYSICAL => {
// Only functional keys can be converted to a KeyboardShortcut. Other physical
// mappings cannot because KeyboardShortcut in Swift is inherently layout-dependent.
// SAFETY: the tag selects the `physical` member (all members are 32-bit).
key_to_equivalent(unsafe { trigger.key.physical })?
}
GHOSTTY_TRIGGER_UNICODE => {
// SAFETY: the tag selects the `unicode` member.
let scalar = char::from_u32(unsafe { trigger.key.unicode })?;
KeyEquivalent::new(scalar)
}
// catch_all matches any key, so it can't be represented as a KeyboardShortcut
_ => return None,
};
Some(KeyboardShortcut::new(
key,
event_modifiers_from_ns_flags(event_modifier_flags(trigger.mods)),
))
}
// MARK: Mods
/// Returns the event modifier flags (raw `NSEvent.ModifierFlags`) set for the Ghostty mods enum.
#[must_use]
pub fn event_modifier_flags(mods: ghostty_input_mods_e) -> u64 {
let mut flags = 0;
if mods & GHOSTTY_MODS_SHIFT != 0 {
flags |= NS_FLAG_SHIFT;
}
if mods & GHOSTTY_MODS_CTRL != 0 {
flags |= NS_FLAG_CONTROL;
}
if mods & GHOSTTY_MODS_ALT != 0 {
flags |= NS_FLAG_OPTION;
}
if mods & GHOSTTY_MODS_SUPER != 0 {
flags |= NS_FLAG_COMMAND;
}
flags
}
/// Translate event modifier flags (raw `NSEvent.ModifierFlags`) to a ghostty mods enum.
#[must_use]
pub fn ghostty_mods(flags: u64) -> ghostty_input_mods_e {
let mut mods = GHOSTTY_MODS_NONE;
if flags & NS_FLAG_SHIFT != 0 {
mods |= GHOSTTY_MODS_SHIFT;
}
if flags & NS_FLAG_CONTROL != 0 {
mods |= GHOSTTY_MODS_CTRL;
}
if flags & NS_FLAG_OPTION != 0 {
mods |= GHOSTTY_MODS_ALT;
}
if flags & NS_FLAG_COMMAND != 0 {
mods |= GHOSTTY_MODS_SUPER;
}
if flags & NS_FLAG_CAPS_LOCK != 0 {
mods |= GHOSTTY_MODS_CAPS;
}
// Handle sided input. We can't tell that both are pressed in the
// Ghostty structure but that's okay -- we don't use that information.
if flags & NX_DEVICERSHIFTKEYMASK != 0 {
mods |= GHOSTTY_MODS_SHIFT_RIGHT;
}
if flags & NX_DEVICERCTLKEYMASK != 0 {
mods |= GHOSTTY_MODS_CTRL_RIGHT;
}
if flags & NX_DEVICERALTKEYMASK != 0 {
mods |= GHOSTTY_MODS_ALT_RIGHT;
}
if flags & NX_DEVICERCMDKEYMASK != 0 {
mods |= GHOSTTY_MODS_SUPER_RIGHT;
}
mods
}
/// `EventModifiers(nsFlags:)` (`EventModifiers+Extension.swift`) on raw flags.
#[must_use]
pub fn event_modifiers_from_ns_flags(flags: u64) -> EventModifiers {
let mut result = EventModifiers::empty();
if flags & NS_FLAG_SHIFT != 0 {
result.insert(EventModifiers::SHIFT);
}
if flags & NS_FLAG_CONTROL != 0 {
result.insert(EventModifiers::CONTROL);
}
if flags & NS_FLAG_OPTION != 0 {
result.insert(EventModifiers::OPTION);
}
if flags & NS_FLAG_COMMAND != 0 {
result.insert(EventModifiers::COMMAND);
}
if flags & NS_FLAG_CAPS_LOCK != 0 {
result.insert(EventModifiers::CAPS_LOCK);
}
result
}
/// `NSEvent.ModifierFlags(swiftUIFlags:)` (`EventModifiers+Extension.swift`), as raw flags.
#[must_use]
pub fn ns_flags_from_event_modifiers(flags: EventModifiers) -> u64 {
let mut result = 0;
if flags.contains(EventModifiers::SHIFT) {
result |= NS_FLAG_SHIFT;
}
if flags.contains(EventModifiers::CONTROL) {
result |= NS_FLAG_CONTROL;
}
if flags.contains(EventModifiers::OPTION) {
result |= NS_FLAG_OPTION;
}
if flags.contains(EventModifiers::COMMAND) {
result |= NS_FLAG_COMMAND;
}
if flags.contains(EventModifiers::CAPS_LOCK) {
result |= NS_FLAG_CAPS_LOCK;
}
result
}
/// `Ghostty.keyToEquivalent`: a map from the Ghostty key enum to the keyEquivalent string for
/// shortcuts. Note that not all ghostty key enum values are represented here because not all of
/// them can be mapped to a KeyEquivalent.
#[must_use]
pub fn key_to_equivalent(key: ghostty_input_key_e) -> Option<KeyEquivalent> {
Some(match key {
// Function keys
GHOSTTY_KEY_ARROW_UP => KeyEquivalent::UP_ARROW,
GHOSTTY_KEY_ARROW_DOWN => KeyEquivalent::DOWN_ARROW,
GHOSTTY_KEY_ARROW_LEFT => KeyEquivalent::LEFT_ARROW,
GHOSTTY_KEY_ARROW_RIGHT => KeyEquivalent::RIGHT_ARROW,
GHOSTTY_KEY_HOME => KeyEquivalent::HOME,
GHOSTTY_KEY_END => KeyEquivalent::END,
GHOSTTY_KEY_DELETE => KeyEquivalent::DELETE,
GHOSTTY_KEY_PAGE_UP => KeyEquivalent::PAGE_UP,
GHOSTTY_KEY_PAGE_DOWN => KeyEquivalent::PAGE_DOWN,
GHOSTTY_KEY_ESCAPE => KeyEquivalent::ESCAPE,
GHOSTTY_KEY_ENTER => KeyEquivalent::RETURN,
GHOSTTY_KEY_TAB => KeyEquivalent::TAB,
GHOSTTY_KEY_BACKSPACE => KeyEquivalent::DELETE,
GHOSTTY_KEY_SPACE => KeyEquivalent::SPACE,
_ => return None,
})
}
// MARK: Ghostty.Input.BindingFlags
bitflags! {
/// `ghostty_binding_flags_e`
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub struct BindingFlags: u32 {
const CONSUMED = GHOSTTY_BINDING_FLAGS_CONSUMED as u32;
const ALL = GHOSTTY_BINDING_FLAGS_ALL as u32;
const GLOBAL = GHOSTTY_BINDING_FLAGS_GLOBAL as u32;
const PERFORMABLE = GHOSTTY_BINDING_FLAGS_PERFORMABLE as u32;
}
}
impl BindingFlags {
/// `init(cFlags:)`.
#[must_use]
pub const fn from_c_flags(c: c_int) -> Self {
Self::from_bits_retain(c as u32)
}
/// `cFlags`.
#[must_use]
pub const fn c_flags(self) -> c_int {
self.bits() as c_int
}
}
use std::ffi::c_int;
#[cfg(test)]
mod tests {
use super::*;
use crate::ghostty::capi::ghostty_input_trigger_key_u;
fn unicode(c: char, mods: c_int) -> ghostty_input_trigger_s {
ghostty_input_trigger_s {
tag: GHOSTTY_TRIGGER_UNICODE,
key: ghostty_input_trigger_key_u { unicode: c as u32 },
mods,
}
}
#[test]
fn unicode_trigger_becomes_shortcut() {
let s = keyboard_shortcut(&unicode('q', GHOSTTY_MODS_SUPER)).unwrap();
assert_eq!(
s,
KeyboardShortcut::new(KeyEquivalent::new('q'), EventModifiers::COMMAND)
);
let s = keyboard_shortcut(&unicode(',', GHOSTTY_MODS_SUPER | GHOSTTY_MODS_SHIFT)).unwrap();
assert_eq!(s.modifiers, EventModifiers::COMMAND | EventModifiers::SHIFT);
}
#[test]
fn physical_and_catch_all_triggers() {
let t = ghostty_input_trigger_s {
tag: GHOSTTY_TRIGGER_PHYSICAL,
key: ghostty_input_trigger_key_u {
physical: GHOSTTY_KEY_ARROW_LEFT,
},
mods: GHOSTTY_MODS_ALT,
};
let s = keyboard_shortcut(&t).unwrap();
assert_eq!(s.key, KeyEquivalent::LEFT_ARROW);
assert_eq!(s.modifiers, EventModifiers::OPTION);
// Not a functional key.
let t = ghostty_input_trigger_s {
key: ghostty_input_trigger_key_u {
physical: GHOSTTY_KEY_A,
},
..t
};
assert!(keyboard_shortcut(&t).is_none());
let t = ghostty_input_trigger_s {
tag: GHOSTTY_TRIGGER_CATCH_ALL,
..t
};
assert!(keyboard_shortcut(&t).is_none());
}
#[test]
fn invalid_unicode_scalar_is_none() {
let t = unicode('a', 0);
let t = ghostty_input_trigger_s {
key: ghostty_input_trigger_key_u { unicode: 0xD800 },
..t
};
assert!(keyboard_shortcut(&t).is_none());
}
#[test]
fn mods_round_trip_and_sides() {
let flags = NS_FLAG_SHIFT | NS_FLAG_COMMAND | NS_FLAG_CAPS_LOCK | NX_DEVICERCMDKEYMASK;
let m = ghostty_mods(flags);
assert_eq!(
m,
GHOSTTY_MODS_SHIFT | GHOSTTY_MODS_SUPER | GHOSTTY_MODS_CAPS | GHOSTTY_MODS_SUPER_RIGHT
);
assert_eq!(event_modifier_flags(m), NS_FLAG_SHIFT | NS_FLAG_COMMAND);
assert_eq!(
ghostty_mods(NX_DEVICERCTLKEYMASK | NX_DEVICERALTKEYMASK | NX_DEVICERSHIFTKEYMASK),
GHOSTTY_MODS_CTRL_RIGHT | GHOSTTY_MODS_ALT_RIGHT | GHOSTTY_MODS_SHIFT_RIGHT
);
assert_eq!(ghostty_mods(0), GHOSTTY_MODS_NONE);
}
#[test]
fn event_modifiers_conversions() {
let m = EventModifiers::SHIFT | EventModifiers::OPTION | EventModifiers::CAPS_LOCK;
assert_eq!(
event_modifiers_from_ns_flags(ns_flags_from_event_modifiers(m)),
m
);
}
#[test]
fn binding_flags() {
let f = BindingFlags::from_c_flags(
GHOSTTY_BINDING_FLAGS_CONSUMED | GHOSTTY_BINDING_FLAGS_GLOBAL,
);
assert!(f.contains(BindingFlags::CONSUMED | BindingFlags::GLOBAL));
assert!(!f.contains(BindingFlags::PERFORMABLE));
assert_eq!(f.c_flags(), 5);
}
}
+34
View File
@@ -0,0 +1,34 @@
//! `Sources/Ghostty`: the Swift side's wrappers around the libghostty C API
//! (`Ghostty.App`, `Ghostty.Config`, `Ghostty.Surface`, actions, notifications).
//!
//! These drive `ghostty-lib`'s `ghostty_*` functions directly (see [`capi`]),
//! i.e. the identical API surface that the Swift app drives through
//! `ghostty.h`.
// Platform independent (tested on every target).
pub mod action;
pub mod capi;
pub mod command;
pub mod config;
pub mod config_types;
pub mod duration_format;
pub mod error;
pub mod input;
pub mod notification;
pub mod package;
pub mod shell;
pub mod surface_configuration;
// AppKit.
#[cfg(target_vendor = "apple")]
pub mod app;
#[cfg(target_vendor = "apple")]
pub mod event;
#[cfg(target_vendor = "apple")]
pub mod fullscreen_mode;
#[cfg(target_vendor = "apple")]
pub mod ns_event;
#[cfg(target_vendor = "apple")]
pub mod surface;
#[cfg(target_vendor = "apple")]
pub mod surface_view;
@@ -0,0 +1,182 @@
//! The notification names and `userInfo` keys of `GhosttyPackage.swift`
//! (`extension Notification.Name` and `extension Ghostty.Notification`), with
//! the same strings as the Swift app.
//!
//! Post and observe them through
//! [`NotificationCenter`](crate::helpers::notification_center::NotificationCenter);
//! `userInfo` values keep their Rust type (see that module).
//!
//! The Swift code has two namespaces (`Notification.Name.*` and the older
//! `Ghostty.Notification.*`); both are flattened here, the Swift spelling is
//! given in each doc comment.
use crate::helpers::notification_center::Name;
// MARK: Notification.Name
/// Configuration change. If the object is nil then it is app-wide. Otherwise its surface-specific.
pub const GHOSTTY_CONFIG_DID_CHANGE: Name = Name("com.mitchellh.ghostty.configDidChange");
/// `GhosttyConfigChangeKey` (value: `Rc<Config>`).
pub const GHOSTTY_CONFIG_CHANGE_KEY: &str = GHOSTTY_CONFIG_DID_CHANGE.0;
/// Color change. Object is the surface changing.
pub const GHOSTTY_COLOR_DID_CHANGE: Name = Name("com.mitchellh.ghostty.ghosttyColorDidChange");
/// `GhosttyColorChangeKey` (value: `action::ColorChange`).
pub const GHOSTTY_COLOR_CHANGE_KEY: &str = GHOSTTY_COLOR_DID_CHANGE.0;
/// Goto tab. Has tab index in the userinfo.
pub const GHOSTTY_MOVE_TAB: Name = Name("com.mitchellh.ghostty.moveTab");
/// `GhosttyMoveTabKey` (value: `action::MoveTab`).
pub const GHOSTTY_MOVE_TAB_KEY: &str = GHOSTTY_MOVE_TAB.0;
/// Close tab
pub const GHOSTTY_CLOSE_TAB: Name = Name("com.mitchellh.ghostty.closeTab");
/// Close other tabs
pub const GHOSTTY_CLOSE_OTHER_TABS: Name = Name("com.mitchellh.ghostty.closeOtherTabs");
/// Close tabs to the right of the focused tab
pub const GHOSTTY_CLOSE_TABS_ON_THE_RIGHT: Name = Name("com.mitchellh.ghostty.closeTabsOnTheRight");
/// Close window
pub const GHOSTTY_CLOSE_WINDOW: Name = Name("com.mitchellh.ghostty.closeWindow");
/// Resize the window to a default size.
pub const GHOSTTY_RESET_WINDOW_SIZE: Name = Name("com.mitchellh.ghostty.resetWindowSize");
/// Ring the bell
pub const GHOSTTY_BELL_DID_RING: Name = Name("com.mitchellh.ghostty.ghosttyBellDidRing");
/// Readonly mode changed
pub const GHOSTTY_DID_CHANGE_READONLY: Name = Name("com.mitchellh.ghostty.didChangeReadonly");
/// `ReadonlyKey` (value: `bool`).
pub const READONLY_KEY: &str = "com.mitchellh.ghostty.didChangeReadonly.readonly";
pub const GHOSTTY_COMMAND_PALETTE_DID_TOGGLE: Name =
Name("com.mitchellh.ghostty.commandPaletteDidToggle");
/// Toggle maximize of current window
pub const GHOSTTY_MAXIMIZE_DID_TOGGLE: Name = Name("com.mitchellh.ghostty.maximizeDidToggle");
/// Notification sent when scrollbar updates
pub const GHOSTTY_DID_UPDATE_SCROLLBAR: Name = Name("com.mitchellh.ghostty.didUpdateScrollbar");
/// `ScrollbarKey` (value: `action::Scrollbar`).
pub const SCROLLBAR_KEY: &str = "com.mitchellh.ghostty.didUpdateScrollbar.scrollbar";
/// Focus the search field
pub const GHOSTTY_SEARCH_FOCUS: Name = Name("com.mitchellh.ghostty.searchFocus");
// MARK: Ghostty.Notification
/// Used to pass a configuration along when creating a new tab/window/split
/// (value: `SurfaceConfiguration`).
pub const NEW_SURFACE_CONFIG_KEY: &str = "com.mitchellh.ghostty.newSurfaceConfig";
/// Posted when a new split is requested. The sending object will be the surface that had focus. The
/// userdata has one key "direction" with the direction to split to.
pub const GHOSTTY_NEW_SPLIT: Name = Name("com.mitchellh.ghostty.newSplit");
/// Close the calling surface.
pub const GHOSTTY_CLOSE_SURFACE: Name = Name("com.mitchellh.ghostty.closeSurface");
/// Focus previous/next split. Has a SplitFocusDirection in the userinfo.
pub const GHOSTTY_FOCUS_SPLIT: Name = Name("com.mitchellh.ghostty.focusSplit");
/// `SplitDirectionKey`.
pub const SPLIT_DIRECTION_KEY: &str = GHOSTTY_FOCUS_SPLIT.0;
/// Goto tab. Has tab index in the userinfo.
pub const GHOSTTY_GOTO_TAB: Name = Name("com.mitchellh.ghostty.gotoTab");
/// `GotoTabKey`.
pub const GOTO_TAB_KEY: &str = GHOSTTY_GOTO_TAB.0;
/// New tab. Has base surface config requested in userinfo.
pub const GHOSTTY_NEW_TAB: Name = Name("com.mitchellh.ghostty.newTab");
/// New window. Has base surface config requested in userinfo.
pub const GHOSTTY_NEW_WINDOW: Name = Name("com.mitchellh.ghostty.newWindow");
/// Present terminal. Bring the surface's window to focus without activating the app.
pub const GHOSTTY_PRESENT_TERMINAL: Name = Name("com.mitchellh.ghostty.presentTerminal");
/// Toggle fullscreen of current window
pub const GHOSTTY_TOGGLE_FULLSCREEN: Name = Name("com.mitchellh.ghostty.toggleFullscreen");
/// `FullscreenModeKey`.
pub const FULLSCREEN_MODE_KEY: &str = GHOSTTY_TOGGLE_FULLSCREEN.0;
/// Notification sent to toggle split maximize/unmaximize.
pub const DID_TOGGLE_SPLIT_ZOOM: Name = Name("com.mitchellh.ghostty.didToggleSplitZoom");
/// Notification
pub const DID_RECEIVE_INITIAL_WINDOW_FRAME: Name =
Name("com.mitchellh.ghostty.didReceiveInitialWindowFrame");
/// `FrameKey`.
pub const FRAME_KEY: &str = "com.mitchellh.ghostty.frame";
/// Notification to render the inspector for a surface
pub const INSPECTOR_NEEDS_DISPLAY: Name = Name("com.mitchellh.ghostty.inspectorNeedsDisplay");
/// Notification to show/hide the inspector
pub const DID_CONTROL_INSPECTOR: Name = Name("com.mitchellh.ghostty.didControlInspector");
pub const CONFIRM_CLIPBOARD: Name = Name("com.mitchellh.ghostty.confirmClipboard");
/// `ConfirmClipboardStrKey` (value: `String`).
pub const CONFIRM_CLIPBOARD_STR_KEY: &str = "com.mitchellh.ghostty.confirmClipboard.str";
/// `ConfirmClipboardStateKey` (value: the opaque request state pointer as `usize`).
pub const CONFIRM_CLIPBOARD_STATE_KEY: &str = "com.mitchellh.ghostty.confirmClipboard.state";
/// `ConfirmClipboardRequestKey` (value: `package::ClipboardRequest`).
pub const CONFIRM_CLIPBOARD_REQUEST_KEY: &str = "com.mitchellh.ghostty.confirmClipboard.request";
/// Notification sent to the active split view to resize the split.
pub const DID_RESIZE_SPLIT: Name = Name("com.mitchellh.ghostty.didResizeSplit");
/// `ResizeSplitDirectionKey`.
pub const RESIZE_SPLIT_DIRECTION_KEY: &str = "com.mitchellh.ghostty.didResizeSplit.direction";
/// `ResizeSplitAmountKey`.
pub const RESIZE_SPLIT_AMOUNT_KEY: &str = "com.mitchellh.ghostty.didResizeSplit.amount";
/// Notification sent to the split root to equalize split sizes
pub const DID_EQUALIZE_SPLITS: Name = Name("com.mitchellh.ghostty.didEqualizeSplits");
/// Notification that renderer health changed
pub const DID_UPDATE_RENDERER_HEALTH: Name = Name("com.mitchellh.ghostty.didUpdateRendererHealth");
/// Notifications related to key sequences
pub const DID_CONTINUE_KEY_SEQUENCE: Name = Name("com.mitchellh.ghostty.didContinueKeySequence");
pub const DID_END_KEY_SEQUENCE: Name = Name("com.mitchellh.ghostty.didEndKeySequence");
/// `KeySequenceKey`.
pub const KEY_SEQUENCE_KEY: &str = "com.mitchellh.ghostty.didContinueKeySequence.key";
/// Notifications related to key tables
pub const DID_CHANGE_KEY_TABLE: Name = Name("com.mitchellh.ghostty.didChangeKeyTable");
/// `KeyTableKey`.
pub const KEY_TABLE_KEY: &str = "com.mitchellh.ghostty.didChangeKeyTable.action";
/// `Notification.Name.terminalWindowBellDidChangeNotification` (declared in
/// `BaseTerminalController.swift`; MA3 posts it) and its `userInfo` key.
pub const TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION: Name =
Name("com.mitchellh.ghostty.terminalWindowBellDidChange");
pub const TERMINAL_WINDOW_HAS_BELL_KEY: &str =
"com.mitchellh.ghostty.terminalWindowBellDidChange.hasBell";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_match_swift() {
assert_eq!(
GHOSTTY_CONFIG_DID_CHANGE.raw_value(),
"com.mitchellh.ghostty.configDidChange"
);
assert_eq!(
GHOSTTY_CONFIG_CHANGE_KEY,
"com.mitchellh.ghostty.configDidChange"
);
assert_eq!(
READONLY_KEY,
"com.mitchellh.ghostty.didChangeReadonly.readonly"
);
assert_eq!(
KEY_SEQUENCE_KEY,
"com.mitchellh.ghostty.didContinueKeySequence.key"
);
}
}
@@ -0,0 +1,118 @@
//! `NSEvent+Extension.swift`: `NSEvent` -> Ghostty key event conversions.
#![allow(unsafe_code)]
use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType};
use super::{
capi::{ghostty_input_action_e, ghostty_input_key_s},
input::ghostty_mods,
};
/// Create a Ghostty key event for a given keyboard action.
///
/// This will not set the "text" or "composing" fields since these can't safely be set
/// with the information or lifetimes given.
///
/// The translationMods should be set to the modifiers used for actual character
/// translation if available.
#[must_use]
pub fn ghostty_key_event(
event: &NSEvent,
action: ghostty_input_action_e,
translation_mods: Option<NSEventModifierFlags>,
) -> ghostty_input_key_s {
let modifier_flags = event.modifierFlags();
let mut key_ev = ghostty_input_key_s {
action,
mods: 0,
consumed_mods: 0,
keycode: u32::from(event.keyCode()),
// We can't infer or set these safely from this method. Since text is
// a cString, we can't use self.characters because of garbage collection.
// We have to let the caller handle this.
text: std::ptr::null(),
unshifted_codepoint: 0,
composing: false,
};
// macOS provides no easy way to determine the consumed modifiers for
// producing text. We apply a simple heuristic here that has worked for years
// so far: control and command never contribute to the translation of text,
// assume everything else did.
key_ev.mods = ghostty_mods(modifier_flags.0 as u64);
let consumed = translation_mods.unwrap_or(modifier_flags)
& !(NSEventModifierFlags::Control | NSEventModifierFlags::Command);
key_ev.consumed_mods = ghostty_mods(consumed.0 as u64);
// Our unshifted codepoint is the codepoint with no modifiers. We
// ignore multi-codepoint values. We have to use `byApplyingModifiers`
// instead of `charactersIgnoringModifiers` because the latter changes
// behavior with ctrl pressed and we don't want any of that.
key_ev.unshifted_codepoint = 0;
let ty = event.r#type();
if ty == NSEventType::KeyDown || ty == NSEventType::KeyUp {
let chars = event.charactersByApplyingModifiers(NSEventModifierFlags::empty());
if let Some(c) = chars.and_then(|chars| chars.to_string().chars().next()) {
key_ev.unshifted_codepoint = c as u32;
}
}
key_ev
}
/// Returns the text to set for a key event for Ghostty.
///
/// This namely contains logic to avoid control characters, since we handle control character
/// mapping manually within Ghostty.
#[must_use]
pub fn ghostty_characters(event: &NSEvent) -> Option<String> {
// If we have no characters associated with this event we do nothing.
let characters = event.characters()?.to_string();
ghostty_characters_for(&characters, || {
event
.charactersByApplyingModifiers(event.modifierFlags() & !NSEventModifierFlags::Control)
.map(|s| s.to_string())
})
}
/// The pure part of [`ghostty_characters`]; `without_control` computes
/// `characters(byApplyingModifiers: modifierFlags.subtracting(.control))`.
fn ghostty_characters_for(
characters: &str,
without_control: impl FnOnce() -> Option<String>,
) -> Option<String> {
let mut it = characters.chars();
if let (Some(scalar), None) = (it.next(), it.next()) {
// If we have a single control character, then we return the characters
// without control pressed. We do this because we handle control character
// encoding directly within Ghostty's KeyEncoder.
if (scalar as u32) < 0x20 {
return without_control();
}
// If we have a single value in the PUA, then it's a function key and
// we don't want to send PUA ranges down to Ghostty.
if (0xF700..=0xF8FF).contains(&(scalar as u32)) {
return None;
}
}
Some(characters.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
// Not run on Linux (apple-only module); kept as documentation of intent.
#[test]
fn characters_rules() {
assert_eq!(ghostty_characters_for("a", || None).as_deref(), Some("a"));
assert_eq!(
ghostty_characters_for("\u{3}", || Some("c".into())).as_deref(),
Some("c")
);
assert_eq!(ghostty_characters_for("\u{F700}", || None), None);
assert_eq!(ghostty_characters_for("ab", || None).as_deref(), Some("ab"));
}
}
+392
View File
@@ -0,0 +1,392 @@
//! `GhosttyPackage.swift` (minus the notification names, which are in
//! [`super::notification`]) and `GhosttyPackageMeta.swift`.
#![allow(unsafe_code, clippy::wildcard_imports)]
#![cfg_attr(test, allow(clippy::assert_is_empty))]
use std::ffi::{CStr, c_int};
use super::capi::{
consts::*,
ghostty_clipboard_content_s,
ghostty_info,
ghostty_string_free,
ghostty_string_s,
};
// MARK: C Extensions
/// The user notification category identifier
pub const USER_NOTIFICATION_CATEGORY: &str = "com.mitchellh.ghostty.userNotification";
/// The user notification "Show" action
pub const USER_NOTIFICATION_ACTION_SHOW: &str = "com.mitchellh.ghostty.userNotification.Show";
// MARK: Build Info
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Info {
/// `ghostty_build_mode_e`
pub mode: c_int,
pub version: String,
}
/// `Ghostty.info`.
#[must_use]
pub fn info() -> Info {
let raw = ghostty_info();
let version = if raw.version.is_null() {
// NSString(bytes:...) of nothing falls back to "unknown".
"unknown".to_string()
} else {
// SAFETY: `version` points to `version_len` bytes that live for the process.
let bytes =
unsafe { std::slice::from_raw_parts(raw.version.cast::<u8>(), raw.version_len) };
std::str::from_utf8(bytes).map_or_else(|_| "unknown".to_string(), str::to_string)
};
Info {
mode: raw.build_mode,
version,
}
}
// MARK: General Helpers
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LaunchSource {
Cli,
App,
ZigRun,
}
impl LaunchSource {
/// `init?(rawValue:)`.
#[must_use]
pub fn from_raw_value(raw: &str) -> Option<Self> {
match raw {
"cli" => Some(Self::Cli),
"app" => Some(Self::App),
"zig_run" => Some(Self::ZigRun),
_ => None,
}
}
/// Pure form of [`launch_source`]: `env` is the value of
/// `GHOSTTY_MAC_LAUNCH_SOURCE`.
#[must_use]
pub fn from_env_value(env: Option<&str>) -> Self {
// We default to the CLI because the app bundle always sets the
// source. If its unset we assume we're in a CLI environment.
let Some(value) = env else {
return Self::Cli;
};
// If the env var is set but its unknown then we default back to the app.
Self::from_raw_value(value).unwrap_or(Self::App)
}
}
/// Returns the mechanism that launched the app. This is based on an env var so
/// its up to the env var being set in the correct circumstance.
#[must_use]
pub fn launch_source() -> LaunchSource {
LaunchSource::from_env_value(std::env::var("GHOSTTY_MAC_LAUNCH_SOURCE").ok().as_deref())
}
// MARK: Swift Types for C Types
/// A `ghostty_string_s` that is freed with `ghostty_string_free` when dropped.
pub struct AllocatedString {
c_string: ghostty_string_s,
}
impl AllocatedString {
#[must_use]
pub fn new(c: ghostty_string_s) -> Self {
Self { c_string: c }
}
/// The contents; empty if null or not valid UTF-8.
#[must_use]
pub fn string(&self) -> String {
if self.c_string.ptr.is_null() {
return String::new();
}
// SAFETY: `ptr`/`len` describe the allocation owned by this value.
let bytes = unsafe {
std::slice::from_raw_parts(self.c_string.ptr.cast::<u8>(), self.c_string.len)
};
String::from_utf8(bytes.to_vec()).unwrap_or_default()
}
}
impl Drop for AllocatedString {
fn drop(&mut self) {
ghostty_string_free(self.c_string);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SetFloatWindow {
On,
Off,
Toggle,
}
impl SetFloatWindow {
/// `from(_ c: ghostty_action_float_window_e)`.
#[must_use]
pub fn from_c(c: c_int) -> Option<Self> {
match c {
GHOSTTY_FLOAT_WINDOW_ON => Some(Self::On),
GHOSTTY_FLOAT_WINDOW_OFF => Some(Self::Off),
GHOSTTY_FLOAT_WINDOW_TOGGLE => Some(Self::Toggle),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SetSecureInput {
On,
Off,
Toggle,
}
impl SetSecureInput {
/// `from(_ c: ghostty_action_secure_input_e)`.
#[must_use]
pub fn from_c(c: c_int) -> Option<Self> {
match c {
GHOSTTY_SECURE_INPUT_ON => Some(Self::On),
GHOSTTY_SECURE_INPUT_OFF => Some(Self::Off),
GHOSTTY_SECURE_INPUT_TOGGLE => Some(Self::Toggle),
_ => None,
}
}
}
/// An enum that is used for the directions that a split focus event can change.
///
/// TODO(MA3): `toSplitTreeFocusDirection` (`SplitTree<ViewType>.FocusDirection`)
/// lives with the SplitTree port in `features::splits`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SplitFocusDirection {
Previous,
Next,
Up,
Down,
Left,
Right,
}
impl SplitFocusDirection {
/// Initialize from a Ghostty API enum (`ghostty_action_goto_split_e`).
#[must_use]
pub fn from_direction(direction: c_int) -> Option<Self> {
match direction {
GHOSTTY_GOTO_SPLIT_PREVIOUS => Some(Self::Previous),
GHOSTTY_GOTO_SPLIT_NEXT => Some(Self::Next),
GHOSTTY_GOTO_SPLIT_UP => Some(Self::Up),
GHOSTTY_GOTO_SPLIT_DOWN => Some(Self::Down),
GHOSTTY_GOTO_SPLIT_LEFT => Some(Self::Left),
GHOSTTY_GOTO_SPLIT_RIGHT => Some(Self::Right),
_ => None,
}
}
#[must_use]
pub const fn to_native(self) -> c_int {
match self {
Self::Previous => GHOSTTY_GOTO_SPLIT_PREVIOUS,
Self::Next => GHOSTTY_GOTO_SPLIT_NEXT,
Self::Up => GHOSTTY_GOTO_SPLIT_UP,
Self::Down => GHOSTTY_GOTO_SPLIT_DOWN,
Self::Left => GHOSTTY_GOTO_SPLIT_LEFT,
Self::Right => GHOSTTY_GOTO_SPLIT_RIGHT,
}
}
}
/// Enum used for resizing splits. This is the direction the split divider will move.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SplitResizeDirection {
Up,
Down,
Left,
Right,
}
impl SplitResizeDirection {
/// `from(direction: ghostty_action_resize_split_direction_e)`.
#[must_use]
pub fn from_direction(direction: c_int) -> Option<Self> {
match direction {
GHOSTTY_RESIZE_SPLIT_UP => Some(Self::Up),
GHOSTTY_RESIZE_SPLIT_DOWN => Some(Self::Down),
GHOSTTY_RESIZE_SPLIT_LEFT => Some(Self::Left),
GHOSTTY_RESIZE_SPLIT_RIGHT => Some(Self::Right),
_ => None,
}
}
#[must_use]
pub const fn to_native(self) -> c_int {
match self {
Self::Up => GHOSTTY_RESIZE_SPLIT_UP,
Self::Down => GHOSTTY_RESIZE_SPLIT_DOWN,
Self::Left => GHOSTTY_RESIZE_SPLIT_LEFT,
Self::Right => GHOSTTY_RESIZE_SPLIT_RIGHT,
}
}
}
/// Identity of an `NSPasteboard` (its address). Swift's
/// `osc_52_write(OSPasteboard?)` holds the pasteboard object; Ghostty only ever
/// uses the two process-lifetime pasteboards (`NSPasteboard.general` and
/// `NSPasteboard.ghosttySelection`), so the identity is enough here and the
/// AppKit side turns it back into the object. Not dereferenced in this module.
pub type PasteboardHandle = usize;
/// The type of a clipboard request
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipboardRequest {
/// A direct paste of clipboard contents
Paste,
/// An application is attempting to read from the clipboard using OSC 52
Osc52Read,
/// An application is attempting to write to the clipboard using OSC 52
Osc52Write(Option<PasteboardHandle>),
}
impl ClipboardRequest {
/// The text to show in the clipboard confirmation prompt for a given request type
#[must_use]
pub const fn text(&self) -> &'static str {
match self {
Self::Paste => {
"Pasting this text to the terminal may be dangerous as it looks like some commands may be executed."
}
Self::Osc52Read => {
"An application is attempting to read from the clipboard.\nThe current clipboard contents are shown below."
}
Self::Osc52Write(_) => {
"An application is attempting to write to the clipboard.\nThe content to write is shown below."
}
}
}
/// `from(request: ghostty_clipboard_request_e)`.
#[must_use]
pub fn from_request(request: c_int) -> Option<Self> {
match request {
GHOSTTY_CLIPBOARD_REQUEST_PASTE => Some(Self::Paste),
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ => Some(Self::Osc52Read),
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE => Some(Self::Osc52Write(None)),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClipboardContent {
pub mime: String,
pub data: String,
}
impl ClipboardContent {
/// `from(content: ghostty_clipboard_content_s)`.
///
/// # Safety
/// Non-null `mime` / `data` must be NUL terminated strings.
#[must_use]
pub unsafe fn from_c(content: &ghostty_clipboard_content_s) -> Option<Self> {
if content.mime.is_null() || content.data.is_null() {
return None;
}
// SAFETY: non-null and NUL terminated per the contract.
let (mime, data) = unsafe { (CStr::from_ptr(content.mime), CStr::from_ptr(content.data)) };
Some(Self {
mime: mime.to_string_lossy().into_owned(),
data: data.to_string_lossy().into_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launch_source_defaults() {
assert_eq!(LaunchSource::from_env_value(None), LaunchSource::Cli);
assert_eq!(LaunchSource::from_env_value(Some("app")), LaunchSource::App);
assert_eq!(
LaunchSource::from_env_value(Some("zig_run")),
LaunchSource::ZigRun
);
assert_eq!(LaunchSource::from_env_value(Some("???")), LaunchSource::App);
}
#[test]
fn split_directions_round_trip() {
for d in [
SplitFocusDirection::Previous,
SplitFocusDirection::Next,
SplitFocusDirection::Up,
SplitFocusDirection::Down,
SplitFocusDirection::Left,
SplitFocusDirection::Right,
] {
assert_eq!(SplitFocusDirection::from_direction(d.to_native()), Some(d));
}
assert_eq!(SplitFocusDirection::from_direction(99), None);
for d in [
SplitResizeDirection::Up,
SplitResizeDirection::Down,
SplitResizeDirection::Left,
SplitResizeDirection::Right,
] {
assert_eq!(SplitResizeDirection::from_direction(d.to_native()), Some(d));
}
}
#[test]
fn clipboard_request_from_c() {
assert_eq!(
ClipboardRequest::from_request(GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE),
Some(ClipboardRequest::Osc52Write(None))
);
assert_eq!(ClipboardRequest::from_request(42), None);
assert!(ClipboardRequest::Paste.text().contains("dangerous"));
}
#[test]
fn clipboard_content() {
let c = ghostty_clipboard_content_s {
mime: c"text/plain".as_ptr(),
data: c"hi".as_ptr(),
};
// SAFETY: static C strings.
let content = unsafe { ClipboardContent::from_c(&c) };
assert_eq!(
content,
Some(ClipboardContent {
mime: "text/plain".into(),
data: "hi".into()
})
);
let n = ghostty_clipboard_content_s {
mime: std::ptr::null(),
data: c"hi".as_ptr(),
};
// SAFETY: null is handled.
assert!(unsafe { ClipboardContent::from_c(&n) }.is_none());
}
#[test]
fn info_has_version() {
assert!(!info().version.is_empty());
}
}
+87
View File
@@ -0,0 +1,87 @@
//! `Ghostty.Shell` (`Ghostty.Shell.swift`).
/// Characters to escape in the shell.
const ESCAPE_CHARACTERS: &str = "\\ ()[]{}<>\"'`!#$&;|*?\t";
/// Escape shell-sensitive characters in a string by prefixing each with a
/// backslash. Suitable for inserting paths/URLs into a live terminal buffer.
#[must_use]
pub fn escape(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
if ESCAPE_CHARACTERS.contains(c) {
result.push('\\');
}
result.push(c);
}
result
}
/// `quoteUnsafe = /[^\w@%+=:,.\/-]/`: true if `c` needs quoting.
fn is_quote_unsafe(c: char) -> bool {
!(c.is_alphanumeric() || matches!(c, '_' | '@' | '%' | '+' | '=' | ':' | ',' | '.' | '/' | '-'))
}
/// Returns a shell-quoted version of the string, like Python's shlex.quote.
/// Suitable for building shell command lines that will be executed.
#[must_use]
pub fn quote(s: &str) -> String {
if !(s.is_empty() || s.chars().any(is_quote_unsafe)) {
return s.to_string();
}
format!("'{}'", s.replace('\'', "'\"'\"'"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_cases() {
let cases = [
("hello", "hello"),
("", ""),
("file name", "file\\ name"),
("a\\b", "a\\\\b"),
("(foo)", "\\(foo\\)"),
("[bar]", "\\[bar\\]"),
("{baz}", "\\{baz\\}"),
("<qux>", "\\<qux\\>"),
("say\"hi\"", "say\\\"hi\\\""),
("it's", "it\\'s"),
("`cmd`", "\\`cmd\\`"),
("wow!", "wow\\!"),
("#comment", "\\#comment"),
("$HOME", "\\$HOME"),
("a&b", "a\\&b"),
("a;b", "a\\;b"),
("a|b", "a\\|b"),
("*.txt", "\\*.txt"),
("file?.log", "file\\?.log"),
("col1\tcol2", "col1\\\tcol2"),
("$(echo 'hi')", "\\$\\(echo\\ \\'hi\\'\\)"),
("/tmp/my file (1).txt", "/tmp/my\\ file\\ \\(1\\).txt"),
];
for (input, expected) in cases {
assert_eq!(escape(input), expected, "input {input:?}");
}
}
#[test]
fn quote_cases() {
let cases = [
("", "''"),
("filename", "filename"),
("abcABC123@%_-+=:,./", "abcABC123@%_-+=:,./"),
("file name", "'file name'"),
("file$name", "'file$name'"),
("file!name", "'file!name'"),
("file\\name", "'file\\name'"),
("it's", "'it'\"'\"'s'"),
("file$'name'", "'file$'\"'\"'name'\"'\"''"),
];
for (input, expected) in cases {
assert_eq!(quote(input), expected, "input {input:?}");
}
}
}
+133
View File
@@ -0,0 +1,133 @@
//! `Ghostty.Surface` (`Ghostty.Surface.swift`): the data model wrapping a
//! `ghostty_surface_t`.
//!
//! Main-thread only (`@MainActor` in Swift). The typed `Input.KeyEvent` /
//! `Input.Mouse*Event` overloads of the Swift class belong to the
//! `Ghostty.Input` port (MA2); this wrapper exposes the C-level forms they
//! lower to.
#![allow(unsafe_code)]
use std::ffi::c_void;
use dispatch2::DispatchQueue;
use crate::ghostty::capi::{
ghostty_binding_flags_e,
ghostty_input_key_s,
ghostty_surface_binding_action,
ghostty_surface_free,
ghostty_surface_key,
ghostty_surface_key_is_binding,
ghostty_surface_mouse_button,
ghostty_surface_mouse_captured,
ghostty_surface_mouse_pos,
ghostty_surface_mouse_scroll,
ghostty_surface_t,
ghostty_surface_text,
};
/// Wraps a `ghostty_surface_t`; frees it on drop (on the main queue).
pub struct Surface {
surface: ghostty_surface_t,
}
impl Surface {
/// `init(cSurface:)`. Takes ownership of the surface.
#[must_use]
pub fn new(c_surface: ghostty_surface_t) -> Self {
Self { surface: c_surface }
}
/// `unsafeCValue`: the underlying C value. Unsafe because the value is
/// freed when this `Surface` is dropped.
#[must_use]
pub fn unsafe_c_value(&self) -> ghostty_surface_t {
self.surface
}
/// Send text to the terminal as if it was typed. This doesn't send the key
/// events so keyboard shortcuts and other encodings do not take effect.
pub fn send_text(&self, text: &str) {
if text.is_empty() {
return;
}
// SAFETY: valid surface; the pointer/length describe `text`.
unsafe { ghostty_surface_text(self.surface, text.as_ptr().cast(), text.len()) };
}
/// Send a key event to the terminal (`sendKeyEvent`, C form).
/// TODO(MA2): the typed `Input.KeyEvent` overload (`withCValue`).
pub fn send_key_event(&self, event: ghostty_input_key_s) -> bool {
// SAFETY: valid surface; `event.text` is valid for the call (caller).
unsafe { ghostty_surface_key(self.surface, event) }
}
/// Check if a key event matches a keybinding; returns the binding flags
/// (`Input.BindingFlags.rawValue`) if it does (`keyIsBinding`, C form).
/// TODO(MA2): the typed `Input.KeyEvent` overload.
#[must_use]
pub fn key_is_binding(&self, event: ghostty_input_key_s) -> Option<u32> {
let mut flags: ghostty_binding_flags_e = 0;
// SAFETY: valid surface and out pointer.
if unsafe { ghostty_surface_key_is_binding(self.surface, event, &raw mut flags) } {
Some(flags as u32)
} else {
None
}
}
/// Whether the terminal has captured mouse input (mouse reporting).
#[must_use]
pub fn mouse_captured(&self) -> bool {
// SAFETY: valid surface.
unsafe { ghostty_surface_mouse_captured(self.surface) }
}
/// `sendMouseButton` with the already lowered C values
/// (`event.action.cMouseState`, `event.button.cMouseButton`, `event.mods.cMods`).
/// TODO(MA2): typed `Input.MouseButtonEvent` overload.
pub fn send_mouse_button(&self, state: i32, button: i32, mods: i32) {
// SAFETY: valid surface.
unsafe { ghostty_surface_mouse_button(self.surface, state, button, mods) };
}
/// `sendMousePos` (`event.x`, `event.y`, `event.mods.cMods`).
/// TODO(MA2): typed `Input.MousePosEvent` overload.
pub fn send_mouse_pos(&self, x: f64, y: f64, mods: i32) {
// SAFETY: valid surface.
unsafe { ghostty_surface_mouse_pos(self.surface, x, y, mods) };
}
/// `sendMouseScroll` (`event.x`, `event.y`, `event.mods.cScrollMods`).
/// TODO(MA2): typed `Input.MouseScrollEvent` overload.
pub fn send_mouse_scroll(&self, x: f64, y: f64, scroll_mods: i32) {
// SAFETY: valid surface.
unsafe { ghostty_surface_mouse_scroll(self.surface, x, y, scroll_mods) };
}
/// Perform a keybinding action, e.g. `goto_tab:4`. Returns true if the
/// action was performed; invalid actions return false.
#[must_use]
pub fn perform(&self, action: &str) -> bool {
if action.is_empty() {
return false;
}
// SAFETY: valid surface; pointer/length describe `action`.
unsafe {
ghostty_surface_binding_action(self.surface, action.as_ptr().cast(), action.len())
}
}
}
impl Drop for Surface {
fn drop(&mut self) {
// deinit is not guaranteed to happen on the main thread and our API
// calls into libghostty must happen there, so the free is scheduled
// on the main queue (Swift: `Task.detached { @MainActor in ... }`).
let surface = self.surface as usize;
DispatchQueue::main().exec_async(move || {
// SAFETY: the surface was owned by this wrapper, which is gone.
unsafe { ghostty_surface_free(surface as *mut c_void) };
});
}
}
@@ -0,0 +1,219 @@
//! `Ghostty.SurfaceConfiguration` (`Surface View/SurfaceView.swift`): the
//! configuration for a surface. For any configuration not set, defaults are
//! chosen by libghostty, usually from the Ghostty configuration.
//!
//! Portable (no AppKit): the view pointer and the scale factor, which the
//! Swift version reads from `NSScreen.main`, are parameters of
//! [`SurfaceConfiguration::with_c_value`].
#![allow(unsafe_code)]
use std::{
collections::BTreeMap,
ffi::{CStr, CString, c_char, c_void},
};
use crate::ghostty::capi::{
consts::{GHOSTTY_PLATFORM_MACOS, GHOSTTY_SURFACE_CONTEXT_WINDOW},
ghostty_env_var_s,
ghostty_platform_macos_s,
ghostty_platform_u,
ghostty_surface_config_new,
ghostty_surface_config_s,
ghostty_surface_context_e,
};
/// `Ghostty.SurfaceConfiguration`.
#[derive(Clone, Debug, PartialEq)]
pub struct SurfaceConfiguration {
/// Explicit font size to use in points.
pub font_size: Option<f32>,
/// Explicit working directory to set.
pub working_directory: Option<String>,
/// Explicit command to set.
pub command: Option<String>,
/// Environment variables to set for the terminal.
pub environment_variables: BTreeMap<String, String>,
/// Extra input to send as stdin.
pub initial_input: Option<String>,
/// Wait after the command.
pub wait_after_command: bool,
/// Context for surface creation.
pub context: ghostty_surface_context_e,
}
impl Default for SurfaceConfiguration {
fn default() -> Self {
Self {
font_size: None,
working_directory: None,
command: None,
environment_variables: BTreeMap::new(),
initial_input: None,
wait_after_command: false,
context: GHOSTTY_SURFACE_CONTEXT_WINDOW,
}
}
}
/// Read an optional C string as UTF-8 (`String(cString:encoding: .utf8)`).
///
/// # Safety
/// `p` must be null or a valid NUL terminated string.
unsafe fn opt_string(p: *const c_char) -> Option<String> {
if p.is_null() {
return None;
}
// SAFETY: caller guarantees validity.
unsafe { CStr::from_ptr(p) }
.to_str()
.ok()
.map(str::to_owned)
}
/// `withCString` semantics: a Swift string with an embedded NUL is seen by C
/// up to the NUL.
fn to_cstring(s: &str) -> CString {
let end = s.find('\0').unwrap_or(s.len());
CString::new(&s.as_bytes()[..end]).unwrap_or_default()
}
impl SurfaceConfiguration {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// `init(from: ghostty_surface_config_s)`.
///
/// # Safety
/// The string and env var pointers of `config` must be valid (as
/// returned by `ghostty_surface_inherited_config`).
#[must_use]
pub unsafe fn from_c(config: &ghostty_surface_config_s) -> Self {
let mut this = Self {
font_size: Some(config.font_size),
..Self::default()
};
// SAFETY: per the function contract.
unsafe {
this.working_directory = opt_string(config.working_directory);
this.command = opt_string(config.command);
if config.env_var_count > 0 && !config.env_vars.is_null() {
for i in 0..config.env_var_count {
let env = *config.env_vars.add(i);
if let (Some(k), Some(v)) = (opt_string(env.key), opt_string(env.value)) {
this.environment_variables.insert(k, v);
}
}
}
}
this.context = config.context;
this
}
/// `withCValue(view:_:)`: a C configuration valid only within `body`.
/// `view` is the address of the surface view (also the userdata);
/// `scale_factor` is `NSScreen.main!.backingScaleFactor`.
pub fn with_c_value<T>(
&self,
view: *mut c_void,
scale_factor: f64,
body: impl FnOnce(&mut ghostty_surface_config_s) -> T,
) -> T {
let mut config = ghostty_surface_config_new();
config.userdata = view;
config.platform_tag = GHOSTTY_PLATFORM_MACOS;
config.platform = ghostty_platform_u {
macos: ghostty_platform_macos_s { nsview: view },
};
config.scale_factor = scale_factor;
// Zero is our default value that means to inherit the font size.
config.font_size = self.font_size.unwrap_or(0.0);
config.wait_after_command = self.wait_after_command;
config.context = self.context;
// The CStrings stay alive for the whole call, like `withCString`.
let working_dir = self.working_directory.as_deref().map(to_cstring);
let command = self.command.as_deref().map(to_cstring);
let input = self.initial_input.as_deref().map(to_cstring);
let ptr = |s: &Option<CString>| s.as_ref().map_or(std::ptr::null(), |s| s.as_ptr());
config.working_directory = ptr(&working_dir);
config.command = ptr(&command);
config.initial_input = ptr(&input);
let keys: Vec<CString> = self
.environment_variables
.keys()
.map(|k| to_cstring(k))
.collect();
let values: Vec<CString> = self
.environment_variables
.values()
.map(|v| to_cstring(v))
.collect();
let mut env_vars: Vec<ghostty_env_var_s> = keys
.iter()
.zip(&values)
.map(|(k, v)| ghostty_env_var_s {
key: k.as_ptr(),
value: v.as_ptr(),
})
.collect();
config.env_vars = env_vars.as_mut_ptr();
config.env_var_count = env_vars.len();
body(&mut config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults() {
let c = SurfaceConfiguration::new();
assert_eq!(c.font_size, None);
assert!(!c.wait_after_command);
assert_eq!(c.context, GHOSTTY_SURFACE_CONTEXT_WINDOW);
}
#[test]
fn c_value_round_trip() {
let mut c = SurfaceConfiguration::new();
c.font_size = Some(14.0);
c.working_directory = Some("/tmp".into());
c.command = Some("echo hi".into());
c.initial_input = Some("ls\n".into());
c.wait_after_command = true;
c.environment_variables.insert("A".into(), "1".into());
c.environment_variables.insert("B".into(), "2".into());
let mut fake = 0u8;
let view = std::ptr::addr_of_mut!(fake).cast::<c_void>();
let back = c.with_c_value(view, 2.0, |cfg| {
assert_eq!(cfg.userdata, view);
assert_eq!(cfg.platform_tag, GHOSTTY_PLATFORM_MACOS);
assert!((cfg.scale_factor - 2.0).abs() < f64::EPSILON);
assert!(cfg.wait_after_command);
// SAFETY: pointers valid within the closure.
let mut back = unsafe { SurfaceConfiguration::from_c(cfg) };
// initial_input / wait_after_command are not read back (as in Swift).
back.initial_input = c.initial_input.clone();
back.wait_after_command = true;
back
});
assert_eq!(back, c);
}
#[test]
fn unset_strings_are_null() {
let c = SurfaceConfiguration::new();
c.with_c_value(std::ptr::null_mut(), 1.0, |cfg| {
assert!(cfg.working_directory.is_null());
assert!(cfg.command.is_null());
assert!(cfg.initial_input.is_null());
assert_eq!(cfg.env_var_count, 0);
assert_eq!(cfg.font_size.to_bits(), 0);
});
}
}
@@ -0,0 +1,734 @@
//! `Ghostty.SurfaceView` (`Surface View/SurfaceView_AppKit.swift`,
//! `SurfaceView.swift`): the NSView of a terminal surface.
//!
//! TODO(MA2): this is the *model-only first cut* written by MA1 so that
//! `Ghostty.App`'s action glue compiles and behaves like the Swift app for
//! everything that only reads/writes the view's published state. It contains
//! the instance state those callbacks touch (title, pwd, cell size, hover URL,
//! progress report, search state, scrollbar, cursor style, notification ids,
//! ...) and the methods `Ghostty.App` calls. All NSView behaviour -- the
//! `init(_:baseConfig:uuid:)` that creates the `ghostty_surface_t`, layer/
//! Metal hosting, first responder & key/mouse/IME handling, tracking areas,
//! drag and drop, scrolling, the SwiftUI wrappers -- is MA2's. Every unported
//! piece is marked `TODO(MA2)` with its Swift origin.
#![allow(unsafe_code)]
// TODO(MA2) methods that do not use `self` yet.
#![allow(clippy::unused_self)]
use std::{
cell::{Cell, RefCell},
collections::HashSet,
ffi::c_int,
ptr::NonNull,
rc::Rc,
sync::Arc,
};
use block2::RcBlock;
use dispatch2::{DispatchQueue, MainThreadBound};
use log::error;
use objc2::{
DefinedClass,
MainThreadMarker,
MainThreadOnly,
Message,
define_class,
msg_send,
rc::{Retained, Weak},
runtime::AnyObject,
};
use objc2_app_kit::{
NSAlert,
NSAlertFirstButtonReturn,
NSAlertStyle,
NSCursor,
NSModalResponse,
NSResponder,
NSTextField,
NSView,
};
use objc2_foundation::{
NSArray,
NSDictionary,
NSError,
NSNumber,
NSObject,
NSObjectProtocol,
NSPoint,
NSRect,
NSSize,
NSString,
NSTimer,
NSUUID,
};
use objc2_user_notifications::{
UNMutableNotificationContent,
UNNotification,
UNNotificationRequest,
UNNotificationSound,
UNUserNotificationCenter,
};
use crate::{
ghostty::{
action::{ProgressReport, ProgressReportState, Scrollbar, StartSearch},
capi::{
consts::{
GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU,
GHOSTTY_MOUSE_SHAPE_CROSSHAIR,
GHOSTTY_MOUSE_SHAPE_DEFAULT,
GHOSTTY_MOUSE_SHAPE_E_RESIZE,
GHOSTTY_MOUSE_SHAPE_EW_RESIZE,
GHOSTTY_MOUSE_SHAPE_GRAB,
GHOSTTY_MOUSE_SHAPE_GRABBING,
GHOSTTY_MOUSE_SHAPE_N_RESIZE,
GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED,
GHOSTTY_MOUSE_SHAPE_NS_RESIZE,
GHOSTTY_MOUSE_SHAPE_POINTER,
GHOSTTY_MOUSE_SHAPE_S_RESIZE,
GHOSTTY_MOUSE_SHAPE_TEXT,
GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT,
GHOSTTY_MOUSE_SHAPE_W_RESIZE,
},
ghostty_surface_needs_confirm_quit,
ghostty_surface_process_exited,
ghostty_surface_t,
},
package::USER_NOTIFICATION_CATEGORY,
surface::Surface,
},
helpers::{cursor::CursorStyle, keyboard_shortcut::KeyboardShortcut, published::Published},
};
/// `Ghostty.SurfaceView.SearchState`.
pub struct SearchState {
pub needle: Published<String>,
pub selected: Published<Option<usize>>,
pub total: Published<Option<usize>>,
}
impl SearchState {
/// `init(from startSearch:)`.
#[must_use]
pub fn new(start_search: &StartSearch) -> Self {
Self {
needle: Published::new(start_search.needle.clone().unwrap_or_default()),
selected: Published::new(None),
total: Published::new(None),
}
}
}
/// The instance state of a [`SurfaceView`].
pub struct SurfaceViewIvars {
/// `id`: unique ID per surface.
pub id: Retained<NSUUID>,
/// `surfaceModel` (its C value is `surface`).
surface_model: RefCell<Option<Surface>>,
/// `title`: the title as defined by the pty (or set manually).
pub title: Published<String>,
/// `pwd`.
pub pwd: Published<Option<String>>,
/// `cellSize`.
pub cell_size: Published<NSSize>,
/// `healthy` (renderer health).
pub healthy: Published<bool>,
/// `hoverUrl`.
pub hover_url: Published<Option<String>>,
/// `progressReport`; setting it (re)starts the 15s expiry timer.
pub progress_report: Published<Option<ProgressReport>>,
/// `keySequence`.
pub key_sequence: Published<Vec<KeyboardShortcut>>,
/// `keyTables`.
pub key_tables: Published<Vec<String>>,
/// `searchState`.
pub search_state: Published<Option<Rc<SearchState>>>,
/// `pointerStyle`.
pub pointer_style: Published<CursorStyle>,
/// `cursorVisible`.
pub cursor_visible: Published<bool>,
/// `bell`.
pub bell: Published<bool>,
/// `readonly`.
pub readonly: Published<bool>,
/// `initialSize`.
pub initial_size: Cell<Option<NSSize>>,
/// `passwordInput`.
password_input: Cell<bool>,
/// `scrollbar`.
pub scrollbar: RefCell<Option<Scrollbar>>,
/// `notificationIdentifiers`.
pub notification_identifiers: RefCell<HashSet<String>>,
/// `focused` (default true, like Swift).
focused: Cell<bool>,
title_change_timer: RefCell<Option<Retained<NSTimer>>>,
title_fallback_timer: RefCell<Option<Retained<NSTimer>>>,
progress_report_timer: RefCell<Option<Retained<NSTimer>>>,
/// `titleFromTerminal`.
title_from_terminal: RefCell<Option<String>>,
// Keeps the `title` observer (which cancels the fallback timer) alive.
_title_sub: RefCell<Option<crate::helpers::published::Subscription>>,
}
impl SurfaceViewIvars {
fn new(id: Retained<NSUUID>) -> Self {
Self {
id,
surface_model: RefCell::new(None),
title: Published::new(String::new()),
pwd: Published::new(None),
cell_size: Published::new(NSSize::new(0.0, 0.0)),
healthy: Published::new(true),
hover_url: Published::new(None),
progress_report: Published::new(None),
key_sequence: Published::new(Vec::new()),
key_tables: Published::new(Vec::new()),
search_state: Published::new(None),
pointer_style: Published::new(CursorStyle::HorizontalText),
cursor_visible: Published::new(true),
bell: Published::new(false),
readonly: Published::new(false),
initial_size: Cell::new(None),
password_input: Cell::new(false),
scrollbar: RefCell::new(None),
notification_identifiers: RefCell::new(HashSet::new()),
focused: Cell::new(true),
title_change_timer: RefCell::new(None),
title_fallback_timer: RefCell::new(None),
progress_report_timer: RefCell::new(None),
title_from_terminal: RefCell::new(None),
_title_sub: RefCell::new(None),
}
}
}
define_class!(
/// The NSView implementation for a terminal surface.
// SAFETY: NSView has no subclassing requirements we violate; no Drop impl.
#[unsafe(super(NSView, NSResponder, NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "GhosttySurfaceView"]
#[ivars = SurfaceViewIvars]
pub struct SurfaceView;
unsafe impl NSObjectProtocol for SurfaceView {}
impl SurfaceView {
/// We need to support being a first responder so that we can get input events.
#[unsafe(method(acceptsFirstResponder))]
fn accepts_first_responder(&self) -> bool {
true
}
}
// TODO(MA2): everything else of SurfaceView_AppKit.swift: input events,
// NSTextInputClient, tracking areas, layers, drag & drop, services.
);
impl SurfaceView {
/// Create a view without a `ghostty_surface_t` yet.
/// TODO(MA2): Swift `init(_ app:baseConfig:uuid:)` (SurfaceView_AppKit.swift:256)
/// creates the surface with `ghostty_surface_new` and sets up the layer,
/// tracking areas, the app-wide config and the title fallback.
#[must_use]
pub fn new_model(mtm: MainThreadMarker, uuid: Option<Retained<NSUUID>>) -> Retained<Self> {
let id = uuid.unwrap_or_default();
let this = Self::alloc(mtm).set_ivars(SurfaceViewIvars::new(id));
// SAFETY: `initWithFrame:` is the designated initializer of NSView.
let this: Retained<Self> = unsafe {
msg_send![super(this), initWithFrame: NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(800.0, 600.0))]
};
// `title` didSet: a non-empty title cancels the fallback timer.
let weak = Weak::from_retained(&this);
let bound = MainThreadBound::new(weak, mtm);
let sub = this.ivars().title.sink(move |title| {
if title.is_empty() {
return;
}
// Always on the main thread.
let mtm = MainThreadMarker::new().expect("main thread");
if let Some(view) = bound.get(mtm).load()
&& let Some(t) = view.ivars().title_fallback_timer.borrow_mut().take()
{
t.invalidate();
}
});
*this.ivars()._title_sub.borrow_mut() = Some(sub);
this
}
// MARK: Identity and surface
/// `id` (the UUID).
#[must_use]
pub fn id(&self) -> &NSUUID {
&self.ivars().id
}
/// `id.uuidString`.
#[must_use]
pub fn id_string(&self) -> String {
self.ivars().id.UUIDString().to_string()
}
/// `surfaceModel = ...` (set once the C surface exists).
pub fn set_surface_model(&self, surface: Option<Surface>) {
*self.ivars().surface_model.borrow_mut() = surface;
}
/// `surface`: the underlying C value.
#[must_use]
pub fn surface(&self) -> Option<ghostty_surface_t> {
self.ivars()
.surface_model
.borrow()
.as_ref()
.map(Surface::unsafe_c_value)
}
/// `needsConfirmQuit`.
#[must_use]
pub fn needs_confirm_quit(&self) -> bool {
match self.surface() {
// SAFETY: valid surface owned by this view.
Some(s) => unsafe { ghostty_surface_needs_confirm_quit(s) },
None => false,
}
}
/// `processExited`.
#[must_use]
pub fn process_exited(&self) -> bool {
match self.surface() {
// SAFETY: valid surface owned by this view.
Some(s) => unsafe { ghostty_surface_process_exited(s) },
None => true,
}
}
// MARK: Published state
/// `title`.
#[must_use]
pub fn title(&self) -> String {
self.ivars().title.get()
}
/// `focused`.
#[must_use]
pub fn focused(&self) -> bool {
self.ivars().focused.get()
}
/// TODO(MA2): `focusDidChange(_:)` (SurfaceView_AppKit.swift:~447) is the
/// real setter (notifies libghostty, secure input, notification cleanup).
pub fn set_focused_model(&self, focused: bool) {
self.ivars().focused.set(focused);
}
/// `passwordInput`.
#[must_use]
pub fn password_input(&self) -> bool {
self.ivars().password_input.get()
}
/// `passwordInput = ...`.
/// TODO(integration): the Swift `didSet` updates `SecureInput.shared`
/// (`setScoped`/`removeScoped`) -- wire once features/secure_input lands (MA4).
pub fn set_password_input(&self, value: bool) {
self.ivars().password_input.set(value);
}
/// `pwd = ...`.
pub fn set_pwd(&self, pwd: Option<String>) {
self.ivars().pwd.set(pwd);
}
/// `cellSize = ...`.
pub fn set_cell_size(&self, size: NSSize) {
self.ivars().cell_size.set(size);
}
/// `initialSize = ...`.
pub fn set_initial_size(&self, size: Option<NSSize>) {
self.ivars().initial_size.set(size);
}
/// `hoverUrl = ...`.
pub fn set_hover_url(&self, url: Option<String>) {
self.ivars().hover_url.set(url);
}
/// `progressReport = ...`: also restarts the 15 second expiry timer.
pub fn set_progress_report(&self, report: Option<ProgressReport>) {
// Cancel any existing timer.
if let Some(t) = self.ivars().progress_report_timer.borrow_mut().take() {
t.invalidate();
}
let has_report = report.is_some();
self.ivars().progress_report.set(report);
// If we have a new progress report, start a timer to remove it after 15 seconds.
if has_report {
let weak = Weak::from_retained(&self.retain());
let bound = MainThreadBound::new(weak, self.mtm());
let block = RcBlock::new(move |_timer: NonNull<NSTimer>| {
let mtm = MainThreadMarker::new().expect("main thread");
if let Some(view) = bound.get(mtm).load() {
// Like Swift's `self?.progressReport = nil` (which re-runs didSet).
view.set_progress_report(None);
*view.ivars().progress_report_timer.borrow_mut() = None;
}
});
// SAFETY: the block only runs on the main run loop.
let timer = unsafe {
NSTimer::scheduledTimerWithTimeInterval_repeats_block(15.0, false, &block)
};
*self.ivars().progress_report_timer.borrow_mut() = Some(timer);
}
}
/// `searchState = ...`.
/// TODO(MA2): SurfaceView_AppKit.swift:72 `didSet` -- the Combine pipeline
/// that sends `search:<needle>` (debounced 300ms when 1-2 chars) and the
/// `end_search` binding action when cleared.
pub fn set_search_state(&self, state: Option<Rc<SearchState>>) {
let had = self.ivars().search_state.get().is_some();
let clearing = state.is_none() && had;
self.ivars().search_state.set(state);
if clearing && let Some(surface) = self.ivars().surface_model.borrow().as_ref() {
let _ = surface.perform("end_search");
}
}
/// `searchState`.
#[must_use]
pub fn search_state(&self) -> Option<Rc<SearchState>> {
self.ivars().search_state.get()
}
// MARK: Cursor
/// `setCursorShape(_:)`.
pub fn set_cursor_shape(&self, shape: c_int) {
let style = match shape {
GHOSTTY_MOUSE_SHAPE_DEFAULT => CursorStyle::Default,
GHOSTTY_MOUSE_SHAPE_TEXT => CursorStyle::HorizontalText,
GHOSTTY_MOUSE_SHAPE_GRAB => CursorStyle::GrabIdle,
GHOSTTY_MOUSE_SHAPE_GRABBING => CursorStyle::GrabActive,
GHOSTTY_MOUSE_SHAPE_POINTER => CursorStyle::Link,
GHOSTTY_MOUSE_SHAPE_W_RESIZE => CursorStyle::ResizeLeft,
GHOSTTY_MOUSE_SHAPE_E_RESIZE => CursorStyle::ResizeRight,
GHOSTTY_MOUSE_SHAPE_N_RESIZE => CursorStyle::ResizeUp,
GHOSTTY_MOUSE_SHAPE_S_RESIZE => CursorStyle::ResizeDown,
GHOSTTY_MOUSE_SHAPE_NS_RESIZE => CursorStyle::ResizeUpDown,
GHOSTTY_MOUSE_SHAPE_EW_RESIZE => CursorStyle::ResizeLeftRight,
GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT => CursorStyle::VerticalText,
GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU => CursorStyle::ContextMenu,
GHOSTTY_MOUSE_SHAPE_CROSSHAIR => CursorStyle::Crosshair,
GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED => CursorStyle::OperationNotAllowed,
// We ignore unknown shapes.
_ => return,
};
// TODO(MA2): the Swift view applies `pointerStyle` through SwiftUI's
// `.backport.pointerStyle` / cursor rects.
self.ivars().pointer_style.set(style);
}
/// `setCursorVisibility(_:)`.
pub fn set_cursor_visibility(&self, visible: bool) {
self.ivars().cursor_visible.set(visible);
// Technically this action could be called anytime we want to change the
// mouse visibility but at the time of writing this mouse-hide-while-typing
// is the only use case so this is the preferred method.
NSCursor::setHiddenUntilMouseMoves(!visible);
}
// MARK: Title
/// `setTitle(_:)`: coalesces rapid changes with a 75 ms timer.
pub fn set_title(&self, title: &str) {
if let Some(t) = self.ivars().title_change_timer.borrow_mut().take() {
t.invalidate();
}
let weak = Weak::from_retained(&self.retain());
let bound = MainThreadBound::new(weak, self.mtm());
let title = title.to_owned();
let block = RcBlock::new(move |_timer: NonNull<NSTimer>| {
let mtm = MainThreadMarker::new().expect("main thread");
let Some(view) = bound.get(mtm).load() else {
return;
};
// Set the title if it wasn't manually set.
if view.ivars().title_from_terminal.borrow().is_some() {
*view.ivars().title_from_terminal.borrow_mut() = Some(title.clone());
return;
}
view.ivars().title.set(title.clone());
});
// SAFETY: the block only runs on the main run loop.
let timer =
unsafe { NSTimer::scheduledTimerWithTimeInterval_repeats_block(0.075, false, &block) };
*self.ivars().title_change_timer.borrow_mut() = Some(timer);
}
/// `promptTitle()`: set the title by prompting the user.
pub fn prompt_title(&self) {
let mtm = self.mtm();
let alert = NSAlert::new(mtm);
alert.setMessageText(&NSString::from_str("Change Terminal Title"));
alert.setInformativeText(&NSString::from_str("Leave blank to restore the default."));
alert.setAlertStyle(NSAlertStyle::Informational);
// Add a text field to the alert.
let text_field = NSTextField::initWithFrame(
NSTextField::alloc(mtm),
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(250.0, 24.0)),
);
text_field.setStringValue(&NSString::from_str(&self.title()));
alert.setAccessoryView(Some(&text_field));
alert.addButtonWithTitle(&NSString::from_str("OK"));
alert.addButtonWithTitle(&NSString::from_str("Cancel"));
// Make the text field the first responder so it gets focus.
alert.window().setInitialFirstResponder(Some(&text_field));
let weak = Weak::from_retained(&self.retain());
let field = text_field.clone();
let completion = move |response: NSModalResponse| {
let Some(this) = weak.load() else { return };
// Check if the user clicked "OK".
if response != NSAlertFirstButtonReturn {
return;
}
// Get the input text.
let new_title = field.stringValue().to_string();
if new_title.is_empty() {
// Empty means that user wants the title to be set automatically.
// We also need to reload the config for the "title" property to be
// used again by this tab.
let prev_title = this
.ivars()
.title_from_terminal
.borrow_mut()
.take()
.unwrap_or_else(|| "\u{1F47B}".to_owned());
this.set_title(&prev_title);
} else {
// Set the title and prevent it from being changed automatically.
*this.ivars().title_from_terminal.borrow_mut() = Some(this.title());
this.ivars().title.set(new_title);
}
};
// We prefer to run our alert in a sheet modal if we have a window.
if let Some(window) = self.window() {
let block = RcBlock::new(completion);
alert.beginSheetModalForWindow_completionHandler(&window, Some(&block));
} else {
// On macOS 26 RC, this codepath results in the "OK" button not being
// visible (upstream note); the sheet path is taken most times.
completion(alert.runModal());
}
}
// MARK: User notifications
/// `showUserNotification(title:body:requireFocus:)`: show a user
/// notification and associate it with this surface.
pub fn show_user_notification(&self, title: &str, body: &str, require_focus: bool) {
let content = UNMutableNotificationContent::new();
content.setTitle(&NSString::from_str(title));
content.setSubtitle(&NSString::from_str(&self.title()));
content.setBody(&NSString::from_str(body));
content.setSound(Some(&UNNotificationSound::defaultSound()));
content.setCategoryIdentifier(&NSString::from_str(USER_NOTIFICATION_CATEGORY));
let surface_key = NSString::from_str("surface");
let focus_key = NSString::from_str("requireFocus");
let surface_val: Retained<AnyObject> =
Retained::into_super(Retained::into_super(NSString::from_str(&self.id_string())));
let focus_val: Retained<AnyObject> = Retained::into_super(Retained::into_super(
Retained::into_super(NSNumber::new_bool(require_focus)),
));
let user_info: Retained<NSDictionary<NSString, AnyObject>> =
NSDictionary::from_retained_objects(
&[&*surface_key, &*focus_key],
&[surface_val, focus_val],
);
// SAFETY: the dictionary holds property list objects.
unsafe { content.setUserInfo(&Retained::cast_unchecked::<NSDictionary>(user_info)) };
let uuid = NSUUID::new().UUIDString().to_string();
let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
&NSString::from_str(&uuid),
&content,
None,
);
// The completion handler may be executed on a background thread so we
// hop to the main queue (Swift: `@MainActor error in`) before touching
// view state. `MainThreadBound` is `Send + Sync` and only yields its
// value on the main thread.
let mtm = self.mtm();
let bound = Arc::new(MainThreadBound::new(
(Weak::from_retained(&self.retain()), uuid),
mtm,
));
let handler = RcBlock::new(move |err: *mut NSError| {
// SAFETY: the error pointer is null or a valid NSError for the call.
if let Some(err) = unsafe { err.as_ref() } {
error!(
"Error scheduling user notification: {}",
err.localizedDescription()
);
return;
}
let bound = bound.clone();
DispatchQueue::main().exec_async(move || {
let mtm = MainThreadMarker::new().expect("main thread");
let (weak, uuid) = bound.get(mtm);
let Some(this) = weak.load() else { return };
// We need to keep track of this notification so we can remove it
// under certain circumstances.
this.ivars()
.notification_identifiers
.borrow_mut()
.insert(uuid.clone());
// If we're focused then we schedule to remove the notification
// after a few seconds. If we gain focus we automatically remove it
// in focusDidChange.
if this.focused() {
let inner =
MainThreadBound::new((Weak::from_retained(&this), uuid.clone()), mtm);
let block = RcBlock::new(move |_t: NonNull<NSTimer>| {
let mtm = MainThreadMarker::new().expect("main thread");
let (weak, uuid) = inner.get(mtm);
if let Some(this) = weak.load() {
this.ivars()
.notification_identifiers
.borrow_mut()
.remove(uuid);
}
let ids = NSArray::from_retained_slice(&[NSString::from_str(uuid)]);
UNUserNotificationCenter::currentNotificationCenter()
.removeDeliveredNotificationsWithIdentifiers(&ids);
});
// SAFETY: the block only runs on the main run loop.
let _ = unsafe {
NSTimer::scheduledTimerWithTimeInterval_repeats_block(3.0, false, &block)
};
}
});
});
UNUserNotificationCenter::currentNotificationCenter()
.addNotificationRequest_withCompletionHandler(&request, Some(&handler));
}
/// `handleUserNotification(notification:focus:)`: handle a user
/// notification click.
pub fn handle_user_notification(&self, notification: &UNNotification, focus: bool) {
// SAFETY: plain property getters.
let id = notification.request().identifier().to_string();
if !self
.ivars()
.notification_identifiers
.borrow_mut()
.remove(&id)
{
return;
}
if focus {
if let Some(window) = self.window() {
window.makeKeyAndOrderFront(Some(&self.retain()));
}
move_focus(self, None, None);
}
}
/// `cellSize = convertFromBacking(size)` helper used by `Ghostty.App.setCellSize`.
#[must_use]
pub fn convert_size_from_backing(&self, size: NSSize) -> NSSize {
self.convertSizeFromBacking(size)
}
/// `ProgressReport` removal check used by `Ghostty.App.progressReport`.
#[must_use]
pub fn is_progress_remove(report: &ProgressReport) -> bool {
report.state == ProgressReportState::Remove
}
}
/// `Ghostty.moveFocus(to:from:delay:)` (`Surface View/SurfaceView.swift:1150`).
///
/// Moves first-responder focus to `to`, retrying with a doubling back-off if
/// the view is not in a window yet.
pub fn move_focus(to: &SurfaceView, from: Option<&SurfaceView>, delay: Option<f64>) {
// The whole delay machinery is a bit of a hack to work around a situation
// where the window is destroyed and the surface view will never be attached
// to a window. Realistically, we should handle this upstream but we also
// don't want this function to be a source of infinite loops.
let max_delay = 0.5;
if delay.unwrap_or(0.0) >= max_delay {
return;
}
// We start at a 50 millisecond delay and do a doubling backoff.
let next_delay = delay.map_or(0.05, |d| d * 2.0);
let mtm = to.mtm();
let to_weak = Weak::from_retained(&to.retain());
let from_weak = from.map(|f| Weak::from_retained(&f.retain()));
let bound = MainThreadBound::new((to_weak, from_weak), mtm);
let work = move || {
let mtm = MainThreadMarker::new().expect("main thread");
let (to_weak, from_weak) = bound.get(mtm);
let Some(to) = to_weak.load() else { return };
let from = from_weak.as_ref().and_then(Weak::load);
// If the callback runs before the surface is attached to a view then the
// window will be nil. We just reschedule in that case.
let Some(window) = to.window() else {
move_focus(&to, from.as_deref(), Some(next_delay));
return;
};
// If we had a previously focused node and it's not where we're sending
// focus, make sure that we explicitly tell it to lose focus. In theory we
// should NOT have to do this but the focus callback isn't getting called
// for some reason.
if let Some(from) = from {
let _ = from.resignFirstResponder();
}
let _ = window.makeFirstResponder(Some(&to));
};
match delay {
// The Swift version dispatches async on the main queue (no delay) when
// none was given, otherwise asyncAfter.
None => DispatchQueue::main().exec_async(work),
Some(d) => {
let work = std::cell::RefCell::new(Some(work));
let block = RcBlock::new(move |_t: NonNull<NSTimer>| {
if let Some(w) = work.borrow_mut().take() {
w();
}
});
// SAFETY: the block only runs on the main run loop.
let _ =
unsafe { NSTimer::scheduledTimerWithTimeInterval_repeats_block(d, false, &block) };
}
}
}
@@ -0,0 +1,77 @@
//! `Helpers/AnySortKey.swift`.
use std::{any::Any, cmp::Ordering, rc::Rc};
/// Type-erased wrapper for any Comparable type to use as a sort key.
///
/// Like the Swift version, keys of different underlying types compare as
/// equal (`guard let l = lhs as? T, let r = rhs as? T else { return .orderedSame }`).
type Comparator = Rc<dyn Fn(&dyn Any, &dyn Any) -> Ordering>;
#[derive(Clone)]
pub struct AnySortKey {
value: Rc<dyn Any>,
comparator: Comparator,
}
impl AnySortKey {
pub fn new<T: Ord + 'static>(value: T) -> Self {
Self {
value: Rc::new(value),
comparator: Rc::new(|lhs, rhs| {
let (Some(l), Some(r)) = (lhs.downcast_ref::<T>(), rhs.downcast_ref::<T>()) else {
return Ordering::Equal;
};
l.cmp(r)
}),
}
}
fn compare(&self, other: &Self) -> Ordering {
(self.comparator)(self.value.as_ref(), other.value.as_ref())
}
}
impl PartialEq for AnySortKey {
fn eq(&self, other: &Self) -> bool {
self.compare(other) == Ordering::Equal
}
}
impl Eq for AnySortKey {}
impl PartialOrd for AnySortKey {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for AnySortKey {
fn cmp(&self, other: &Self) -> Ordering {
self.compare(other)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn orders_same_type() {
assert!(AnySortKey::new(1) < AnySortKey::new(2));
assert!(AnySortKey::new("b".to_string()) > AnySortKey::new("a".to_string()));
assert!(AnySortKey::new(3) == AnySortKey::new(3));
}
#[test]
fn mixed_types_compare_equal() {
assert!(AnySortKey::new(1) == AnySortKey::new("x".to_string()));
}
#[test]
fn sorts_a_heterogeneous_vec_stably() {
let mut v = [AnySortKey::new(3), AnySortKey::new(1), AnySortKey::new(2)];
v.sort();
assert!(v[0] == AnySortKey::new(1) && v[2] == AnySortKey::new(3));
}
}
@@ -0,0 +1,34 @@
//! `Helpers/AppInfo.swift`.
/// True if we appear to be running in Xcode.
#[must_use]
pub fn is_running_in_xcode() -> bool {
is_running_in_xcode_with(|k| std::env::var_os(k).is_some())
}
fn is_running_in_xcode_with(has_var: impl Fn(&str) -> bool) -> bool {
has_var("__XCODE_BUILT_PRODUCTS_DIR_PATHS")
}
/// `Bundle.main.infoDictionary?[key] as? Bool`.
#[cfg(target_vendor = "apple")]
#[must_use]
pub fn bundle_info_bool(key: &str) -> Option<bool> {
use objc2_foundation::{NSBundle, NSNumber, NSString};
let info = NSBundle::mainBundle().infoDictionary()?;
let value = info.objectForKey(&NSString::from_str(key))?;
value.downcast_ref::<NSNumber>().map(NSNumber::boolValue)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_xcode_env() {
assert!(is_running_in_xcode_with(
|k| k == "__XCODE_BUILT_PRODUCTS_DIR_PATHS"
));
assert!(!is_running_in_xcode_with(|_| false));
}
}
@@ -0,0 +1,47 @@
//! `Helpers/CodableBridge.swift`: a wrapper that allows a Swift `Codable` to
//! implement `NSSecureCoding`.
//!
//! Swift archives the value with `NSKeyedArchiver.encodeEncodable` into a
//! nested archive stored as `NSData` under the key `"data"` (inner key
//! `"value"`). Rust has no `Codable`; the equivalent is the [`Codable`] trait
//! (a byte encoding the type chooses, e.g. a hand-written plist/JSON), stored
//! under the same keys in the outer coder. Archives are therefore not
//! byte-compatible with the Swift app's (documented deviation); the bridge is
//! used for window-restoration state only.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use objc2::{ClassType, rc::Retained};
use objc2_foundation::{NSCoder, NSData, NSString};
/// The Rust equivalent of Swift's `Codable`.
pub trait Codable: Sized {
fn encode(&self) -> Vec<u8>;
fn decode(bytes: &[u8]) -> Option<Self>;
}
/// The key under which the encoded bytes are stored.
pub const DATA_KEY: &str = "data";
/// `CodableBridge.supportsSecureCoding`.
pub const SUPPORTS_SECURE_CODING: bool = true;
/// `CodableBridge(coder:)` failable decode.
pub fn decode_from_coder<T: Codable>(coder: &NSCoder) -> Option<T> {
// SAFETY: the class passed matches the expected NSData.
let data: Retained<NSData> =
unsafe { coder.decodeObjectOfClass_forKey(NSData::class(), &NSString::from_str(DATA_KEY)) }
.and_then(|o| o.downcast::<NSData>().ok())?;
T::decode(&data.to_vec())
}
/// `CodableBridge.encode(with:)`.
pub fn encode_to_coder<T: Codable>(value: &T, coder: &NSCoder) {
let data = NSData::with_bytes(&value.encode());
// SAFETY: NSData is a valid NSCoding object for the key.
#[allow(unsafe_code)]
unsafe {
coder.encodeObject_forKey(Some(&data), &NSString::from_str(DATA_KEY));
}
}
+31
View File
@@ -0,0 +1,31 @@
//! Stand-in for the `SwiftUI.Color` values the Swift `Ghostty.Config` returns
//! (`backgroundColor`, `unfocusedSplitFill`, `splitDividerColor`): an sRGB
//! color with `Double` components. AppKit conversions live in
//! `helpers::extensions::ns_color`.
/// An sRGB color, components in `0.0..=1.0`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Color {
pub red: f64,
pub green: f64,
pub blue: f64,
}
impl Color {
pub const WHITE: Self = Self::new(1.0, 1.0, 1.0);
#[must_use]
pub const fn new(red: f64, green: f64, blue: f64) -> Self {
Self { red, green, blue }
}
/// `Color(red: Double(r) / 255, ...)` for 8-bit components.
#[must_use]
pub fn from_rgb8(r: u8, g: u8, b: u8) -> Self {
Self::new(
f64::from(r) / 255.0,
f64::from(g) / 255.0,
f64::from(b) / 255.0,
)
}
}
@@ -0,0 +1,8 @@
//! `Helpers/CrossKit.swift`: types that are effectively identical between
//! AppKit and UIKit. Only the AppKit side exists in this port; the
//! `OSViewRepresentable` protocol is SwiftUI-only (unportable).
pub type OSView = objc2_app_kit::NSView;
pub type OSColor = objc2_app_kit::NSColor;
pub type OSSize = objc2_foundation::NSSize;
pub type OSPasteboard = objc2_app_kit::NSPasteboard;
+106
View File
@@ -0,0 +1,106 @@
//! `Helpers/Cursor.swift`.
// The pre-macOS-15 fallbacks are deprecated but used on older systems, like the Swift.
#![allow(deprecated)]
use std::cell::RefCell;
use objc2::rc::Retained;
use objc2_app_kit::{NSCursor, NSHorizontalDirections, NSVerticalDirections};
use super::{cursor_counter::CursorCounter, extensions::macos_at_least};
thread_local! {
static COUNTER: RefCell<CursorCounter> = const { RefCell::new(CursorCounter::new()) };
}
/// This helps manage the stateful nature of NSCursor hiding and unhiding.
pub struct Cursor;
impl Cursor {
#[must_use]
pub fn is_visible() -> bool {
COUNTER.with(|c| c.borrow().is_visible())
}
pub fn hide() {
COUNTER.with(|c| c.borrow_mut().hide());
NSCursor::hide();
}
/// Unhide the cursor. Returns true if the cursor was previously hidden.
pub fn unhide() -> bool {
// Its always safe to call unhide when the counter is zero because it
// won't go negative.
NSCursor::unhide();
COUNTER.with(|c| c.borrow_mut().unhide())
}
pub fn unhide_completely() -> u64 {
let counter = COUNTER.with(|c| c.borrow().count());
for _ in 0..counter {
let was_hidden = Self::unhide();
debug_assert!(was_hidden);
}
debug_assert_eq!(COUNTER.with(|c| c.borrow().count()), 0);
counter
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CursorStyle {
Default,
GrabIdle,
GrabActive,
HorizontalText,
VerticalText,
Link,
ResizeLeft,
ResizeRight,
ResizeUp,
ResizeDown,
ResizeUpDown,
ResizeLeftRight,
ContextMenu,
Crosshair,
OperationNotAllowed,
}
impl CursorStyle {
#[must_use]
pub fn cursor(self) -> Retained<NSCursor> {
// macOS 15 introduced the directional resize cursors.
let new_resize = macos_at_least(15);
match self {
Self::Default => NSCursor::arrowCursor(),
Self::GrabIdle => NSCursor::openHandCursor(),
Self::GrabActive => NSCursor::closedHandCursor(),
Self::HorizontalText => NSCursor::IBeamCursor(),
Self::VerticalText => NSCursor::IBeamCursorForVerticalLayout(),
Self::Link => NSCursor::pointingHandCursor(),
Self::ResizeLeft if new_resize => {
NSCursor::columnResizeCursorInDirections(NSHorizontalDirections::Left)
}
Self::ResizeLeft => NSCursor::resizeLeftCursor(),
Self::ResizeRight if new_resize => {
NSCursor::columnResizeCursorInDirections(NSHorizontalDirections::Right)
}
Self::ResizeRight => NSCursor::resizeRightCursor(),
Self::ResizeUp if new_resize => {
NSCursor::rowResizeCursorInDirections(NSVerticalDirections::Up)
}
Self::ResizeUp => NSCursor::resizeUpCursor(),
Self::ResizeDown if new_resize => {
NSCursor::rowResizeCursorInDirections(NSVerticalDirections::Down)
}
Self::ResizeDown => NSCursor::resizeDownCursor(),
Self::ResizeUpDown if new_resize => NSCursor::rowResizeCursor(),
Self::ResizeUpDown => NSCursor::resizeUpDownCursor(),
Self::ResizeLeftRight if new_resize => NSCursor::columnResizeCursor(),
Self::ResizeLeftRight => NSCursor::resizeLeftRightCursor(),
Self::ContextMenu => NSCursor::contextualMenuCursor(),
Self::Crosshair => NSCursor::crosshairCursor(),
Self::OperationNotAllowed => NSCursor::operationNotAllowedCursor(),
}
}
}
@@ -0,0 +1,68 @@
//! The reference counting of `Helpers/Cursor.swift`'s `Cursor` class, split
//! from the NSCursor calls so it is testable everywhere.
/// Tracks how many times the cursor was hidden.
#[derive(Debug, Default)]
pub struct CursorCounter {
counter: u64,
}
impl CursorCounter {
#[must_use]
pub const fn new() -> Self {
Self { counter: 0 }
}
#[must_use]
pub fn is_visible(&self) -> bool {
self.counter == 0
}
#[must_use]
pub fn count(&self) -> u64 {
self.counter
}
pub fn hide(&mut self) {
self.counter += 1;
}
/// Returns true if the cursor was previously hidden.
pub fn unhide(&mut self) -> bool {
if self.counter > 0 {
self.counter -= 1;
return true;
}
false
}
/// Unhide until visible; returns how many hides were undone.
pub fn unhide_completely(&mut self) -> u64 {
let counter = self.counter;
for _ in 0..counter {
let was_hidden = self.unhide();
debug_assert!(was_hidden);
}
debug_assert_eq!(self.counter, 0);
counter
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hide_unhide() {
let mut c = CursorCounter::new();
assert!(c.is_visible());
assert!(!c.unhide());
c.hide();
c.hide();
assert!(!c.is_visible());
assert!(c.unhide());
assert!(!c.is_visible());
assert_eq!(c.unhide_completely(), 1);
assert!(c.is_visible());
}
}
@@ -0,0 +1,36 @@
//! Main queue helpers: `DispatchQueue.main.async { ... }` and
//! `.asyncAfter(deadline:)` for closures that capture main-thread-only
//! (non-`Send`) state such as `Rc`/`Retained` AppKit objects.
use std::time::Duration;
use dispatch2::{DispatchQueue, DispatchTime};
/// Wrapper to move a closure to the main queue. It is only ever run there,
/// i.e. on the main thread, where the main-thread-only captures are valid.
struct MainOnly<F>(F);
// SAFETY: the wrapped closure is only invoked on the main queue. Callers of
// `main_async*` are on the main thread (AppKit objects are created there) or
// pass only `Send` data; non-`Send` state is never touched off the main thread.
#[allow(unsafe_code)]
unsafe impl<F> Send for MainOnly<F> {}
/// `DispatchQueue.main.async { f() }`.
pub fn main_async(f: impl FnOnce() + 'static) {
let wrapped = MainOnly(f);
DispatchQueue::main().exec_async(move || {
let wrapped = wrapped;
(wrapped.0)();
});
}
/// `DispatchQueue.main.asyncAfter(deadline: .now() + delay) { f() }`.
pub fn main_async_after(delay: Duration, f: impl FnOnce() + 'static) {
let wrapped = MainOnly(f);
let when = DispatchTime::try_from(delay).unwrap_or(DispatchTime::FOREVER);
let _ = DispatchQueue::main().after(when, move || {
let wrapped = wrapped;
(wrapped.0)();
});
}
@@ -0,0 +1,281 @@
//! `Helpers/ExpiringUndoManager.swift`.
//!
//! An `NSUndoManager` subclass that supports registering undo operations that
//! automatically expire after a specified duration.
//!
//! When an undo operation expires, it is automatically removed from the undo
//! stack and cannot be invoked.
//!
//! ```ignore
//! let undo_manager = ExpiringUndoManager::new(mtm);
//! undo_manager.register_undo(&my_object, Duration::from_secs(30), |target| {
//! // Undo operation that expires after 30 seconds
//! });
//! ```
//!
//! Deviation: Swift's `ExpiringTarget.deinit` calls `expire()`, which messages
//! the undo manager with `self`; that is not possible while the Objective-C
//! object is being deallocated, so the drop only invalidates the timer. The
//! manager owns its targets (strongly) until they expire or are removed, so a
//! target is never deallocated while registered.
// 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::RefCell, ptr::NonNull, time::Duration};
use block2::RcBlock;
use objc2::{
AnyThread,
DefinedClass,
MainThreadOnly,
define_class,
msg_send,
rc::{Retained, Weak},
runtime::AnyObject,
};
use objc2_foundation::{NSObject, NSTimer, NSUndoManager};
use super::extensions::duration::DurationExt;
/// The instance variables of an [`ExpiringTarget`].
pub struct TargetIvars {
/// The actual target object for the undo operation, held weakly to avoid
/// retain cycles.
target: RefCell<Option<Weak<AnyObject>>>,
/// Timer that triggers expiration after the specified duration.
timer: RefCell<Option<Retained<NSTimer>>>,
/// The undo manager from which to remove actions when this target expires.
undo_manager: RefCell<Option<Weak<NSUndoManager>>>,
}
impl Drop for TargetIvars {
fn drop(&mut self) {
if let Some(timer) = self.timer.get_mut().take() {
timer.invalidate();
}
}
}
define_class!(
/// A target object for ExpiringUndoManager that removes itself from the
/// undo manager after it expires.
///
/// This class acts as a proxy for the real target object in undo
/// operations. It holds a weak reference to the actual target and
/// automatically removes all associated undo operations when either the
/// specified duration expires or `expire()` is called manually.
// SAFETY: NSObject has no subclassing requirements; the ivars' Drop is the
// only destructor logic.
#[unsafe(super(NSObject))]
#[name = "GhosttyExpiringTarget"]
#[ivars = TargetIvars]
struct ExpiringTarget;
);
impl ExpiringTarget {
fn new(target: &AnyObject, duration: Duration, undo_manager: &NSUndoManager) -> Retained<Self> {
let this = Self::alloc().set_ivars(TargetIvars {
target: RefCell::new(Some(Weak::new(target))),
timer: RefCell::new(None),
undo_manager: RefCell::new(Some(Weak::new(undo_manager))),
});
// SAFETY: NSObject's init is the designated initializer.
let this: Retained<Self> = unsafe { msg_send![super(this), init] };
let weak = Weak::new(&*this);
// SAFETY: the block is a valid `void (^)(NSTimer *)`.
let timer = unsafe {
NSTimer::scheduledTimerWithTimeInterval_repeats_block(
duration.time_interval(),
false,
&RcBlock::new(move |_timer: NonNull<NSTimer>| {
if let Some(this) = weak.load() {
this.expire();
}
}),
)
};
*this.ivars().timer.borrow_mut() = Some(timer);
this
}
/// The actual target object, if it is still alive.
fn target(&self) -> Option<Retained<AnyObject>> {
self.ivars().target.borrow().as_ref().and_then(Weak::load)
}
/// Manually expires the target, removing all associated undo actions and
/// invalidating the timer.
///
/// This method is called automatically when the timer fires, but can also
/// be called manually to expire the target before the timer duration has
/// elapsed.
fn expire(&self) {
*self.ivars().target.borrow_mut() = None;
let manager = self
.ivars()
.undo_manager
.borrow()
.as_ref()
.and_then(Weak::load);
if let Some(manager) = manager {
// SAFETY: `self` is a valid target object.
#[allow(unsafe_code)]
unsafe {
manager.removeAllActionsWithTarget(self);
}
}
if let Some(timer) = self.ivars().timer.borrow_mut().take() {
timer.invalidate();
}
}
}
/// Instance variables of the manager.
#[derive(Default)]
pub struct ManagerIvars {
/// The set of expiring targets so we can properly clean them up when
/// removeAllActions is called with the real target.
expiring_targets: RefCell<Vec<Retained<ExpiringTarget>>>,
}
define_class!(
/// An UndoManager subclass that supports registering undo operations that
/// automatically expire after a specified duration.
// SAFETY: NSUndoManager may be subclassed; we override only documented
// methods and call super; no Drop.
#[unsafe(super(NSUndoManager))]
#[thread_kind = MainThreadOnly]
#[name = "GhosttyExpiringUndoManager"]
#[ivars = ManagerIvars]
pub struct ExpiringUndoManager;
impl ExpiringUndoManager {
/// Removes all undo and redo operations from the undo manager.
///
/// This override ensures that all expiring targets are also cleared
/// when the undo manager is reset.
#[unsafe(method(removeAllActions))]
fn remove_all_actions(&self) {
// SAFETY: calling the superclass implementation.
let _: () = unsafe { msg_send![super(self), removeAllActions] };
self.ivars().expiring_targets.borrow_mut().clear();
}
/// Removes all undo and redo operations involving the specified target.
///
/// This override ensures that when actions are removed for a target,
/// any associated expiring targets are also properly cleaned up.
#[unsafe(method(removeAllActionsWithTarget:))]
fn remove_all_actions_with_target(&self, target: &AnyObject) {
// Call super to handle standard removal
// SAFETY: calling the superclass implementation.
let _: () = unsafe { msg_send![super(self), removeAllActionsWithTarget: target] };
let target_ptr: *const AnyObject = target;
// If the target is an expiring target, remove it.
let is_expiring = self
.ivars()
.expiring_targets
.borrow()
.iter()
.any(|e| std::ptr::eq((&**e as *const ExpiringTarget).cast::<AnyObject>(), target_ptr));
if is_expiring {
self.ivars()
.expiring_targets
.borrow_mut()
.retain(|e| !std::ptr::eq((&**e as *const ExpiringTarget).cast::<AnyObject>(), target_ptr));
} else {
// Find and remove any ExpiringTarget instances that wrap this target.
let matching: Vec<Retained<ExpiringTarget>> = self
.ivars()
.expiring_targets
.borrow()
.iter()
.filter(|e| e.target().is_none_or(|t| std::ptr::eq(&*t as *const AnyObject, target_ptr)))
.cloned()
.collect();
for e in matching {
// Technically they'll always expire when they get
// deinitialized but we want to make sure it happens right now.
e.expire();
self.ivars().expiring_targets.borrow_mut().retain(|x| !std::ptr::eq(Retained::as_ptr(x), Retained::as_ptr(&e)));
}
}
}
}
);
impl ExpiringUndoManager {
#[must_use]
pub fn new(mtm: objc2::MainThreadMarker) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(ManagerIvars::default());
// SAFETY: NSUndoManager's init is the designated initializer.
unsafe { msg_send![super(this), init] }
}
/// Registers an undo operation that automatically expires after the
/// specified duration.
///
/// - `target`: the target object for the undo operation. The undo
/// operation will be removed if this object is deallocated before the
/// operation is invoked.
/// - `duration`: the duration after which the undo operation should expire
/// and be removed from the undo stack.
/// - `handler`: the closure to execute when the undo operation is invoked.
/// It receives the target object as its parameter.
pub fn register_undo(
&self,
target: &AnyObject,
expires_after: Duration,
handler: impl Fn(&AnyObject) + 'static,
) {
// Ignore instantly expiring undos
if expires_after.time_interval() <= 0.0 {
return;
}
// Ignore when undo registration is disabled. UndoManager still lets
// registration happen then cancels later but I was seeing some
// weird behavior with this so let's just guard on it.
if !self.isUndoRegistrationEnabled() {
return;
}
let expiring_target = ExpiringTarget::new(target, expires_after, self);
self.ivars()
.expiring_targets
.borrow_mut()
.push(expiring_target.clone());
let weak_self = Weak::new(self);
let block = RcBlock::new(move |raw: NonNull<AnyObject>| {
// SAFETY: the undo manager passes back the registered target.
#[allow(unsafe_code)]
let raw = unsafe { raw.as_ref() };
if let Some(this) = weak_self.load() {
this.ivars().expiring_targets.borrow_mut().retain(|e| {
!std::ptr::eq(
(&**e as *const ExpiringTarget).cast::<AnyObject>(),
raw as *const AnyObject,
)
});
}
// SAFETY: `raw` is the ExpiringTarget we registered.
#[allow(unsafe_code)]
let expiring: &ExpiringTarget =
unsafe { &*(raw as *const AnyObject).cast::<ExpiringTarget>() };
let Some(target) = expiring.target() else {
return;
};
handler(&target);
});
// SAFETY: the target is an object and the handler block matches
// `void (^)(id)`.
#[allow(unsafe_code)]
unsafe {
self.registerUndoWithTarget_handler(&expiring_target, &block);
}
}
}
@@ -0,0 +1,80 @@
//! `Helpers/Extensions/Array+Extension.swift`.
use std::ffi::CString;
/// `extension Array`.
pub trait ArrayExt<T> {
/// `subscript(safe:)`.
fn get_safe(&self, index: isize) -> Option<&T>;
/// Returns the index before i, with wraparound. Assumes i is a valid index.
fn index_wrapping_before(&self, i: usize) -> usize;
/// Returns the index after i, with wraparound. Assumes i is a valid index.
fn index_wrapping_after(&self, i: usize) -> usize;
}
impl<T> ArrayExt<T> for [T] {
fn get_safe(&self, index: isize) -> Option<&T> {
usize::try_from(index).ok().and_then(|i| self.get(i))
}
fn index_wrapping_before(&self, i: usize) -> usize {
if i == 0 {
return self.len() - 1;
}
i - 1
}
fn index_wrapping_after(&self, i: usize) -> usize {
if i == self.len() - 1 {
return 0;
}
i + 1
}
}
/// `Array<String>.withCStrings`: NUL terminated copies of the strings (the
/// returned `CString`s own the memory the pointers of `as_ptr()` refer to).
/// A string containing an interior NUL is truncated at it, as a C consumer
/// would read it.
#[must_use]
pub fn to_c_strings<S: AsRef<str>>(strings: &[S]) -> Vec<CString> {
strings
.iter()
.map(|s| {
let bytes = s.as_ref().as_bytes();
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
CString::new(&bytes[..end]).unwrap_or_default()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_subscript() {
let a = [1, 2, 3];
assert_eq!(a.get_safe(0), Some(&1));
assert_eq!(a.get_safe(3), None);
assert_eq!(a.get_safe(-1), None);
}
#[test]
fn wrapping_indices() {
let a = [1, 2, 3];
assert_eq!(a.index_wrapping_before(0), 2);
assert_eq!(a.index_wrapping_before(2), 1);
assert_eq!(a.index_wrapping_after(2), 0);
assert_eq!(a.index_wrapping_after(0), 1);
}
#[test]
fn c_strings() {
let c = to_c_strings(&["a", "b\0c", ""]);
assert_eq!(c[0].to_str().unwrap(), "a");
assert_eq!(c[1].to_str().unwrap(), "b");
assert_eq!(c[2].as_bytes().len(), 0);
assert_eq!(to_c_strings::<&str>(&[]).len(), 0);
}
}
@@ -0,0 +1,26 @@
//! `Helpers/Extensions/Double+Extension.swift`.
use std::ops::RangeInclusive;
pub trait DoubleExt {
/// `clamped(to:)`.
fn clamped_to(self, range: RangeInclusive<f64>) -> f64;
}
impl DoubleExt for f64 {
fn clamped_to(self, range: RangeInclusive<f64>) -> f64 {
self.max(*range.start()).min(*range.end())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamps() {
assert_eq!(5.0.clamped_to(0.0..=1.0), 1.0);
assert_eq!((-5.0).clamped_to(0.0..=1.0), 0.0);
assert_eq!(0.5.clamped_to(0.0..=1.0), 0.5);
}
}
@@ -0,0 +1,25 @@
//! `Helpers/Extensions/Duration+Extension.swift`.
use std::time::Duration;
pub trait DurationExt {
/// `Duration.timeInterval` (seconds as a double).
fn time_interval(&self) -> f64;
}
impl DurationExt for Duration {
fn time_interval(&self) -> f64 {
self.as_secs() as f64 + f64::from(self.subsec_nanos()) / 1_000_000_000.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seconds() {
assert_eq!(Duration::from_millis(1500).time_interval(), 1.5);
assert_eq!(Duration::ZERO.time_interval(), 0.0);
}
}
@@ -0,0 +1,112 @@
//! `Helpers/Extensions/EventModifiers+Extension.swift`: conversions between
//! `SwiftUI.EventModifiers` ([`EventModifiers`]) and `NSEvent.ModifierFlags`.
//!
//! The conversion works on the raw `NSEventModifierFlags` bits (documented,
//! stable values) so it is testable everywhere; the typed
//! `NSEventModifierFlags` conversions are thin wrappers (Apple only).
use crate::helpers::keyboard_shortcut::EventModifiers;
pub const NS_CAPS_LOCK: usize = 1 << 16;
pub const NS_SHIFT: usize = 1 << 17;
pub const NS_CONTROL: usize = 1 << 18;
pub const NS_OPTION: usize = 1 << 19;
pub const NS_COMMAND: usize = 1 << 20;
/// `EventModifiers.init(nsFlags:)` on raw `NSEvent.ModifierFlags` bits.
#[must_use]
pub fn event_modifiers_from_ns_bits(ns: usize) -> EventModifiers {
let mut result = EventModifiers::empty();
if ns & NS_SHIFT != 0 {
result.insert(EventModifiers::SHIFT);
}
if ns & NS_CONTROL != 0 {
result.insert(EventModifiers::CONTROL);
}
if ns & NS_OPTION != 0 {
result.insert(EventModifiers::OPTION);
}
if ns & NS_COMMAND != 0 {
result.insert(EventModifiers::COMMAND);
}
if ns & NS_CAPS_LOCK != 0 {
result.insert(EventModifiers::CAPS_LOCK);
}
result
}
/// `NSEvent.ModifierFlags.init(swiftUIFlags:)` as raw bits.
#[must_use]
pub fn ns_bits_from_event_modifiers(flags: EventModifiers) -> usize {
let mut result = 0;
if flags.contains(EventModifiers::SHIFT) {
result |= NS_SHIFT;
}
if flags.contains(EventModifiers::CONTROL) {
result |= NS_CONTROL;
}
if flags.contains(EventModifiers::OPTION) {
result |= NS_OPTION;
}
if flags.contains(EventModifiers::COMMAND) {
result |= NS_COMMAND;
}
if flags.contains(EventModifiers::CAPS_LOCK) {
result |= NS_CAPS_LOCK;
}
result
}
/// `EventModifiers(nsFlags:)`.
#[cfg(target_vendor = "apple")]
#[must_use]
pub fn event_modifiers_from_ns_flags(flags: objc2_app_kit::NSEventModifierFlags) -> EventModifiers {
event_modifiers_from_ns_bits(flags.0)
}
/// `NSEvent.ModifierFlags(swiftUIFlags:)`.
#[cfg(target_vendor = "apple")]
#[must_use]
pub fn ns_flags_from_event_modifiers(flags: EventModifiers) -> objc2_app_kit::NSEventModifierFlags {
objc2_app_kit::NSEventModifierFlags(ns_bits_from_event_modifiers(flags))
}
/// Alias of [`ns_flags_from_event_modifiers`] (`NSEvent.ModifierFlags(swiftUIFlags:)`).
#[cfg(target_vendor = "apple")]
#[must_use]
pub fn ns_flags_from_swiftui(flags: EventModifiers) -> objc2_app_kit::NSEventModifierFlags {
ns_flags_from_event_modifiers(flags)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_known_flags() {
let all = EventModifiers::SHIFT
| EventModifiers::CONTROL
| EventModifiers::OPTION
| EventModifiers::COMMAND
| EventModifiers::CAPS_LOCK;
assert_eq!(
event_modifiers_from_ns_bits(ns_bits_from_event_modifiers(all)),
all
);
assert_eq!(
ns_bits_from_event_modifiers(EventModifiers::COMMAND),
1 << 20
);
assert_eq!(event_modifiers_from_ns_bits(1 << 17), EventModifiers::SHIFT);
}
#[test]
fn other_bits_are_ignored() {
// numeric pad / function / device bits are not translated
assert_eq!(
event_modifiers_from_ns_bits((1 << 21) | (1 << 23) | 0x2),
EventModifiers::empty()
);
assert_eq!(ns_bits_from_event_modifiers(EventModifiers::FUNCTION), 0);
}
}
@@ -0,0 +1,26 @@
//! `Helpers/Extensions/FileHandle+Extension.swift`: `FileHandle` as a
//! `TextOutputStream`. Any `io::Write` already has `write_all`; this is the
//! string convenience.
use std::io::Write;
pub trait WriteStringExt: Write {
/// Write a string to the handle.
fn write_string(&mut self, string: &str) -> std::io::Result<()> {
self.write_all(string.as_bytes())
}
}
impl<W: Write + ?Sized> WriteStringExt for W {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_utf8() {
let mut v = Vec::new();
v.write_string("héllo").unwrap();
assert_eq!(v, "héllo".as_bytes());
}
}
@@ -0,0 +1,68 @@
//! `Helpers/Extensions/*`: one module per Swift file. A Swift `extension X`
//! becomes an extension trait `XExt` implemented for the objc2 type (or free
//! functions for value types). Pure logic is in the platform independent
//! modules; the AppKit traits are `cfg(target_vendor = "apple")`.
//!
//! Unportable (SwiftUI only): `View+Extension.swift`, `Transferable+Extension.swift`.
//! `UUID+Extension` (CFUUID) lives in [`uuid`]; `KeyboardShortcut+Extension`
//! is `helpers::keyboard_shortcut`.
// Platform independent.
pub mod array;
pub mod double;
pub mod duration;
pub mod event_modifiers;
pub mod file_handle;
pub mod ns_screen_logic;
pub mod ns_window_logic;
pub mod object_identifier;
pub mod optional;
pub mod os_color;
pub mod string;
// AppKit.
#[cfg(target_vendor = "apple")]
pub mod ns_appearance;
#[cfg(target_vendor = "apple")]
pub mod ns_application;
#[cfg(target_vendor = "apple")]
pub mod ns_color;
#[cfg(target_vendor = "apple")]
pub mod ns_image;
#[cfg(target_vendor = "apple")]
pub mod ns_menu;
#[cfg(target_vendor = "apple")]
pub mod ns_menu_item;
#[cfg(target_vendor = "apple")]
pub mod ns_notification;
#[cfg(target_vendor = "apple")]
pub mod ns_pasteboard;
#[cfg(target_vendor = "apple")]
pub mod ns_screen;
#[cfg(target_vendor = "apple")]
pub mod ns_view;
#[cfg(target_vendor = "apple")]
pub mod ns_window;
#[cfg(target_vendor = "apple")]
pub mod ns_workspace;
#[cfg(target_vendor = "apple")]
pub mod undo_manager;
#[cfg(target_vendor = "apple")]
pub mod user_defaults;
#[cfg(target_vendor = "apple")]
pub mod uuid;
/// `#available(macOS N, *)`: true if the running OS is at least `major`.
#[cfg(target_vendor = "apple")]
#[must_use]
pub fn macos_at_least(major: isize) -> bool {
use objc2_foundation::{NSOperatingSystemVersion, NSProcessInfo};
NSProcessInfo::processInfo().isOperatingSystemAtLeastVersion(NSOperatingSystemVersion {
majorVersion: major,
minorVersion: 0,
patchVersion: 0,
})
}
#[cfg(target_vendor = "apple")]
pub mod uti;
@@ -0,0 +1,41 @@
//! `Helpers/Extensions/NSAppearance+Extension.swift`.
use objc2::rc::Retained;
use objc2_app_kit::{NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua};
use super::os_color;
use crate::ghostty::config::Config;
pub trait NSAppearanceExt {
/// Returns true if the appearance is some kind of dark.
fn is_dark(&self) -> bool;
}
impl NSAppearanceExt for NSAppearance {
fn is_dark(&self) -> bool {
self.name().to_string().to_lowercase().contains("dark")
}
}
/// `NSAppearance(ghosttyConfig:)`: the desired NSAppearance for the Ghostty
/// configuration (`window-theme`), if any.
#[must_use]
#[allow(unsafe_code)]
pub fn appearance_from_ghostty_config(config: &Config) -> Option<Retained<NSAppearance>> {
let theme = config.window_theme()?;
// SAFETY: reading AppKit's immutable appearance name constants.
let (aqua, dark) = unsafe { (NSAppearanceNameAqua, NSAppearanceNameDarkAqua) };
match theme.as_str() {
"dark" => NSAppearance::appearanceNamed(dark),
"light" => NSAppearance::appearanceNamed(aqua),
"auto" => {
let c = config.background_color();
if os_color::is_light_color(c.red, c.green, c.blue) {
NSAppearance::appearanceNamed(aqua)
} else {
NSAppearance::appearanceNamed(dark)
}
}
_ => None,
}
}
@@ -0,0 +1,118 @@
//! `Helpers/Extensions/NSApplication+Extension.swift`.
use std::{cell::RefCell, collections::HashMap};
use objc2_app_kit::{
NSApplication,
NSApplicationPresentationOptions,
NSRunningApplication,
NSWorkspace,
};
use objc2_foundation::NSBundle;
thread_local! {
/// `NSApplication.presentationOptionCounts`, keyed by the option's raw value.
static PRESENTATION_OPTION_COUNTS: RefCell<HashMap<usize, u64>> = RefCell::new(HashMap::new());
}
/// Pure ref counting behind `acquire/releasePresentationOption`.
#[derive(Debug, Default)]
pub struct OptionCounts(HashMap<usize, u64>);
/// What the caller must do to the app's presentation options.
#[derive(Debug, PartialEq, Eq)]
pub enum OptionChange {
Insert,
Remove,
Nothing,
}
impl OptionCounts {
/// `acquirePresentationOption`: always (re)inserts the option.
pub fn acquire(&mut self, option: usize) -> OptionChange {
*self.0.entry(option).or_insert(0) += 1;
OptionChange::Insert
}
/// `releasePresentationOption`: removes the option when the last
/// reference is released.
pub fn release(&mut self, option: usize) -> OptionChange {
let Some(&value) = self.0.get(&option) else {
return OptionChange::Nothing;
};
if value == 0 {
return OptionChange::Nothing;
}
if value == 1 {
self.0.remove(&option);
OptionChange::Remove
} else {
self.0.insert(option, value - 1);
OptionChange::Nothing
}
}
}
pub trait NSApplicationExt {
/// Add a presentation option to the application and maintain a reference
/// count so that an equal number of releases is required to disable it.
/// This is useful so that multiple classes can affect global app state
/// without overriding others.
fn acquire_presentation_option(&self, option: NSApplicationPresentationOptions);
/// See `acquire_presentation_option`.
fn release_presentation_option(&self, option: NSApplicationPresentationOptions);
/// True if the application is frontmost. This isn't exactly the same as
/// isActive because an app can be active but not be frontmost if the
/// window with activity is an NSPanel.
fn is_frontmost(&self) -> bool;
}
impl NSApplicationExt for NSApplication {
fn acquire_presentation_option(&self, option: NSApplicationPresentationOptions) {
PRESENTATION_OPTION_COUNTS.with(|c| {
let mut counts = OptionCounts(std::mem::take(&mut *c.borrow_mut()));
let change = counts.acquire(option.0);
*c.borrow_mut() = counts.0;
debug_assert_eq!(change, OptionChange::Insert);
});
self.setPresentationOptions(self.presentationOptions() | option);
}
fn release_presentation_option(&self, option: NSApplicationPresentationOptions) {
let change = PRESENTATION_OPTION_COUNTS.with(|c| {
let mut counts = OptionCounts(std::mem::take(&mut *c.borrow_mut()));
let change = counts.release(option.0);
*c.borrow_mut() = counts.0;
change
});
if change == OptionChange::Remove {
self.setPresentationOptions(self.presentationOptions() - option);
}
}
fn is_frontmost(&self) -> bool {
let front: Option<_> = NSWorkspace::sharedWorkspace().frontmostApplication();
let front_id =
front.and_then(|a: objc2::rc::Retained<NSRunningApplication>| a.bundleIdentifier());
let main_id = NSBundle::mainBundle().bundleIdentifier();
front_id.map(|s| s.to_string()) == main_id.map(|s| s.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ref_counts() {
let mut c = OptionCounts::default();
assert_eq!(c.release(1), OptionChange::Nothing);
assert_eq!(c.acquire(1), OptionChange::Insert);
assert_eq!(c.acquire(1), OptionChange::Insert);
assert_eq!(c.release(1), OptionChange::Nothing);
assert_eq!(c.release(1), OptionChange::Remove);
assert_eq!(c.release(1), OptionChange::Nothing);
}
}
@@ -0,0 +1,180 @@
//! `Helpers/Extensions/NSColor+Extension.swift` and
//! `OSColor+Extension.swift` (`OSColor` is `NSColor` on macOS). The math is in
//! `os_color`.
// 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::{Message, rc::Retained};
use objc2_app_kit::{NSColor, NSColorList, NSColorSpace};
use objc2_foundation::NSString;
use super::os_color;
thread_local! {
/// Using a color list let's us get localized names.
static APPLE_COLOR_LIST: Option<Retained<NSColorList>> =
NSColorList::colorListNamed(&NSString::from_str("Apple"));
}
/// `NSColor(named:)`: a color from the "Apple" color list by (capitalized) name.
#[must_use]
pub fn color_named(name: &str) -> Option<Retained<NSColor>> {
let color = APPLE_COLOR_LIST.with(|list| {
let list = list.as_ref()?;
list.colorWithKey(&NSString::from_str(&capitalized(name)))
})?;
let components = color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
Some(NSColor::colorWithRed_green_blue_alpha(
components.redComponent(),
components.greenComponent(),
components.blueComponent(),
components.alphaComponent(),
))
}
/// Swift's `String.capitalized`: first letter of each word upper, rest lower.
fn capitalized(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut start = true;
for c in s.chars() {
if c.is_alphanumeric() {
if start {
out.extend(c.to_uppercase());
} else {
out.extend(c.to_lowercase());
}
start = false;
} else {
out.push(c);
start = true;
}
}
out
}
/// `NSColor.colorNames`.
#[must_use]
pub fn color_names() -> Vec<String> {
APPLE_COLOR_LIST.with(|list| {
list.as_ref()
.map(|l| {
l.allKeys()
.iter()
.map(|k| k.to_string().to_lowercase())
.collect()
})
.unwrap_or_default()
})
}
/// `OSColor(hex:)`.
#[must_use]
pub fn color_from_hex(hex: &str) -> Option<Retained<NSColor>> {
let (r, g, b, a) = os_color::parse_hex(hex)?;
Some(NSColor::colorWithRed_green_blue_alpha(r, g, b, a))
}
/// `OSColor(ghostty: ghostty_config_color_s)` from the 8-bit components.
#[must_use]
pub fn color_from_ghostty(r: u8, g: u8, b: u8) -> Retained<NSColor> {
let (r, g, b) = os_color::from_ghostty(r, g, b);
NSColor::colorWithRed_green_blue_alpha(r, g, b, 1.0)
}
pub trait NSColorExt {
/// Returns a new color with its saturation multiplied by the given
/// factor, clamped to [0, 1].
fn adjusting_saturation(&self, factor: f64) -> Retained<NSColor>;
/// Calculates the perceptual distance to another color in RGB space.
fn distance_to(&self, other: &NSColor) -> f64;
/// `OSColor.luminance`.
fn luminance(&self) -> f64;
/// `OSColor.isLightColor`.
fn is_light_color(&self) -> bool;
/// `OSColor.hexString`.
fn hex_string(&self) -> Option<String>;
/// `OSColor.darken(by:)`.
fn darken(&self, amount: f64) -> Retained<NSColor>;
}
#[allow(unsafe_code)]
fn hsb(color: &NSColor) -> (f64, f64, f64, f64) {
let (mut h, mut s, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
// SAFETY: the out-pointers are valid for writes.
unsafe { color.getHue_saturation_brightness_alpha(&mut h, &mut s, &mut b, &mut a) };
(h, s, b, a)
}
fn srgb(color: &NSColor) -> Option<Retained<NSColor>> {
color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())
}
impl NSColorExt for NSColor {
fn adjusting_saturation(&self, factor: f64) -> Retained<NSColor> {
let hsb_color = srgb(self).unwrap_or_else(|| self.retain());
let (h, s, b, a) = hsb(&hsb_color);
NSColor::colorWithHue_saturation_brightness_alpha(
h,
os_color::adjusted_saturation(s, factor),
b,
a,
)
}
fn distance_to(&self, other: &NSColor) -> f64 {
let (Some(a), Some(b)) = (srgb(self), srgb(other)) else {
return f64::INFINITY;
};
os_color::distance(
(a.redComponent(), a.greenComponent(), a.blueComponent()),
(b.redComponent(), b.greenComponent(), b.blueComponent()),
)
}
#[allow(unsafe_code)]
fn luminance(&self) -> f64 {
// getRed:green:blue:alpha requires sRGB space
let Some(rgb) = srgb(self) else { return 0.0 };
os_color::luminance(
rgb.redComponent(),
rgb.greenComponent(),
rgb.blueComponent(),
)
}
fn is_light_color(&self) -> bool {
self.luminance() > 0.5
}
fn hex_string(&self) -> Option<String> {
let rgb = self.colorUsingColorSpace(&NSColorSpace::deviceRGBColorSpace())?;
Some(os_color::hex_string(
rgb.redComponent(),
rgb.greenComponent(),
rgb.blueComponent(),
))
}
fn darken(&self, amount: f64) -> Retained<NSColor> {
let (h, s, b, a) = hsb(self);
NSColor::colorWithHue_saturation_brightness_alpha(
h,
s,
os_color::darkened_brightness(b, amount),
a,
)
}
}
#[cfg(test)]
mod tests {
use super::capitalized;
#[test]
fn capitalized_matches_swift() {
assert_eq!(capitalized("red"), "Red");
assert_eq!(capitalized("DARK GRAY"), "Dark Gray");
assert_eq!(capitalized("a-b"), "A-B");
}
}
@@ -0,0 +1,143 @@
//! `Helpers/Extensions/NSImage+Extension.swift`.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code, deprecated)]
use objc2::{AnyThread, rc::Retained};
use objc2_app_kit::{NSColor, NSCompositingOperation, NSGradient, NSGraphicsContext, NSImage};
use objc2_core_graphics::{
CGBitmapContextCreate,
CGBitmapContextCreateImage,
CGBlendMode,
CGColorSpace,
CGContext,
CGImageAlphaInfo,
};
use objc2_foundation::{NSArray, NSPoint, NSRect, NSSize};
fn zero_rect(size: NSSize) -> NSRect {
NSRect::new(NSPoint::new(0.0, 0.0), size)
}
/// `NSImage.cgImage(forProposedRect: nil, context: nil, hints: nil)`.
#[allow(unsafe_code)]
fn cg_image(image: &NSImage) -> Option<Retained<objc2_core_graphics::CGImage>> {
// SAFETY: nil rect/context/hints are allowed.
unsafe { image.CGImageForProposedRect_context_hints(std::ptr::null_mut(), None, None) }
}
pub trait NSImageExt {
/// Apply a gradient onto this image, using this image as a mask.
fn gradient(&self, colors: &[Retained<NSColor>]) -> Option<Retained<NSImage>>;
/// Tint an NSImage with the given color by applying a basic fill on top of it.
fn tint(&self, color: &NSColor) -> Option<Retained<NSImage>>;
}
/// `NSImage.combine(images:blendingModes:)`: combine multiple images with the
/// given blend modes. This is useful given a set of layers to create a final
/// rasterized image.
#[allow(unsafe_code)]
#[must_use]
pub fn combine(
images: &[Retained<NSImage>],
blending_modes: &[CGBlendMode],
) -> Option<Retained<NSImage>> {
if images.len() != blending_modes.len() || images.is_empty() {
return None;
}
// The final size will be the same size as our first image.
let size = images[0].size();
// Create a bitmap context manually
let space = CGColorSpace::new_device_rgb();
// SAFETY: null data lets CoreGraphics allocate the backing store.
let ctx = unsafe {
CGBitmapContextCreate(
std::ptr::null_mut(),
size.width as usize,
size.height as usize,
8,
0,
space.as_deref(),
CGImageAlphaInfo::PremultipliedLast.0,
)
}?;
// Clear the context
CGContext::set_fill_color_with_color(Some(&ctx), None);
CGContext::clear_rect(Some(&ctx), zero_rect(size));
// Draw each image with its corresponding blend mode
for (image, mode) in images.iter().zip(blending_modes) {
let cg = cg_image(image)?;
CGContext::set_blend_mode(Some(&ctx), *mode);
CGContext::draw_image(Some(&ctx), zero_rect(size), Some(&cg));
}
// Create a CGImage from the context
let combined = CGBitmapContextCreateImage(Some(&ctx))?;
// Wrap the CGImage in an NSImage
Some(NSImage::initWithCGImage_size(
NSImage::alloc(),
&combined,
size,
))
}
impl NSImageExt for NSImage {
fn gradient(&self, colors: &[Retained<NSColor>]) -> Option<Retained<NSImage>> {
let size = self.size();
let result = NSImage::initWithSize(NSImage::alloc(), size);
result.lockFocus();
let out = (|| {
// Draw the gradient
let array = NSArray::from_retained_slice(colors);
let gradient = NSGradient::initWithColors(NSGradient::alloc(), &array)?;
gradient.drawInRect_angle(zero_rect(size), 90.0);
// Apply the mask
self.drawAtPoint_fromRect_operation_fraction(
NSPoint::new(0.0, 0.0),
NSRect::ZERO,
NSCompositingOperation::DestinationIn,
1.0,
);
Some(())
})();
result.unlockFocus();
out.map(|()| result)
}
fn tint(&self, color: &NSColor) -> Option<Retained<NSImage>> {
let size = self.size();
// Create a new image with the same size as the base image
let new_image = NSImage::initWithSize(NSImage::alloc(), size);
// Draw into the new image
new_image.lockFocus();
let out = (|| {
// Set up the drawing context
let context = NSGraphicsContext::currentContext()?.CGContext();
// Draw the base image
let cg = cg_image(self)?;
CGContext::draw_image(Some(&context), zero_rect(size), Some(&cg));
// Set the tint color and blend mode
CGContext::set_fill_color_with_color(Some(&context), Some(&color.CGColor()));
CGContext::set_blend_mode(Some(&context), CGBlendMode::SourceAtop);
// Apply the tint color over the entire image
CGContext::fill_rect(Some(&context), zero_rect(size));
Some(context)
})();
if let Some(context) = &out {
CGContext::restore_g_state(Some(context));
}
new_image.unlockFocus();
out.map(|_| new_image)
}
}
@@ -0,0 +1,57 @@
//! `Helpers/Extensions/NSMenu+Extension.swift`.
use std::collections::HashSet;
use objc2::runtime::Sel;
use objc2_app_kit::{NSMenu, NSMenuItem, NSUserInterfaceItemIdentification};
pub trait NSMenuExt {
/// Inserts a menu item after an existing item with the specified action
/// selector.
///
/// If an item with the same identifier already exists, it is removed first
/// to avoid duplicates. This is useful when menus are cached and reused
/// across different targets.
///
/// Returns the index where the item was inserted, or `None` if the action
/// was not found and the item was not inserted.
fn insert_item_after(&self, item: &NSMenuItem, action: Sel) -> Option<usize>;
/// Removes all menu items whose identifier is in the given set.
fn remove_items_with_identifiers(&self, identifiers: &HashSet<String>);
}
impl NSMenuExt for NSMenu {
fn insert_item_after(&self, item: &NSMenuItem, action: Sel) -> Option<usize> {
if let Some(identifier) = item.identifier() {
let existing = self
.itemArray()
.iter()
.find(|i| i.identifier().is_some_and(|id| id == identifier));
if let Some(existing) = existing {
self.removeItem(&existing);
}
}
let idx = self
.itemArray()
.iter()
.position(|i| i.action() == Some(action))?;
let insertion_index = idx + 1;
self.insertItem_atIndex(item, insertion_index as isize);
Some(insertion_index)
}
fn remove_items_with_identifiers(&self, identifiers: &HashSet<String>) {
let items = self.itemArray();
for (index, item) in items.to_vec().into_iter().enumerate().rev() {
let Some(identifier) = item.identifier() else {
continue;
};
if identifiers.contains(&identifier.to_string()) {
self.removeItemAtIndex(index as isize);
}
}
}
}
@@ -0,0 +1,27 @@
//! `Helpers/Extensions/NSMenuItem+Extension.swift`.
use objc2::rc::Retained;
use objc2_app_kit::{NSImage, NSMenuItem};
use objc2_foundation::NSString;
use super::macos_at_least;
pub trait NSMenuItemExt {
/// Sets the image property from a symbol if we want images on our menu items.
fn set_image_if_desired(&self, system_symbol_name: &str);
}
impl NSMenuItemExt for NSMenuItem {
fn set_image_if_desired(&self, system_symbol_name: &str) {
// We only set on macOS 26 when icons on menu items became the norm.
if macos_at_least(26) {
let title = self.title();
let image: Option<Retained<NSImage>> =
NSImage::imageWithSystemSymbolName_accessibilityDescription(
&NSString::from_str(system_symbol_name),
Some(&title),
);
self.setImage(image.as_deref());
}
}
}
@@ -0,0 +1,72 @@
//! Block based observation of AppKit/Foundation notifications
//! (`NotificationCenter.default.addObserver(forName:...)`), the AppKit
//! counterpart of `helpers::notification_center` (which carries Ghostty's own
//! notifications). Dropping the [`NsObserver`] removes it.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use std::ptr::NonNull;
use block2::RcBlock;
use objc2::{
rc::Retained,
runtime::{AnyObject, ProtocolObject},
};
use objc2_foundation::{
NSNotification,
NSNotificationCenter,
NSNotificationName,
NSObjectProtocol,
};
#[must_use = "dropping the observer removes it"]
pub struct NsObserver {
center: Retained<NSNotificationCenter>,
token: Retained<ProtocolObject<dyn NSObjectProtocol>>,
}
impl NsObserver {
/// Keep observing for the life of the process.
pub fn forget(self) {
std::mem::forget(self);
}
}
impl Drop for NsObserver {
fn drop(&mut self) {
// SAFETY: `token` was returned by addObserverForName on this center.
#[allow(unsafe_code)]
unsafe {
self.center.removeObserver(self.token.as_ref());
}
}
}
/// `NotificationCenter.default.addObserver(forName:object:queue:nil)`:
/// `f` runs synchronously on the posting thread.
pub fn observe(
name: &NSNotificationName,
object: Option<&AnyObject>,
f: impl Fn(&NSNotification) + 'static,
) -> NsObserver {
let center = NSNotificationCenter::defaultCenter();
let block = RcBlock::new(move |n: NonNull<NSNotification>| {
// SAFETY: the notification is valid for the duration of the callback.
#[allow(unsafe_code)]
f(unsafe { n.as_ref() });
});
// SAFETY: the block is a valid `void (^)(NSNotification *)`.
#[allow(unsafe_code)]
let token = unsafe {
center.addObserverForName_object_queue_usingBlock(Some(name), object, None, &block)
};
NsObserver { center, token }
}
/// `NSWindow.didBecomeKeyNotification`.
#[must_use]
pub fn name_window_did_become_key() -> &'static NSNotificationName {
// SAFETY: reading AppKit's immutable notification name constant.
unsafe { objc2_app_kit::NSWindowDidBecomeKeyNotification }
}
@@ -0,0 +1,138 @@
//! `Helpers/Extensions/NSPasteboard+Extension.swift` (+ `NSPasteboardTests`).
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use std::ffi::c_int;
use objc2::{ClassType, msg_send, rc::Retained, runtime::AnyObject};
use objc2_app_kit::{NSPasteboard, NSPasteboardTypeString};
use objc2_foundation::{NSArray, NSCopying, NSString, NSURL};
use super::uti;
use crate::ghostty::{
capi::consts::{GHOSTTY_CLIPBOARD_SELECTION, GHOSTTY_CLIPBOARD_STANDARD},
shell,
};
/// `NSPasteboard.PasteboardType(mimeType:)` (a pasteboard type is an
/// `NSString`). Never fails: unknown MIME types are used directly as the
/// identifier, like the Swift fallback.
#[must_use]
pub fn pasteboard_type_from_mime(mime_type: &str) -> Retained<NSString> {
// Explicit mappings for common MIME types
if mime_type == "text/plain" {
// SAFETY: reading AppKit's immutable string constant.
#[allow(unsafe_code)]
return unsafe { NSPasteboardTypeString }.copy();
}
// Try to get UTType from MIME type; otherwise use the MIME type directly.
match uti::identifier_for_mime_type(mime_type) {
Some(identifier) => NSString::from_str(&identifier),
None => NSString::from_str(mime_type),
}
}
thread_local! {
/// `NSPasteboard.ghosttySelection`.
static GHOSTTY_SELECTION: Retained<NSPasteboard> =
NSPasteboard::pasteboardWithName(&NSString::from_str("com.mitchellh.ghostty.selection"));
}
/// The pasteboard to used for Ghostty selection.
#[must_use]
pub fn ghostty_selection() -> Retained<NSPasteboard> {
GHOSTTY_SELECTION.with(Clone::clone)
}
/// `NSPasteboard.ghostty(_:)`: the pasteboard for the Ghostty enum type.
#[must_use]
pub fn ghostty(clipboard: c_int) -> Option<Retained<NSPasteboard>> {
match clipboard {
GHOSTTY_CLIPBOARD_STANDARD => Some(NSPasteboard::generalPasteboard()),
GHOSTTY_CLIPBOARD_SELECTION => Some(ghostty_selection()),
_ => None,
}
}
pub trait NSPasteboardExt {
/// Gets the contents of the pasteboard as a string following a specific
/// set of semantics. Does these things in order:
/// - Tries to get the absolute filesystem path of the file in the
/// pasteboard if there is one and ensures the file path is properly escaped.
/// - Tries to get any string from the pasteboard.
///
/// If all of the above fail, returns None.
fn get_opinionated_string_contents(&self) -> Option<String>;
}
impl NSPasteboardExt for NSPasteboard {
#[allow(unsafe_code)]
fn get_opinionated_string_contents(&self) -> Option<String> {
let classes = NSArray::from_slice(&[NSURL::class()]);
// SAFETY: `readObjectsForClasses:options:` takes an array of classes.
let objects: Option<Retained<NSArray<AnyObject>>> = unsafe {
msg_send![self, readObjectsForClasses: &*classes, options: std::ptr::null::<AnyObject>()]
};
if let Some(objects) = objects {
let urls: Vec<String> = objects
.iter()
.filter_map(|o| o.downcast::<NSURL>().ok())
.map(|url| {
if url.isFileURL() {
url.path()
.map(|p| shell::escape(&p.to_string()))
.unwrap_or_default()
} else {
url.absoluteString()
.map(|s| s.to_string())
.unwrap_or_default()
}
})
.collect();
if !urls.is_empty() {
return Some(urls.join(" "));
}
}
// SAFETY: reading AppKit's immutable string constant.
self.stringForType(unsafe { NSPasteboardTypeString })
.map(|s| s.to_string())
}
}
#[cfg(test)]
mod tests {
use objc2_app_kit::{NSPasteboardTypeHTML, NSPasteboardTypePNG};
use super::*;
/// Test text/plain MIME type converts to .string
#[test]
fn text_plain_mime_type() {
// SAFETY: reading AppKit constants.
#[allow(unsafe_code)]
let expected = unsafe { NSPasteboardTypeString }.to_string();
assert_eq!(
pasteboard_type_from_mime("text/plain").to_string(),
expected
);
}
/// Test text/html MIME type converts to .html
#[test]
fn text_html_mime_type() {
#[allow(unsafe_code)]
let expected = unsafe { NSPasteboardTypeHTML }.to_string();
assert_eq!(pasteboard_type_from_mime("text/html").to_string(), expected);
}
/// Test image/png MIME type
#[test]
fn image_png_mime_type() {
#[allow(unsafe_code)]
let expected = unsafe { NSPasteboardTypePNG }.to_string();
assert_eq!(pasteboard_type_from_mime("image/png").to_string(), expected);
}
}
@@ -0,0 +1,107 @@
//! `Helpers/Extensions/NSScreen+Extension.swift`. The math is in
//! `ns_screen_logic`.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use objc2::rc::Retained;
use objc2_app_kit::{NSApplication, NSScreen};
use objc2_core_foundation::{CFRetained, CFUUID};
use objc2_foundation::{NSNumber, NSPoint, NSSize, NSString, NSUUID};
use super::{ns_screen_logic, user_defaults, uuid::uuid_from_cfuuid};
use crate::helpers::geometry::{Rect, Size};
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
/// Returns a +1 retained CFUUID or null.
fn CGDisplayCreateUUIDFromDisplayID(display_id: u32) -> *const CFUUID;
}
fn to_rect(r: objc2_foundation::NSRect) -> Rect {
Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height)
}
pub trait NSScreenExt {
/// The unique CoreGraphics display ID for this screen.
fn display_id(&self) -> Option<u32>;
/// The stable UUID for this display, suitable for tracking across
/// reconnects and NSScreen garbage collection.
fn display_uuid(&self) -> Option<Retained<NSUUID>>;
/// Returns true if the given screen has a visible dock. This isn't
/// point-in-time visible, this is true if the dock is always visible AND
/// present on this screen.
fn has_dock(&self) -> bool;
/// Returns true if the screen has a visible notch (i.e., a non-zero safe
/// area inset at the top).
fn has_notch(&self) -> bool;
/// Converts top-left offset coordinates to bottom-left origin coordinates
/// for window positioning.
fn origin_from_top_left_offset(&self, x: f64, y: f64, window_size: NSSize) -> NSPoint;
}
impl NSScreenExt for NSScreen {
fn display_id(&self) -> Option<u32> {
let description = self.deviceDescription();
let key = NSString::from_str("NSScreenNumber");
let value = description.objectForKey(&key)?;
value
.downcast_ref::<NSNumber>()
.map(NSNumber::unsignedIntValue)
}
#[allow(unsafe_code)]
fn display_uuid(&self) -> Option<Retained<NSUUID>> {
let display_id = self.display_id()?;
// SAFETY: plain integer argument; result is +1 retained or null.
let raw = unsafe { CGDisplayCreateUUIDFromDisplayID(display_id) };
let raw = std::ptr::NonNull::new(raw.cast_mut())?;
// SAFETY: adopting the +1 reference.
let cfuuid: CFRetained<CFUUID> = unsafe { CFRetained::from_raw(raw) };
uuid_from_cfuuid(&cfuuid)
}
fn has_dock(&self) -> bool {
// If the dock autohides then we don't have a dock ever.
let domain =
user_defaults::ghostty().persistentDomainForName(&NSString::from_str("com.apple.dock"));
let autohide = domain
.and_then(|d| d.objectForKey(&NSString::from_str("autohide")))
.and_then(|v| v.downcast_ref::<NSNumber>().map(NSNumber::boolValue));
if autohide == Some(true) {
return false;
}
// There is no public API to directly ask about dock visibility, so we
// compare visibleFrame and frame (see ns_screen_logic).
let mtm = crate::helpers::main_thread();
let menu_height = NSApplication::sharedApplication(mtm)
.mainMenu()
.map_or(0.0, |m| m.menuBarHeight());
ns_screen_logic::has_visible_dock(
to_rect(self.visibleFrame()),
to_rect(self.frame()),
menu_height,
self.safeAreaInsets().top,
)
}
fn has_notch(&self) -> bool {
// We assume that a top safe area means notch, since we don't
// currently know any other situation this is true.
ns_screen_logic::has_notch(self.safeAreaInsets().top)
}
fn origin_from_top_left_offset(&self, x: f64, y: f64, window_size: NSSize) -> NSPoint {
let p = ns_screen_logic::origin_from_top_left_offset(
to_rect(self.visibleFrame()),
x,
y,
Size {
width: window_size.width,
height: window_size.height,
},
);
NSPoint::new(p.x, p.y)
}
}
@@ -0,0 +1,127 @@
//! The platform independent parts of `Helpers/Extensions/NSScreen+Extension.swift`
//! (and the Swift `NSScreenTests`).
use crate::helpers::geometry::{Point, Rect, Size};
/// `NSScreen.origin(fromTopLeftOffsetX:offsetY:windowSize:)`: converts
/// top-left offset coordinates to bottom-left origin coordinates for window
/// positioning, relative to the screen's `visibleFrame`.
#[must_use]
pub fn origin_from_top_left_offset(
visible_frame: Rect,
x: f64,
y: f64,
window_size: Size,
) -> Point {
// Convert top-left coordinates to bottom-left origin
Point {
x: visible_frame.min_x() + x,
y: visible_frame.max_y() - y - window_size.height,
}
}
/// The geometry part of `NSScreen.hasDock` (after the autohide default check).
#[must_use]
pub fn has_visible_dock(
visible_frame: Rect,
frame: Rect,
menu_height: f64,
notch_inset: f64,
) -> bool {
// If our visible width is less than the frame we assume its the dock.
if visible_frame.width() < frame.width() {
return true;
}
// We need to see if our visible frame height is less than the full
// screen height minus the menu and notch and such.
let boundary_area_padding = 5.0;
visible_frame.height() < (frame.height() - menu_height.max(notch_inset) - boundary_area_padding)
}
/// `NSScreen.hasNotch`: a top safe area inset is assumed to mean a notch.
#[must_use]
pub fn has_notch(safe_area_top: f64) -> bool {
safe_area_top > 0.0
}
#[cfg(test)]
mod tests {
use super::*;
const WIN: Size = Size {
width: 400.0,
height: 300.0,
};
#[test]
fn positive_coordinate_conversion() {
let o = origin_from_top_left_offset(Rect::new(0.0, 100.0, 1000.0, 800.0), 15.0, 15.0, WIN);
assert_eq!((o.x, o.y), (15.0, 585.0));
}
#[test]
fn zero_coordinates() {
let o = origin_from_top_left_offset(Rect::new(0.0, 100.0, 1000.0, 800.0), 0.0, 0.0, WIN);
assert_eq!((o.x, o.y), (0.0, 600.0));
}
#[test]
fn offset_screen() {
let o = origin_from_top_left_offset(
Rect::new(1440.0, 0.0, 1920.0, 1080.0),
100.0,
50.0,
Size {
width: 600.0,
height: 400.0,
},
);
assert_eq!((o.x, o.y), (1540.0, 630.0));
}
#[test]
fn large_coordinates() {
let o = origin_from_top_left_offset(Rect::new(0.0, 0.0, 1920.0, 1080.0), 500.0, 200.0, WIN);
assert_eq!((o.x, o.y), (500.0, 580.0));
}
#[test]
fn dock_detection() {
let frame = Rect::new(0.0, 0.0, 1000.0, 800.0);
// side dock
assert!(has_visible_dock(
Rect::new(70.0, 0.0, 930.0, 776.0),
frame,
24.0,
0.0
));
// bottom dock
assert!(has_visible_dock(
Rect::new(0.0, 70.0, 1000.0, 706.0),
frame,
24.0,
0.0
));
// no dock, only menu bar
assert!(!has_visible_dock(
Rect::new(0.0, 0.0, 1000.0, 776.0),
frame,
24.0,
0.0
));
// notch inset larger than the menu
assert!(!has_visible_dock(
Rect::new(0.0, 0.0, 1000.0, 760.0),
frame,
24.0,
38.0
));
}
#[test]
fn notch() {
assert!(has_notch(32.0));
assert!(!has_notch(0.0));
}
}
@@ -0,0 +1,237 @@
//! `Helpers/Extensions/NSView+Extension.swift` (the SwiftUI `Image`
//! screenshot variant is unportable).
// 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::fmt::Write as _;
use objc2::{Message, rc::Retained, runtime::AnyObject};
use objc2_app_kit::{NSImage, NSResponder, NSUserInterfaceItemIdentification, NSView};
use objc2_foundation::NSString;
fn class_name(view: &NSView) -> String {
view.class().name().to_string_lossy().into_owned()
}
pub trait NSViewExt {
/// Returns true if this view is currently in the responder chain.
fn is_in_responder_chain(&self) -> bool;
/// Take a screenshot of just this view.
fn screenshot(&self) -> Option<Retained<NSImage>>;
/// Returns the absolute root view by walking up the superview chain.
fn root_view(&self) -> Retained<NSView>;
/// Checks if a view contains another view in its hierarchy.
fn contains_view(&self, view: &NSView) -> bool;
/// Checks if the view contains the given class in its hierarchy.
fn contains_class_name(&self, name: &str) -> bool;
/// Finds the superview with the given class name.
fn first_superview_with_class_name(&self, name: &str) -> Option<Retained<NSView>>;
/// Recursively finds and returns the first descendant view that has the given class name.
fn first_descendant_with_class_name(&self, name: &str) -> Option<Retained<NSView>>;
/// Recursively finds and returns descendant views that have the given class name.
fn descendants_with_class_name(&self, name: &str) -> Vec<Retained<NSView>>;
/// Recursively finds and returns the first descendant view that has the given identifier.
fn first_descendant_with_id(&self, id: &str) -> Option<Retained<NSView>>;
/// Finds and returns the first view with the given class name starting
/// from the absolute root of the view hierarchy. This includes private
/// views like title bar views.
fn first_view_from_root_with_class_name(&self, name: &str) -> Option<Retained<NSView>>;
/// Prints the view hierarchy from the root in a tree-like ASCII format.
///
/// I need this because the "Capture View Hierarchy" was broken under some
/// scenarios in Xcode 26 (FB17912569). But, I kept it around because it
/// might be useful to print out the view hierarchy without halting the
/// program.
fn print_view_hierarchy(&self);
/// Returns a string representation of the view hierarchy in a tree-like format.
fn view_hierarchy_description(&self, indent: &str, is_last: bool) -> String;
}
impl NSViewExt for NSView {
fn is_in_responder_chain(&self) -> bool {
let mut responder: Option<Retained<NSResponder>> =
self.window().and_then(|w| w.firstResponder());
while let Some(current) = responder {
if std::ptr::eq(
(&*current as *const NSResponder).cast::<AnyObject>(),
(self as *const NSView).cast::<AnyObject>(),
) {
return true;
}
// SAFETY: plain getter on a live responder.
responder = unsafe { current.nextResponder() };
}
false
}
fn screenshot(&self) -> Option<Retained<NSImage>> {
use objc2::AnyThread;
let bounds = self.bounds();
let bitmap_rep = self.bitmapImageRepForCachingDisplayInRect(bounds)?;
self.cacheDisplayInRect_toBitmapImageRep(bounds, &bitmap_rep);
let image = NSImage::initWithSize(NSImage::alloc(), bounds.size);
image.addRepresentation(&bitmap_rep);
Some(image)
}
fn root_view(&self) -> Retained<NSView> {
let mut root = self.retain();
// SAFETY: plain getter on a live view.
while let Some(superview) = unsafe { root.superview() } {
root = superview;
}
root
}
fn contains_view(&self, view: &NSView) -> bool {
if std::ptr::eq(self, view) {
return true;
}
self.subviews().iter().any(|s| s.contains_view(view))
}
fn contains_class_name(&self, name: &str) -> bool {
if class_name(self) == name {
return true;
}
self.subviews().iter().any(|s| s.contains_class_name(name))
}
fn first_superview_with_class_name(&self, name: &str) -> Option<Retained<NSView>> {
// SAFETY: plain getter on a live view.
let superview = unsafe { self.superview() }?;
if class_name(&superview) == name {
return Some(superview);
}
superview.first_superview_with_class_name(name)
}
fn first_descendant_with_class_name(&self, name: &str) -> Option<Retained<NSView>> {
for subview in self.subviews() {
if class_name(&subview) == name {
return Some(subview);
} else if let Some(found) = subview.first_descendant_with_class_name(name) {
return Some(found);
}
}
None
}
fn descendants_with_class_name(&self, name: &str) -> Vec<Retained<NSView>> {
let mut result = Vec::new();
for subview in self.subviews() {
if class_name(&subview) == name {
result.push(subview.clone());
}
result.extend(subview.descendants_with_class_name(name));
}
result
}
fn first_descendant_with_id(&self, id: &str) -> Option<Retained<NSView>> {
let want = NSString::from_str(id);
for subview in self.subviews() {
if subview.identifier().is_some_and(|i| *i == *want) {
return Some(subview);
} else if let Some(found) = subview.first_descendant_with_id(id) {
return Some(found);
}
}
None
}
fn first_view_from_root_with_class_name(&self, name: &str) -> Option<Retained<NSView>> {
let root = self.root_view();
// Check if the root view itself matches
if class_name(&root) == name {
return Some(root);
}
// Otherwise search descendants
root.first_descendant_with_class_name(name)
}
fn print_view_hierarchy(&self) {
let root = self.root_view();
println!("View Hierarchy from Root:");
println!("{}", root.view_hierarchy_description("", true));
}
fn view_hierarchy_description(&self, indent: &str, is_last: bool) -> String {
let mut result = String::new();
// Add the tree branch characters
result.push_str(indent);
if !indent.is_empty() {
result.push_str(if is_last { "└── " } else { "├── " });
}
// Add the class name and optional identifier
result.push_str(&class_name(self));
// Add identifier if present
if let Some(identifier) = self.identifier() {
let _ = write!(result, " (id: {identifier})");
}
// Add frame info
let f = self.frame();
let _ = write!(
result,
" [frame: (({}, {}), ({}, {}))]",
f.origin.x, f.origin.y, f.size.width, f.size.height
);
// Add visual properties
let mut properties: Vec<String> = Vec::new();
if self.isHidden() {
properties.push("hidden".into());
}
properties.push(
if self.isOpaque() {
"opaque"
} else {
"transparent"
}
.into(),
);
if self.wantsLayer() {
properties.push("layer-backed".into());
if let Some(bg) = self.layer().and_then(|l| l.backgroundColor()) {
{
let color = objc2_app_kit::NSColor::colorWithCGColor(&bg);
let rgb = color.and_then(|c| {
c.colorUsingColorSpace(&objc2_app_kit::NSColorSpace::deviceRGBColorSpace())
});
if let Some(rgb) = rgb {
properties.push(format!(
"bg:rgba({:.0},{:.0},{:.0},{:.2})",
rgb.redComponent() * 255.0,
rgb.greenComponent() * 255.0,
rgb.blueComponent() * 255.0,
rgb.alphaComponent()
));
} else {
properties.push(format!("bg:{bg:?}"));
}
}
}
}
let _ = writeln!(result, " [{}]", properties.join(", "));
// Process subviews
let subviews = self.subviews();
let count = subviews.len();
for (index, subview) in subviews.iter().enumerate() {
let is_last_subview = index == count - 1;
let new_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
result.push_str(&subview.view_hierarchy_description(&new_indent, is_last_subview));
}
result
}
}
@@ -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
}
@@ -0,0 +1,60 @@
//! The frame math of `NSWindow.constrainToScreen()`
//! (`Helpers/Extensions/NSWindow+Extension.swift`).
use crate::helpers::geometry::Rect;
/// The frame `window_frame` constrained to `visible_frame` in size and
/// origin, or `None` if it already is (no `setFrame` needed).
#[must_use]
pub fn constrained_frame(window_frame: Rect, visible_frame: Rect) -> Option<Rect> {
let mut f = window_frame;
f.size.width = f.size.width.min(visible_frame.size.width);
f.size.height = f.size.height.min(visible_frame.size.height);
f.origin.x = visible_frame
.min_x()
.max(f.origin.x.min(visible_frame.max_x() - f.width()));
f.origin.y = visible_frame
.min_y()
.max(f.origin.y.min(visible_frame.max_y() - f.height()));
(f != window_frame).then_some(f)
}
/// `cgWindowId`: the window number if it has a window device (> 0).
#[must_use]
pub fn cg_window_id(window_number: isize) -> Option<u32> {
if window_number > 0 {
u32::try_from(window_number).ok()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
const V: Rect = Rect::new(0.0, 0.0, 1000.0, 800.0);
#[test]
fn already_inside() {
assert!(constrained_frame(Rect::new(10.0, 10.0, 200.0, 200.0), V).is_none());
}
#[test]
fn shrinks_and_moves() {
let f = constrained_frame(Rect::new(-50.0, 700.0, 2000.0, 100.0), V).unwrap();
assert_eq!(f, Rect::new(0.0, 700.0, 1000.0, 100.0));
let f = constrained_frame(Rect::new(900.0, 750.0, 200.0, 200.0), V).unwrap();
assert_eq!(f, Rect::new(800.0, 600.0, 200.0, 200.0));
}
#[test]
fn window_ids() {
assert_eq!(cg_window_id(0), None);
assert_eq!(cg_window_id(-1), None);
assert_eq!(cg_window_id(42), Some(42));
}
}
@@ -0,0 +1,80 @@
//! `Helpers/Extensions/NSWorkspace+Extension.swift`.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use std::{ffi::c_void, ptr::NonNull};
use objc2::rc::Retained;
use objc2_app_kit::NSWorkspace;
use objc2_core_foundation::CFString;
use objc2_foundation::NSURL;
use super::uti;
#[link(name = "CoreServices", kind = "framework")]
unsafe extern "C" {
/// Returns a +1 retained CFURL (toll-free bridged with NSURL) or null.
fn LSCopyDefaultApplicationURLForContentType(
content_type: &CFString,
role_mask: u32,
out_error: *mut c_void,
) -> *mut c_void;
}
/// `LSRolesMask.all`.
const LS_ROLES_ALL: u32 = 0xFFFF_FFFF;
pub trait NSWorkspaceExt {
/// The URL of the default text editor application, if any.
fn default_text_editor(&self) -> Option<Retained<NSURL>>;
/// The URL of the default terminal (Unix Executable) application, if any.
fn default_terminal(&self) -> Option<Retained<NSURL>>;
/// The URL of the default application for opening files with the
/// specified content type (UTI identifier).
fn default_application_url_for_content_type(
&self,
content_type: &str,
) -> Option<Retained<NSURL>>;
/// The URL of the default application for opening files with the
/// specified file extension.
fn default_application_url_for_extension(&self, ext: &str) -> Option<Retained<NSURL>>;
}
impl NSWorkspaceExt for NSWorkspace {
fn default_text_editor(&self) -> Option<Retained<NSURL>> {
self.default_application_url_for_content_type(uti::PLAIN_TEXT)
}
fn default_terminal(&self) -> Option<Retained<NSURL>> {
self.default_application_url_for_content_type(uti::UNIX_EXECUTABLE)
}
#[allow(unsafe_code)]
fn default_application_url_for_content_type(
&self,
content_type: &str,
) -> Option<Retained<NSURL>> {
let content_type = CFString::from_str(content_type);
// SAFETY: valid CFString; a null error out-parameter is allowed.
let raw = unsafe {
LSCopyDefaultApplicationURLForContentType(
&content_type,
LS_ROLES_ALL,
std::ptr::null_mut(),
)
};
let raw = NonNull::new(raw)?;
// SAFETY: the +1 retained CFURL is toll-free bridged to NSURL and we
// adopt that reference.
unsafe { Retained::from_raw(raw.as_ptr().cast::<NSURL>()) }
}
fn default_application_url_for_extension(&self, ext: &str) -> Option<Retained<NSURL>> {
let uti = uti::identifier_for_extension(ext)?;
self.default_application_url_for_content_type(&uti)
}
}
@@ -0,0 +1,17 @@
//! `Helpers/Extensions/ObjectIdentifier+Extension.swift`.
/// `ObjectIdentifier.hexString`: the identity (object address) in lowercase
/// hex without prefix.
#[must_use]
pub fn hex_string(identity: usize) -> String {
format!("{identity:x}")
}
#[cfg(test)]
mod tests {
#[test]
fn hex() {
assert_eq!(super::hex_string(255), "ff");
assert_eq!(super::hex_string(0), "0");
}
}
@@ -0,0 +1,34 @@
//! `Helpers/Extensions/Optional+Extension.swift`.
use std::ffi::{CString, c_char};
/// `Optional<String>.withCString`: run `body` with a NUL terminated pointer,
/// or null for `None`.
pub fn with_c_string<T>(s: Option<&str>, body: impl FnOnce(*const c_char) -> T) -> T {
match s {
Some(s) => {
let end = s.bytes().position(|b| b == 0).unwrap_or(s.len());
let c = CString::new(&s.as_bytes()[..end]).unwrap_or_default();
body(c.as_ptr())
}
None => body(std::ptr::null()),
}
}
#[cfg(test)]
mod tests {
use std::ffi::CStr;
use super::*;
#[test]
fn some_and_none() {
let got = with_c_string(Some("hi"), |p| {
// SAFETY: p is a valid NUL terminated string for the call.
#[allow(unsafe_code)]
unsafe { CStr::from_ptr(p) }.to_str().unwrap().to_string()
});
assert_eq!(got, "hi");
assert!(with_c_string(None, <*const c_char>::is_null));
}
}
@@ -0,0 +1,152 @@
//! The color math of `Helpers/Extensions/OSColor+Extension.swift` and
//! `NSColor+Extension.swift` on plain sRGB / HSB components. The AppKit
//! wrappers (colorspace conversion, `NSColor` construction) live in
//! `ns_color`.
/// `OSColor.luminance` for sRGB components.
#[must_use]
pub fn luminance(r: f64, g: f64, b: f64) -> f64 {
(0.299 * r) + (0.587 * g) + (0.114 * b)
}
/// `OSColor.isLightColor`.
#[must_use]
pub fn is_light_color(r: f64, g: f64, b: f64) -> bool {
luminance(r, g, b) > 0.5
}
/// `OSColor.hexString` (`#RRGGBB`, components truncated like `Int(x * 255)`).
#[must_use]
pub fn hex_string(r: f64, g: f64, b: f64) -> String {
format!(
"#{:02X}{:02X}{:02X}",
(r * 255.0) as i64,
(g * 255.0) as i64,
(b * 255.0) as i64
)
}
/// `OSColor(hex:)`: returns `(red, green, blue, alpha)` in `0.0..=1.0`.
///
/// Mirrors the Swift: whitespace trimmed, one leading `#` removed, exactly 6
/// or 8 characters, parsed with `Scanner.scanHexInt64` (optional `0x` prefix,
/// stops at the first non-hex character, fails if no digit was scanned).
#[must_use]
pub fn parse_hex(hex: &str) -> Option<(f64, f64, f64, f64)> {
let mut cleaned = hex.trim();
if let Some(rest) = cleaned.strip_prefix('#') {
cleaned = rest;
}
let count = cleaned.chars().count();
if count != 6 && count != 8 {
return None;
}
let digits = cleaned
.strip_prefix("0x")
.or_else(|| cleaned.strip_prefix("0X"))
.unwrap_or(cleaned);
let end = digits
.find(|c: char| !c.is_ascii_hexdigit())
.unwrap_or(digits.len());
if end == 0 {
return None;
}
let n = u64::from_str_radix(&digits[..end], 16).ok()?;
let c = |v: u64| v as f64 / 255.0;
if count == 8 {
Some((
c((n & 0x00FF_0000) >> 16),
c((n & 0x0000_FF00) >> 8),
c(n & 0xFF),
c((n & 0xFF00_0000) >> 24),
))
} else {
Some((
c((n & 0xFF_0000) >> 16),
c((n & 0x00_FF00) >> 8),
c(n & 0xFF),
1.0,
))
}
}
/// `OSColor.darken(by:)`: the new brightness.
#[must_use]
pub fn darkened_brightness(brightness: f64, amount: f64) -> f64 {
(brightness * (1.0 - amount)).min(1.0)
}
/// `NSColor.adjustingSaturation(by:)`: the new saturation, clamped to [0, 1].
#[must_use]
pub fn adjusted_saturation(saturation: f64, factor: f64) -> f64 {
(saturation * factor).clamp(0.0, 1.0)
}
/// `NSColor.distance(to:)`: weighted Euclidean distance in RGB space (the
/// human eye is more sensitive to green).
#[must_use]
pub fn distance(a: (f64, f64, f64), b: (f64, f64, f64)) -> f64 {
let dr = a.0 - b.0;
let dg = a.1 - b.1;
let db = a.2 - b.2;
(2.0 * dr * dr + 4.0 * dg * dg + 3.0 * db * db).sqrt()
}
/// `OSColor(ghostty:)`: 8-bit components to `0.0..=1.0`.
#[must_use]
pub fn from_ghostty(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
(
f64::from(r) / 255.0,
f64::from(g) / 255.0,
f64::from(b) / 255.0,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn luminance_and_light() {
assert!(is_light_color(1.0, 1.0, 1.0));
assert!(!is_light_color(0.0, 0.0, 0.0));
assert!((luminance(1.0, 0.0, 0.0) - 0.299).abs() < 1e-9);
}
#[test]
fn hex_roundtrip() {
assert_eq!(hex_string(1.0, 0.0, 0.5), "#FF007F");
let (r, g, b, a) = parse_hex("#FF8000").unwrap();
assert_eq!((r, a), (1.0, 1.0));
assert!((g - 128.0 / 255.0).abs() < 1e-9 && b == 0.0);
}
#[test]
fn parse_hex_variants() {
assert_eq!(parse_hex(" #80FF0000 ").unwrap().3, 128.0 / 255.0);
assert_eq!(parse_hex("80FF0000").unwrap().0, 1.0);
assert!(parse_hex("#12345").is_none());
assert!(parse_hex("#1234567").is_none());
assert!(parse_hex("ZZZZZZ").is_none());
// scanner semantics: stops at the first non-hex character
assert!(parse_hex("FF00ZZ").is_some());
}
#[test]
fn darken_saturation_distance() {
assert!((darkened_brightness(0.5, 0.4) - 0.3).abs() < 1e-9);
assert_eq!(darkened_brightness(2.0, -0.1), 1.0);
assert_eq!(adjusted_saturation(0.8, 2.0), 1.0);
assert_eq!(adjusted_saturation(0.8, -1.0), 0.0);
assert_eq!(distance((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)), 0.0);
assert!((distance((0.0, 0.0, 0.0), (1.0, 0.0, 0.0)) - 2f64.sqrt()).abs() < 1e-9);
}
#[test]
fn ghostty_color() {
assert_eq!(from_ghostty(255, 0, 51), (1.0, 0.0, 0.2));
}
}
@@ -0,0 +1,120 @@
//! `Helpers/Extensions/String+Extension.swift`.
use std::path::PathBuf;
/// `String.truncate(length:trailing:)`: counts Characters (extended grapheme
/// clusters in Swift; Unicode scalars here, which is identical for the text
/// this is used on).
#[must_use]
pub fn truncate(s: &str, length: usize, trailing: &str) -> String {
let trailing_len = trailing.chars().count();
let count = s.chars().count();
if length <= trailing_len || s.is_empty() || count <= length {
return s.to_string();
}
let max_length = length - trailing_len;
let mut out: String = s.chars().take(max_length).collect();
out.push_str(trailing);
out
}
/// `truncate(length:)` with the default `"…"` trailing text.
#[must_use]
pub fn truncate_default(s: &str, length: usize) -> String {
truncate(s, length, "…")
}
/// `abbreviatedPath`: the path with the home directory abbreviated as `~`.
#[must_use]
pub fn abbreviated_path(path: &str, home: &str) -> String {
match path.strip_prefix(home) {
Some(rest) => format!("~{rest}"),
None => path.to_string(),
}
}
/// `String.temporaryFile(_:)`: write the string to `<tmp>/<filename>.txt`
/// (errors are ignored like `try?`) and return the URL path.
#[must_use]
pub fn temporary_file(contents: &str, temp_dir: &std::path::Path, filename: &str) -> PathBuf {
let mut path = temp_dir.join(filename);
path.set_extension("txt");
let _ = std::fs::write(&path, contents);
path
}
/// `String.fourCharCode`: a four-character ASCII string as a big-endian
/// `FourCharCode` (`UInt32`).
#[must_use]
pub fn four_char_code(s: &str) -> u32 {
debug_assert!(
s.chars().count() <= 4,
"FourCharCode string must be at most 4 characters"
);
s.bytes()
.take(4)
.fold(0u32, |acc, b| (acc << 8) | u32::from(b))
}
/// The home-dependent wrapper (`FileManager.homeDirectoryForCurrentUser`).
#[must_use]
pub fn abbreviated_path_for_current_user(path: &str) -> String {
let home = std::env::var("HOME").unwrap_or_default();
abbreviated_path(path, &home)
}
pub trait StringExt {
fn truncate(&self, length: usize) -> String;
fn abbreviated_path(&self) -> String;
fn four_char_code(&self) -> u32;
}
impl StringExt for str {
fn truncate(&self, length: usize) -> String {
truncate_default(self, length)
}
fn abbreviated_path(&self) -> String {
abbreviated_path_for_current_user(self)
}
fn four_char_code(&self) -> u32 {
four_char_code(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_cases() {
assert_eq!(truncate_default("hello", 10), "hello");
assert_eq!(truncate_default("hello world", 8), "hello w…");
assert_eq!(truncate_default("hello", 5), "hello");
assert_eq!(truncate_default("", 3), "");
// maxLength <= 0 returns self
assert_eq!(truncate("abcdef", 1, "…"), "abcdef");
assert_eq!(truncate("abcdef", 3, ".."), "a..");
}
#[test]
fn abbreviates_home() {
assert_eq!(abbreviated_path("/Users/x/dev", "/Users/x"), "~/dev");
assert_eq!(abbreviated_path("/opt/x", "/Users/x"), "/opt/x");
}
#[test]
fn four_cc() {
assert_eq!(four_char_code("why?"), 0x7768_793F);
assert_eq!(four_char_code("ab"), 0x6162);
assert_eq!(four_char_code(""), 0);
}
#[test]
fn temp_file_written() {
let dir = std::env::temp_dir();
let p = temporary_file("data", &dir, "ghostty-macos-test-temp");
assert_eq!(p.extension().unwrap(), "txt");
assert_eq!(std::fs::read_to_string(&p).unwrap(), "data");
let _ = std::fs::remove_file(p);
}
}
@@ -0,0 +1,25 @@
//! `Helpers/Extensions/UndoManager+Extension.swift`.
use objc2_foundation::NSUndoManager;
pub trait UndoManagerExt {
/// A Boolean value that indicates whether the undo manager is currently
/// performing either an undo or redo operation.
fn is_undoing_or_redoing(&self) -> bool;
/// Temporarily disables undo registration while executing the provided
/// handler, so operations aren't recorded in the undo stack.
fn disable_undo_registration_during(&self, handler: impl FnOnce());
}
impl UndoManagerExt for NSUndoManager {
fn is_undoing_or_redoing(&self) -> bool {
self.isUndoing() || self.isRedoing()
}
fn disable_undo_registration_during(&self, handler: impl FnOnce()) {
self.disableUndoRegistration();
handler();
self.enableUndoRegistration();
}
}
@@ -0,0 +1,58 @@
//! `Helpers/Extensions/UserDefaults+Extension.swift`.
use objc2::{AnyThread, rc::Retained};
use objc2_foundation::{NSString, NSUserDefaults};
/// `UserDefaults.ghosttySuite`: only set in debug builds from
/// `GHOSTTY_USER_DEFAULTS_SUITE`.
#[must_use]
pub fn ghostty_suite() -> Option<String> {
if cfg!(debug_assertions) {
std::env::var("GHOSTTY_USER_DEFAULTS_SUITE").ok()
} else {
None
}
}
/// `UserDefaults.ghostty`: the suite if configured, else the standard defaults.
#[must_use]
pub fn ghostty() -> Retained<NSUserDefaults> {
ghostty_suite()
.and_then(|suite| {
NSUserDefaults::initWithSuiteName(
NSUserDefaults::alloc(),
Some(&NSString::from_str(&suite)),
)
})
.unwrap_or_else(NSUserDefaults::standardUserDefaults)
}
/// `UserDefaults.ghostty.register(defaults: [...])` with boolean values.
pub fn register_defaults(defaults: &[(&str, bool)]) {
use objc2::runtime::AnyObject;
use objc2_foundation::{NSDictionary, NSNumber};
let keys: Vec<Retained<NSString>> = defaults
.iter()
.map(|(k, _)| NSString::from_str(k))
.collect();
let values: Vec<Retained<NSNumber>> = defaults
.iter()
.map(|(_, v)| NSNumber::new_bool(*v))
.collect();
let key_refs: Vec<&NSString> = keys.iter().map(|k| &**k).collect();
let value_refs: Vec<&AnyObject> = values.iter().map(|v| &***v as &AnyObject).collect();
let dict = NSDictionary::from_slices(&key_refs, &value_refs);
// SAFETY: the dictionary holds NSNumbers keyed by NSStrings (a plist).
#[allow(unsafe_code)]
unsafe {
ghostty().registerDefaults(&dict);
}
}
/// `UserDefaults.ghostty.removePersistentDomain(forName: suite)` when a
/// suite is configured (debug builds; see [`ghostty_suite`]).
pub fn remove_persistent_domain_for_ghostty_suite() {
if let Some(suite) = ghostty_suite() {
ghostty().removePersistentDomainForName(&NSString::from_str(&suite));
}
}
@@ -0,0 +1,58 @@
//! Uniform type identifier lookups shared by `NSPasteboard+Extension` and
//! `NSWorkspace+Extension` (Swift uses `UTType(mimeType:)`,
//! `UTType(filenameExtension:)`, `UTType.plainText`, `.unixExecutable`).
//!
//! `UniformTypeIdentifiers.UTType` is a Swift-first API; the CoreServices C
//! functions below give the same identifiers.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use objc2_core_foundation::{CFRetained, CFString};
#[link(name = "CoreServices", kind = "framework")]
unsafe extern "C" {
static kUTTagClassFilenameExtension: &'static CFString;
static kUTTagClassMIMEType: &'static CFString;
fn UTTypeCreatePreferredIdentifierForTag(
tag_class: &CFString,
tag: &CFString,
conforming_to_uti: Option<&CFString>,
) -> *const CFString;
}
/// `UTType.plainText.identifier`.
pub const PLAIN_TEXT: &str = "public.plain-text";
/// `UTType.unixExecutable.identifier`.
pub const UNIX_EXECUTABLE: &str = "public.unix-executable";
#[allow(unsafe_code)]
fn identifier_for_tag(class: &CFString, tag: &str) -> Option<String> {
let tag = CFString::from_str(tag);
// SAFETY: both arguments are valid CFStrings; the result is +1 retained
// or null and adopted by `CFRetained`.
let raw = unsafe { UTTypeCreatePreferredIdentifierForTag(class, &tag, None) };
let raw = std::ptr::NonNull::new(raw.cast_mut())?;
// SAFETY: see above, ownership transferred.
let s: CFRetained<CFString> = unsafe { CFRetained::from_raw(raw) };
Some(s.to_string())
}
/// `UTType(filenameExtension:)?.identifier` (unknown extensions yield a
/// `dyn.*` identifier like `UTType` does).
#[allow(unsafe_code)]
#[must_use]
pub fn identifier_for_extension(ext: &str) -> Option<String> {
// SAFETY: reading an immutable constant exported by CoreServices.
identifier_for_tag(unsafe { kUTTagClassFilenameExtension }, ext)
}
/// `UTType(mimeType:)?.identifier`. Identifiers CoreServices makes up for
/// undeclared types (`dyn.*`) are reported as `None`, so callers use their
/// "unknown MIME type" fallback.
#[allow(unsafe_code)]
#[must_use]
pub fn identifier_for_mime_type(mime: &str) -> Option<String> {
// SAFETY: reading an immutable constant exported by CoreServices.
identifier_for_tag(unsafe { kUTTagClassMIMEType }, mime).filter(|id| !id.starts_with("dyn."))
}
@@ -0,0 +1,12 @@
//! `Helpers/Extensions/UUID+Extension.swift`.
use objc2::{AnyThread, rc::Retained};
use objc2_core_foundation::CFUUID;
use objc2_foundation::{NSString, NSUUID};
/// `UUID(_ cfuuid: CFUUID)`.
#[must_use]
pub fn uuid_from_cfuuid(cfuuid: &CFUUID) -> Option<Retained<NSUUID>> {
let s = CFUUID::new_string(None, Some(cfuuid))?;
NSUUID::initWithUUIDString(NSUUID::alloc(), &NSString::from_str(&s.to_string()))
}
@@ -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();
}
}
@@ -0,0 +1,182 @@
//! The platform independent parts of `Helpers/Fullscreen.swift`: the
//! [`FullscreenMode`] enum (which `Ghostty.Config` also returns) and the
//! frame / menu decisions of non-native fullscreen.
use super::geometry::Rect;
/// The fullscreen modes we support define how the fullscreen behaves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FullscreenMode {
Native,
NonNative,
NonNativeVisibleMenu,
NonNativePaddedNotch,
}
impl FullscreenMode {
/// The Swift `rawValue` (also the `Codable` encoding).
#[must_use]
pub fn raw_value(self) -> &'static str {
match self {
Self::Native => "native",
Self::NonNative => "nonNative",
Self::NonNativeVisibleMenu => "nonNativeVisibleMenu",
Self::NonNativePaddedNotch => "nonNativePaddedNotch",
}
}
/// `FullscreenMode(rawValue:)`.
#[must_use]
pub fn from_raw_value(raw: &str) -> Option<Self> {
match raw {
"native" => Some(Self::Native),
"nonNative" => Some(Self::NonNative),
"nonNativeVisibleMenu" => Some(Self::NonNativeVisibleMenu),
"nonNativePaddedNotch" => Some(Self::NonNativePaddedNotch),
_ => None,
}
}
}
/// `NonNativeFullscreen.Properties`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Properties {
pub hide_menu: bool,
pub padded_notch: bool,
}
impl Default for Properties {
fn default() -> Self {
Self {
hide_menu: true,
padded_notch: false,
}
}
}
impl Properties {
/// The properties of the non-native style for `mode` (native has none).
#[must_use]
pub fn for_mode(mode: FullscreenMode) -> Self {
match mode {
FullscreenMode::NonNativeVisibleMenu => Self {
hide_menu: false,
..Self::default()
},
FullscreenMode::NonNativePaddedNotch => Self {
padded_notch: true,
..Self::default()
},
_ => Self::default(),
}
}
}
/// `NonNativeFullscreen.fullscreenFrame`: it would make more sense to use
/// `visibleFrame` but that omits space by the dock and isn't updated until an
/// event loop tick, so the frame is computed from `screen_frame`.
#[must_use]
pub fn fullscreen_frame(
screen_frame: Rect,
menu_bar_hidden: bool,
menu_bar_height: f64,
padded_notch: bool,
safe_area_top: f64,
) -> Rect {
let mut frame = screen_frame;
if !menu_bar_hidden {
// We need to subtract the menu height since we're still showing it.
frame.size.height -= menu_bar_height;
} else if padded_notch {
// We are hiding the menu, we may need to avoid the notch.
frame.size.height -= safe_area_top;
}
frame
}
/// `SavedState.menu`: hide the menu only if this window is not on any
/// fullscreen space (fullscreen spaces already hide the menu; inserting and
/// removing the presentation option causes issues, see #7075).
///
/// `spaces` is `(is_active_space, is_fullscreen_space)` per space of the window.
#[must_use]
pub fn can_hide_menu(spaces: &[(bool, bool)], active_is_fullscreen: bool) -> bool {
if spaces.iter().any(|&(is_active, _)| is_active) {
!active_is_fullscreen
} else {
spaces.iter().all(|&(_, fullscreen)| !fullscreen)
}
}
/// Where a non-native-fullscreen window re-enters its tab group on exit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabReinsert {
/// Before ("below") the first window of the group.
BelowFirst,
/// After ("above") the window at this index.
AboveIndex(usize),
/// Below the last window.
BelowLast,
}
/// `exit()`'s placement: `tab_index` was the saved index, `count` the current
/// number of windows in the (non-empty) group.
#[must_use]
pub fn tab_reinsert(tab_index: usize, count: usize) -> TabReinsert {
if tab_index == 0 {
TabReinsert::BelowFirst
} else if tab_index <= count {
TabReinsert::AboveIndex(tab_index - 1)
} else {
TabReinsert::BelowLast
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_values_roundtrip() {
for m in [
FullscreenMode::Native,
FullscreenMode::NonNative,
FullscreenMode::NonNativeVisibleMenu,
FullscreenMode::NonNativePaddedNotch,
] {
assert_eq!(FullscreenMode::from_raw_value(m.raw_value()), Some(m));
}
assert_eq!(FullscreenMode::from_raw_value("x"), None);
}
#[test]
fn properties_per_mode() {
assert!(Properties::for_mode(FullscreenMode::NonNative).hide_menu);
assert!(!Properties::for_mode(FullscreenMode::NonNativeVisibleMenu).hide_menu);
assert!(Properties::for_mode(FullscreenMode::NonNativePaddedNotch).padded_notch);
}
#[test]
fn frame_subtracts_menu_or_notch() {
let f = Rect::new(0.0, 0.0, 1000.0, 800.0);
assert_eq!(fullscreen_frame(f, false, 24.0, true, 32.0).height(), 776.0);
assert_eq!(fullscreen_frame(f, true, 24.0, true, 32.0).height(), 768.0);
assert_eq!(fullscreen_frame(f, true, 24.0, false, 32.0).height(), 800.0);
}
#[test]
fn menu_hiding_decision() {
assert!(can_hide_menu(&[], false));
assert!(!can_hide_menu(&[(true, true)], true));
assert!(can_hide_menu(&[(true, false), (false, true)], false));
assert!(!can_hide_menu(&[(false, true)], false));
assert!(can_hide_menu(&[(false, false)], false));
}
#[test]
fn tab_positions() {
assert_eq!(tab_reinsert(0, 3), TabReinsert::BelowFirst);
assert_eq!(tab_reinsert(2, 3), TabReinsert::AboveIndex(1));
assert_eq!(tab_reinsert(4, 3), TabReinsert::BelowLast);
}
}
@@ -0,0 +1,63 @@
//! Plain `CGPoint`/`CGSize`/`CGRect` stand-ins so frame math (NSScreen,
//! NSWindow, LastWindowPosition, fullscreen frames) is testable without
//! AppKit. Conversions to/from the real types live next to the AppKit code.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Size {
pub width: f64,
pub height: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Rect {
pub origin: Point,
pub size: Size,
}
impl Rect {
#[must_use]
pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
Self {
origin: Point { x, y },
size: Size { width, height },
}
}
#[must_use]
pub fn min_x(&self) -> f64 {
self.origin.x
}
#[must_use]
pub fn min_y(&self) -> f64 {
self.origin.y
}
#[must_use]
pub fn max_x(&self) -> f64 {
self.origin.x + self.size.width
}
#[must_use]
pub fn max_y(&self) -> f64 {
self.origin.y + self.size.height
}
#[must_use]
pub fn width(&self) -> f64 {
self.size.width
}
#[must_use]
pub fn height(&self) -> f64 {
self.size.height
}
/// `CGRect.contains(_ rect:)`.
#[must_use]
pub fn contains_rect(&self, other: &Rect) -> bool {
other.min_x() >= self.min_x()
&& other.max_x() <= self.max_x()
&& other.min_y() >= self.min_y()
&& other.max_y() <= self.max_y()
}
}
@@ -0,0 +1,37 @@
//! `Helpers/KeyboardLayout.swift`.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use std::{ffi::c_void, ptr::NonNull};
use objc2_core_foundation::{CFRetained, CFString};
#[link(name = "Carbon", kind = "framework")]
unsafe extern "C" {
fn TISCopyCurrentKeyboardInputSource() -> *mut c_void;
fn TISGetInputSourceProperty(source: *mut c_void, key: &CFString) -> *const c_void;
static kTISPropertyInputSourceID: &'static CFString;
}
pub struct KeyboardLayout;
impl KeyboardLayout {
/// Return a string ID of the current keyboard input source.
#[must_use]
#[allow(unsafe_code)]
pub fn id() -> Option<String> {
// SAFETY: TISCopyCurrentKeyboardInputSource returns a +1 retained
// CFTypeRef or null; `CFRetained` takes ownership (released on drop).
// TISGetInputSourceProperty returns a borrowed CFString valid while
// the source is alive, which we copy into a Rust String before then.
unsafe {
let raw = NonNull::new(TISCopyCurrentKeyboardInputSource())?;
let source: CFRetained<CFString> = CFRetained::from_raw(raw.cast());
let ptr = TISGetInputSourceProperty(raw.as_ptr(), kTISPropertyInputSourceID);
let id = (!ptr.is_null()).then(|| (*ptr.cast::<CFString>()).to_string());
drop(source);
id
}
}
}
@@ -0,0 +1,134 @@
//! Rust stand-ins for the SwiftUI value types the Swift app uses to carry key
//! equivalents (`KeyEquivalent`, `EventModifiers`, `KeyboardShortcut`) plus
//! `Helpers/Extensions/KeyboardShortcut+Extension.swift` and
//! `EventModifiers+Extension.swift` (the `NSEvent.ModifierFlags` conversions
//! live in `helpers::extensions` because they need AppKit types).
//!
//! The values match SwiftUI's (`KeyEquivalent.upArrow` is `NSUpArrowFunctionKey`
//! etc.) so a menu item's `keyEquivalent` is the same string as in Swift.
use std::fmt;
use bitflags::bitflags;
/// `SwiftUI.KeyEquivalent`: a single character.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct KeyEquivalent {
pub character: char,
}
impl KeyEquivalent {
pub const UP_ARROW: Self = Self::new('\u{F700}');
pub const DOWN_ARROW: Self = Self::new('\u{F701}');
pub const LEFT_ARROW: Self = Self::new('\u{F702}');
pub const RIGHT_ARROW: Self = Self::new('\u{F703}');
pub const ESCAPE: Self = Self::new('\u{1B}');
pub const RETURN: Self = Self::new('\r');
pub const TAB: Self = Self::new('\t');
pub const SPACE: Self = Self::new(' ');
pub const DELETE: Self = Self::new('\u{7F}');
pub const DELETE_FORWARD: Self = Self::new('\u{F728}');
pub const HOME: Self = Self::new('\u{F729}');
pub const END: Self = Self::new('\u{F72B}');
pub const PAGE_UP: Self = Self::new('\u{F72C}');
pub const PAGE_DOWN: Self = Self::new('\u{F72D}');
pub const CLEAR: Self = Self::new('\u{F739}');
#[must_use]
pub const fn new(character: char) -> Self {
Self { character }
}
}
bitflags! {
/// `SwiftUI.EventModifiers` (same raw values).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub struct EventModifiers: u32 {
const CAPS_LOCK = 1 << 1;
const SHIFT = 1 << 0;
const CONTROL = 1 << 2;
const OPTION = 1 << 3;
const COMMAND = 1 << 4;
const NUMERIC_PAD = 1 << 5;
const FUNCTION = 1 << 6;
}
}
/// `SwiftUI.KeyboardShortcut`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct KeyboardShortcut {
pub key: KeyEquivalent,
pub modifiers: EventModifiers,
}
impl KeyboardShortcut {
#[must_use]
pub const fn new(key: KeyEquivalent, modifiers: EventModifiers) -> Self {
Self { key, modifiers }
}
/// `KeyboardShortcut.keyList`: each modifier symbol then the key.
#[must_use]
pub fn key_list(&self) -> Vec<String> {
let mut result = Vec::new();
if self.modifiers.contains(EventModifiers::CONTROL) {
result.push("⌃".to_string());
}
if self.modifiers.contains(EventModifiers::OPTION) {
result.push("⌥".to_string());
}
if self.modifiers.contains(EventModifiers::SHIFT) {
result.push("⇧".to_string());
}
if self.modifiers.contains(EventModifiers::COMMAND) {
result.push("⌘".to_string());
}
let key_string = match self.key {
KeyEquivalent::RETURN => "⏎".to_string(),
KeyEquivalent::ESCAPE => "⎋".to_string(),
KeyEquivalent::DELETE => "⌫".to_string(),
KeyEquivalent::SPACE => "␣".to_string(),
KeyEquivalent::TAB => "⇥".to_string(),
KeyEquivalent::UP_ARROW => "▲".to_string(),
KeyEquivalent::DOWN_ARROW => "▼".to_string(),
KeyEquivalent::LEFT_ARROW => "◀".to_string(),
KeyEquivalent::RIGHT_ARROW => "▶".to_string(),
KeyEquivalent::PAGE_UP => "↑".to_string(),
KeyEquivalent::PAGE_DOWN => "↓".to_string(),
KeyEquivalent::HOME => "⤒".to_string(),
KeyEquivalent::END => "⤓".to_string(),
other => other.character.to_uppercase().collect(),
};
result.push(key_string);
result
}
}
impl fmt::Display for KeyboardShortcut {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.key_list().join(""))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_lists_modifiers_in_swift_order() {
let s = KeyboardShortcut::new(
KeyEquivalent::new('t'),
EventModifiers::COMMAND | EventModifiers::SHIFT | EventModifiers::CONTROL,
);
assert_eq!(s.to_string(), "⌃⇧⌘T");
}
#[test]
fn special_keys_use_symbols() {
let s = KeyboardShortcut::new(KeyEquivalent::UP_ARROW, EventModifiers::OPTION);
assert_eq!(s.key_list(), vec!["⌥", "▲"]);
let s = KeyboardShortcut::new(KeyEquivalent::DELETE, EventModifiers::empty());
assert_eq!(s.to_string(), "⌫");
}
}
@@ -0,0 +1,111 @@
//! `Helpers/LastWindowPosition.swift`: manages the persistence and
//! restoration of window positions across app launches. The frame math is in
//! `last_window_position_logic`.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use objc2::rc::Retained;
use objc2_app_kit::{NSScreen, NSWindow};
use objc2_foundation::{NSArray, NSNumber, NSPoint, NSRect, NSSize, NSString};
use super::{
extensions::user_defaults,
geometry::Rect,
last_window_position_logic::{POSITION_KEY, encode, restored_frame},
};
pub struct LastWindowPosition;
impl LastWindowPosition {
/// `LastWindowPosition.shared`.
#[must_use]
pub fn shared() -> Self {
Self
}
/// Saves the window frame. Returns false if it wasn't saved.
pub fn save(&self, window: Option<&NSWindow>) -> bool {
// We should only save the frame if the window is visible.
// This avoids overriding the previously saved one
// with the wrong one when window decorations change while creating,
// e.g. adding a toolbar affects the window's frame.
let Some(window) = window else { return false };
if !window.isVisible() {
return false;
}
let f = window.frame();
let rect = encode(&Rect::new(
f.origin.x,
f.origin.y,
f.size.width,
f.size.height,
));
let numbers: Vec<Retained<NSNumber>> = rect.iter().map(|v| NSNumber::new_f64(*v)).collect();
let array = NSArray::from_retained_slice(&numbers);
// SAFETY: an NSArray of NSNumbers is a valid property-list value.
#[allow(unsafe_code)]
unsafe {
user_defaults::ghostty()
.setObject_forKey(Some(&array), &NSString::from_str(POSITION_KEY));
}
true
}
/// Restores a previously saved window frame (or parts of it) onto the
/// given window.
///
/// - `restore_origin`: whether to restore the saved position. Pass `false`
/// when the config specifies an explicit `window-position-x`/`window-position-y`.
/// - `restore_size`: whether to restore the saved size. Pass `false` when
/// the config specifies an explicit `window-width`/`window-height`.
///
/// Returns `true` if the frame was modified, `false` if there was nothing
/// to restore.
pub fn restore(&self, window: &NSWindow, restore_origin: bool, restore_size: bool) -> bool {
if !(restore_origin || restore_size) {
return false;
}
let defaults = user_defaults::ghostty();
let Some(array) = defaults.arrayForKey(&NSString::from_str(POSITION_KEY)) else {
return false;
};
// `as? [Double]`: every element must be a number.
let mut values = Vec::new();
for o in &array {
let Some(n) = o.downcast_ref::<NSNumber>() else {
return false;
};
values.push(n.doubleValue());
}
if values.len() < 2 {
return false;
}
let screen: Option<Retained<NSScreen>> = window
.screen()
.or_else(|| NSScreen::mainScreen(super::main_thread()));
let Some(screen) = screen else { return false };
let to_rect = |r: NSRect| Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
let Some(new_frame) = restored_frame(
&values,
to_rect(window.frame()),
to_rect(screen.visibleFrame()),
restore_origin,
restore_size,
) else {
return false;
};
window.setFrame_display(
NSRect::new(
NSPoint::new(new_frame.origin.x, new_frame.origin.y),
NSSize::new(new_frame.size.width, new_frame.size.height),
),
true,
);
true
}
}
@@ -0,0 +1,93 @@
//! The frame computation of `Helpers/LastWindowPosition.swift`'s `restore`,
//! independent of AppKit.
use super::geometry::Rect;
/// `UserDefaults` key the position is stored under.
pub const POSITION_KEY: &str = "NSWindowLastPosition";
/// `[x, y, width, height]` as stored in defaults.
#[must_use]
pub fn encode(frame: &Rect) -> [f64; 4] {
[
frame.origin.x,
frame.origin.y,
frame.size.width,
frame.size.height,
]
}
/// The new frame for `window_frame` given the saved `values`, or `None` if
/// there was nothing to restore.
#[must_use]
pub fn restored_frame(
values: &[f64],
window_frame: Rect,
visible_frame: Rect,
restore_origin: bool,
restore_size: bool,
) -> Option<Rect> {
if !(restore_origin || restore_size) || values.len() < 2 {
return None;
}
let mut new_frame = window_frame;
if restore_origin {
new_frame.origin.x = values[0];
new_frame.origin.y = values[1];
}
if restore_size && values.len() >= 4 {
new_frame.size.width = values[2].min(visible_frame.width());
new_frame.size.height = values[3].min(visible_frame.height());
}
// If the new frame is not constrained to the visible screen, shift it a
// little before AppKit does so the correct size is saved beforehand.
if restore_origin && !visible_frame.contains_rect(&new_frame) {
new_frame.origin.x = visible_frame
.min_x()
.max((visible_frame.max_x() - new_frame.width()).min(new_frame.origin.x));
new_frame.origin.y = visible_frame
.min_y()
.max((visible_frame.max_y() - new_frame.height()).min(new_frame.origin.y));
}
Some(new_frame)
}
#[cfg(test)]
mod tests {
use super::*;
const VISIBLE: Rect = Rect::new(0.0, 0.0, 1000.0, 800.0);
#[test]
fn nothing_to_restore() {
let w = Rect::new(5.0, 5.0, 100.0, 100.0);
assert!(restored_frame(&[1.0, 2.0], w, VISIBLE, false, false).is_none());
assert!(restored_frame(&[1.0], w, VISIBLE, true, true).is_none());
}
#[test]
fn restores_origin_only() {
let w = Rect::new(5.0, 5.0, 100.0, 100.0);
let r = restored_frame(&[10.0, 20.0, 300.0, 300.0], w, VISIBLE, true, false).unwrap();
assert_eq!(r, Rect::new(10.0, 20.0, 100.0, 100.0));
}
#[test]
fn size_is_clamped_and_origin_shifted_onscreen() {
let w = Rect::new(0.0, 0.0, 100.0, 100.0);
let r = restored_frame(&[900.0, 700.0, 5000.0, 400.0], w, VISIBLE, true, true).unwrap();
assert_eq!(r.size.width, 1000.0);
assert_eq!(r.size.height, 400.0);
assert_eq!(r.origin.x, 0.0);
assert_eq!(r.origin.y, 400.0);
}
#[test]
fn encode_roundtrip() {
assert_eq!(encode(&Rect::new(1.0, 2.0, 3.0, 4.0)), [1.0, 2.0, 3.0, 4.0]);
}
}
+58
View File
@@ -0,0 +1,58 @@
//! `Sources/Helpers`: the generic helpers.
//!
//! SwiftUI-only helpers have no Rust/AppKit counterpart and are documented as
//! unportable in `docs/rewrite/notes/macos-app-design.md` (`Backport`,
//! `HostingWindow`, `NonDraggableHostingView`, `MetalView`, the `View` /
//! `Transferable` extensions). `Weak<T>` is `std::rc::Weak` / `objc2::rc::Weak`.
//!
//! Pure logic (string/color/frame math, counters, decisions) lives in
//! platform independent modules so it is unit-tested on every target; the
//! AppKit parts are thin `cfg(target_vendor = "apple")` wrappers around it.
#![cfg_attr(test, allow(clippy::unwrap_used))]
// Platform independent (tested on every target).
pub mod any_sort_key;
pub mod app_info;
pub mod color;
pub mod cursor_counter;
pub mod extensions;
pub mod fullscreen_logic;
pub mod geometry;
pub mod keyboard_shortcut;
pub mod last_window_position_logic;
pub mod notification_center;
pub mod published;
// AppKit.
#[cfg(target_vendor = "apple")]
pub mod codable_bridge;
#[cfg(target_vendor = "apple")]
pub mod cross_kit;
#[cfg(target_vendor = "apple")]
pub mod cursor;
#[cfg(target_vendor = "apple")]
pub mod dispatch;
#[cfg(target_vendor = "apple")]
pub mod expiring_undo_manager;
#[cfg(target_vendor = "apple")]
pub mod fullscreen;
#[cfg(target_vendor = "apple")]
pub mod keyboard_layout;
#[cfg(target_vendor = "apple")]
pub mod last_window_position;
#[cfg(target_vendor = "apple")]
pub mod objc_exception_catcher;
#[cfg(target_vendor = "apple")]
pub mod private;
#[cfg(target_vendor = "apple")]
pub mod vibrant_layer;
/// The main thread marker. Every AppKit object is created and used on the main
/// thread (the Swift app uses `@MainActor` / `DispatchQueue.main`); panics
/// elsewhere, which is a programming error.
#[cfg(target_vendor = "apple")]
#[must_use]
pub fn main_thread() -> objc2::MainThreadMarker {
objc2::MainThreadMarker::new().expect("must be called on the main thread")
}
@@ -0,0 +1,312 @@
//! The in-process equivalent of Foundation's `NotificationCenter.default` for
//! Ghostty's *own* notifications (`Notification.Name.ghosttyConfigDidChange`,
//! `Ghostty.Notification.ghosttyNewWindow`, ...).
//!
//! The Swift app posts `Foundation.Notification`s whose `object` is the sender
//! (a surface view, a controller) and whose `userInfo` is an untyped
//! dictionary of Swift values. Both ends are Rust here, so the payload keeps
//! its Rust type: [`UserInfo`] is a string-keyed map of `Rc<dyn Any>` and the
//! sender is a [`Sender`] (identity + a type-erased strong handle that
//! [`Sender::downcast_ref`] turns back into e.g. `Retained<SurfaceView>`).
//!
//! Semantics follow `NotificationCenter`:
//! * delivery is synchronous, on the posting thread (always the main thread:
//! nothing here is `Send`);
//! * an observer registered with a sender only receives notifications posted
//! with that same sender; one registered without a sender receives every
//! notification of that name;
//! * observers registered or removed while a notification is being delivered do
//! not disturb the delivery (the observer list is snapshotted; an observer
//! removed meanwhile is skipped);
//! * dropping the [`ObserverToken`] removes the observer (the equivalent of
//! `removeObserver`, and of storing a Combine `AnyCancellable`).
//!
//! AppKit's own notifications (`NSWindow.didBecomeKeyNotification`, ...) go
//! through `helpers::extensions::ns_notification::observe` instead.
use std::{any::Any, cell::RefCell, collections::HashMap, fmt, rc::Rc};
/// A notification name (`Notification.Name`); the string is the same as in the
/// Swift app (`"com.mitchellh.ghostty.configDidChange"`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Name(pub &'static str);
impl Name {
#[must_use]
pub const fn raw_value(self) -> &'static str {
self.0
}
}
/// The sender of a notification (`Notification.object`).
#[derive(Clone)]
pub struct Sender {
id: usize,
handle: Rc<dyn Any>,
}
impl Sender {
/// `id` is the object's identity (the address of the Objective-C object),
/// `handle` keeps it alive and lets receivers downcast it.
pub fn new<T: 'static>(id: usize, handle: T) -> Self {
Self {
id,
handle: Rc::new(handle),
}
}
#[must_use]
pub fn id(&self) -> usize {
self.id
}
/// The sender as `T` (the type passed to [`Sender::new`]).
#[must_use]
pub fn downcast_ref<T: 'static>(&self) -> Option<&T> {
self.handle.downcast_ref::<T>()
}
}
impl fmt::Debug for Sender {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Sender({:#x})", self.id)
}
}
/// `Notification.userInfo`.
#[derive(Clone, Default)]
pub struct UserInfo(HashMap<&'static str, Rc<dyn Any>>);
impl UserInfo {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Builder style insert.
#[must_use]
pub fn with<T: 'static>(mut self, key: &'static str, value: T) -> Self {
self.insert(key, value);
self
}
pub fn insert<T: 'static>(&mut self, key: &'static str, value: T) {
self.0.insert(key, Rc::new(value));
}
/// `userInfo?[key] as? T`.
#[must_use]
pub fn get<T: 'static>(&self, key: &str) -> Option<&T> {
self.0.get(key).and_then(|v| v.downcast_ref::<T>())
}
#[must_use]
pub fn contains_key(&self, key: &str) -> bool {
self.0.contains_key(key)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// `Foundation.Notification`.
#[derive(Clone)]
pub struct Notification {
pub name: Name,
pub object: Option<Sender>,
pub user_info: UserInfo,
}
/// An observer callback.
pub type Handler = Rc<dyn Fn(&Notification)>;
struct Observer {
id: u64,
name: Name,
object: Option<usize>,
handler: Handler,
}
#[derive(Default)]
struct Inner {
next_id: u64,
observers: Vec<Observer>,
}
/// A notification center. Use [`NotificationCenter::default_center`] for the
/// process-wide one (thread-local, i.e. main thread).
#[derive(Clone, Default)]
pub struct NotificationCenter {
inner: Rc<RefCell<Inner>>,
}
thread_local! {
static DEFAULT: NotificationCenter = NotificationCenter::default();
}
/// Removes its observer when dropped.
#[must_use = "dropping the token removes the observer"]
pub struct ObserverToken {
center: std::rc::Weak<RefCell<Inner>>,
id: u64,
}
impl ObserverToken {
/// Keep the observer registered for the life of the process
/// (`addObserver` without a matching `removeObserver`).
pub fn forget(self) {
std::mem::forget(self);
}
}
impl Drop for ObserverToken {
fn drop(&mut self) {
if let Some(inner) = self.center.upgrade() {
inner.borrow_mut().observers.retain(|o| o.id != self.id);
}
}
}
impl NotificationCenter {
/// `NotificationCenter.default`.
#[must_use]
pub fn default_center() -> Self {
DEFAULT.with(Clone::clone)
}
/// `addObserver(forName:object:queue:using:)` (always delivered inline).
pub fn add_observer(
&self,
name: Name,
object: Option<&Sender>,
handler: impl Fn(&Notification) + 'static,
) -> ObserverToken {
let mut inner = self.inner.borrow_mut();
let id = inner.next_id;
inner.next_id += 1;
inner.observers.push(Observer {
id,
name,
object: object.map(Sender::id),
handler: Rc::new(handler),
});
ObserverToken {
center: Rc::downgrade(&self.inner),
id,
}
}
/// `post(name:object:userInfo:)`.
pub fn post(&self, name: Name, object: Option<Sender>, user_info: UserInfo) {
self.post_notification(&Notification {
name,
object,
user_info,
});
}
pub fn post_notification(&self, n: &Notification) {
let snapshot: Vec<(u64, Handler)> = self
.inner
.borrow()
.observers
.iter()
.filter(|o| {
o.name == n.name
&& match (o.object, &n.object) {
(None, _) => true,
(Some(want), Some(got)) => want == got.id(),
(Some(_), None) => false,
}
})
.map(|o| (o.id, o.handler.clone()))
.collect();
for (id, handler) in snapshot {
// Skip observers removed by an earlier handler of this delivery.
if self.inner.borrow().observers.iter().any(|o| o.id == id) {
handler(n);
}
}
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
const A: Name = Name("test.a");
const B: Name = Name("test.b");
#[test]
fn delivers_by_name_and_sender() {
let nc = NotificationCenter::default();
let any = Rc::new(Cell::new(0));
let only = Rc::new(Cell::new(0));
let s1 = Sender::new(1, 1u32);
let s2 = Sender::new(2, 2u32);
let _t1 = nc.add_observer(A, None, {
let any = any.clone();
move |_| any.set(any.get() + 1)
});
let _t2 = nc.add_observer(A, Some(&s1), {
let only = only.clone();
move |n| {
assert_eq!(
n.object.as_ref().and_then(|o| o.downcast_ref::<u32>()),
Some(&1)
);
only.set(only.get() + 1);
}
});
nc.post(A, Some(s1.clone()), UserInfo::new());
nc.post(A, Some(s2), UserInfo::new());
nc.post(A, None, UserInfo::new());
nc.post(B, Some(s1), UserInfo::new());
assert_eq!(any.get(), 3);
assert_eq!(only.get(), 1);
}
#[test]
fn dropping_token_removes_observer() {
let nc = NotificationCenter::default();
let hits = Rc::new(Cell::new(0));
let t = nc.add_observer(A, None, {
let hits = hits.clone();
move |_| hits.set(hits.get() + 1)
});
nc.post(A, None, UserInfo::new());
drop(t);
nc.post(A, None, UserInfo::new());
assert_eq!(hits.get(), 1);
}
#[test]
fn user_info_is_typed() {
let info = UserInfo::new().with("k", String::from("v")).with("n", 5u64);
assert_eq!(info.get::<String>("k").map(String::as_str), Some("v"));
assert_eq!(info.get::<u64>("n"), Some(&5));
assert!(info.get::<u32>("n").is_none());
assert!(info.get::<u64>("missing").is_none());
}
#[test]
fn handler_may_remove_a_later_observer_during_delivery() {
let nc = NotificationCenter::default();
let hits = Rc::new(Cell::new(0));
let victim: Rc<RefCell<Option<ObserverToken>>> = Rc::new(RefCell::new(None));
let _first = nc.add_observer(A, None, {
let victim = victim.clone();
move |_| drop(victim.borrow_mut().take())
});
*victim.borrow_mut() = Some(nc.add_observer(A, None, {
let hits = hits.clone();
move |_| hits.set(hits.get() + 1)
}));
nc.post(A, None, UserInfo::new());
assert_eq!(hits.get(), 0);
}
}
@@ -0,0 +1,64 @@
//! `Helpers/ObjCExceptionCatcher.{h,m}`: wrappers around ObjC calls that may
//! throw `NSException`s, which cannot cross into safe code.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use std::{fmt, panic::AssertUnwindSafe};
use objc2::exception;
use objc2_app_kit::{NSWindow, NSWindowOrderingMode};
use objc2_foundation::NSException;
/// The Swift version reports an `NSError` in domain `Ghostty.ObjCException`
/// (code 1, `NSLocalizedDescriptionKey` = reason, `exception_name`). In Rust
/// that is this struct (same fields).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ObjCException {
/// `exception.name` (`"exception_name"` in the NSError's userInfo).
pub name: String,
/// `exception.reason` or "Unknown Objective-C exception".
pub reason: String,
}
impl ObjCException {
pub const DOMAIN: &'static str = "Ghostty.ObjCException";
pub const CODE: isize = 1;
}
impl fmt::Display for ObjCException {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.reason)
}
}
impl std::error::Error for ObjCException {}
/// `GhosttyAddTabbedWindowSafely`: `[parent addTabbedWindow:child ordered:]`
/// catching an `NSException`.
///
/// AppKit occasionally throws NSException while adding tabbed windows, in
/// particular when creating tabs from the tab overview page since some macOS
/// update recently in 2025/2026 (unclear).
pub fn add_tabbed_window_safely(
parent: &NSWindow,
child: &NSWindow,
ordered: NSWindowOrderingMode,
) -> Result<(), ObjCException> {
exception::catch(AssertUnwindSafe(|| {
parent.addTabbedWindow_ordered(child, ordered);
}))
.map_err(|exception| {
let ns = exception.and_then(|e| e.downcast::<NSException>().ok());
ObjCException {
name: ns
.as_ref()
.map(|e| e.name().to_string())
.unwrap_or_default(),
reason: ns.and_then(|e| e.reason()).map_or_else(
|| "Unknown Objective-C exception".to_string(),
|r| r.to_string(),
),
}
})
}
@@ -0,0 +1,127 @@
//! `Helpers/Private/CGS.swift`: private CoreGraphics Spaces API.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use objc2_core_foundation::{CFArray, CFNumber, CFRetained};
pub type CGSConnectionID = i32;
pub type CGSSpaceID = usize;
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGSMainConnectionID() -> CGSConnectionID;
fn CGSGetActiveSpace(cid: CGSConnectionID) -> CGSSpaceID;
fn CGSSpaceGetType(cid: CGSConnectionID, space_id: CGSSpaceID) -> u32;
/// Returns a +1 retained CFArray (CFNumbers) or null.
fn CGSCopySpacesForWindows(
cid: CGSConnectionID,
mask: u32,
window_ids: &CFArray<CFNumber>,
) -> *const CFArray<CFNumber>;
}
/// <https://github.com/NUIKit/CGSInternal/blob/c4f6f559d624dc1cfc2bf24c8c19dbf653317fcf/CGSSpace.h#L40>
/// converted to Rust.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CGSSpaceMask(pub u32);
impl CGSSpaceMask {
pub const INCLUDES_CURRENT: Self = Self(1 << 0);
pub const INCLUDES_OTHERS: Self = Self(1 << 1);
pub const INCLUDES_USER: Self = Self(1 << 2);
pub const INCLUDES_VISIBLE: Self = Self(1 << 16);
pub const CURRENT_SPACE: Self = Self(Self::INCLUDES_USER.0 | Self::INCLUDES_CURRENT.0);
pub const OTHER_SPACES: Self = Self(Self::INCLUDES_OTHERS.0 | Self::INCLUDES_CURRENT.0);
pub const ALL_SPACES: Self =
Self(Self::INCLUDES_USER.0 | Self::INCLUDES_OTHERS.0 | Self::INCLUDES_CURRENT.0);
pub const ALL_VISIBLE_SPACES: Self = Self(Self::INCLUDES_VISIBLE.0 | Self::ALL_SPACES.0);
}
/// `CGSSpaceType`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CGSSpaceType {
User = 0,
System = 2,
Fullscreen = 4,
}
impl CGSSpaceType {
#[must_use]
pub fn from_raw(raw: u32) -> Option<Self> {
match raw {
0 => Some(Self::User),
2 => Some(Self::System),
4 => Some(Self::Fullscreen),
_ => None,
}
}
}
/// Represents a unique identifier for a macOS Space (Desktop, Fullscreen, etc).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CGSSpace {
pub raw_value: CGSSpaceID,
}
impl std::fmt::Display for CGSSpace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SpaceID({})", self.raw_value)
}
}
impl CGSSpace {
/// Returns the currently active space.
#[must_use]
#[allow(unsafe_code)]
pub fn active() -> Self {
// SAFETY: private but stable CoreGraphics calls taking/returning integers.
let space = unsafe { CGSGetActiveSpace(CGSMainConnectionID()) };
Self { raw_value: space }
}
/// List the spaces for the given window.
#[must_use]
#[allow(unsafe_code)]
pub fn list(window_id: u32, mask: CGSSpaceMask) -> Vec<Self> {
let ids = CFArray::from_retained_objects(&[CFNumber::new_i64(i64::from(window_id))]);
// SAFETY: `ids` is a valid CFArray of CFNumbers for the call; the
// returned array (if non-null) is +1 retained and adopted below.
let spaces = unsafe { CGSCopySpacesForWindows(CGSMainConnectionID(), mask.0, &ids) };
let Some(spaces) = std::ptr::NonNull::new(spaces.cast_mut()) else {
return Vec::new();
};
// SAFETY: ownership of the +1 reference is transferred.
let spaces: CFRetained<CFArray<CFNumber>> = unsafe { CFRetained::from_raw(spaces) };
spaces
.iter()
.filter_map(|n| n.as_i64())
.map(|v| Self {
raw_value: v as CGSSpaceID,
})
.collect()
}
/// `type`.
#[must_use]
#[allow(unsafe_code)]
pub fn space_type(&self) -> Option<CGSSpaceType> {
// SAFETY: integer arguments only.
let raw = unsafe { CGSSpaceGetType(CGSMainConnectionID(), self.raw_value) };
CGSSpaceType::from_raw(raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn masks() {
assert_eq!(CGSSpaceMask::ALL_SPACES.0, 0b111);
assert_eq!(CGSSpaceMask::ALL_VISIBLE_SPACES.0, (1 << 16) | 0b111);
assert_eq!(CGSSpaceMask::CURRENT_SPACE.0, 0b101);
assert_eq!(CGSSpaceType::from_raw(4), Some(CGSSpaceType::Fullscreen));
}
}
@@ -0,0 +1,75 @@
//! `Helpers/Private/Dock.swift`: private APIs to get/set the Dock state.
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
// Private API to get Dock location
fn CoreDockGetOrientationAndPinning(out_orientation: *mut i32, out_pinning: *mut i32);
// Private API to get the current Dock auto-hide state
fn CoreDockGetAutoHideEnabled() -> bool;
// Toggles the Dock's auto-hide state
fn CoreDockSetAutoHideEnabled(flag: bool);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DockOrientation {
Top = 1,
Bottom = 2,
Left = 3,
Right = 4,
}
impl DockOrientation {
#[must_use]
pub fn from_raw(raw: i32) -> Option<Self> {
match raw {
1 => Some(Self::Top),
2 => Some(Self::Bottom),
3 => Some(Self::Left),
4 => Some(Self::Right),
_ => None,
}
}
}
pub struct Dock;
impl Dock {
/// Returns the orientation of the dock or None if it can't be determined.
#[must_use]
#[allow(unsafe_code)]
pub fn orientation() -> Option<DockOrientation> {
let mut orientation = 0i32;
let mut pinning = 0i32;
// SAFETY: both out-pointers are valid for writes for the call.
unsafe { CoreDockGetOrientationAndPinning(&raw mut orientation, &raw mut pinning) };
DockOrientation::from_raw(orientation)
}
/// The dock autohide.
#[must_use]
#[allow(unsafe_code)]
pub fn auto_hide_enabled() -> bool {
// SAFETY: no arguments; plain getter.
unsafe { CoreDockGetAutoHideEnabled() }
}
#[allow(unsafe_code)]
pub fn set_auto_hide_enabled(value: bool) {
// SAFETY: plain setter taking a bool.
unsafe { CoreDockSetAutoHideEnabled(value) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn orientation_raw() {
assert_eq!(DockOrientation::from_raw(3), Some(DockOrientation::Left));
assert_eq!(DockOrientation::from_raw(0), None);
}
}
@@ -0,0 +1,4 @@
//! `Helpers/Private/{CGS,Dock}.swift`: private CoreGraphics / CoreDock APIs.
pub mod cgs;
pub mod dock;
@@ -0,0 +1,145 @@
//! The Rust equivalent of a SwiftUI `@Published` property / Combine
//! `CurrentValueSubject`: a value cell that remembers its latest value and
//! notifies subscribers after each change with the new value (Combine's
//! `$property` publisher emits in `willSet`, but the Swift call sites only use
//! the value they are handed, which is the new one).
//!
//! Single-threaded (main thread), like everything that owns AppKit objects.
//! Dropping the returned [`Subscription`] cancels it (an `AnyCancellable`).
use std::{cell::RefCell, rc::Rc};
type Callback<T> = Rc<dyn Fn(&T)>;
struct Inner<T> {
value: T,
next_id: u64,
subscribers: Vec<(u64, Callback<T>)>,
}
/// `@Published var value: T`. Cloning shares the same cell.
pub struct Published<T> {
inner: Rc<RefCell<Inner<T>>>,
}
impl<T> Clone for Published<T> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
/// Cancels the subscription when dropped.
#[must_use = "dropping the subscription cancels it"]
pub struct Subscription {
cancel: Option<Box<dyn FnOnce()>>,
}
impl Subscription {
/// Keep the subscription for the life of the cell.
pub fn forget(mut self) {
self.cancel = None;
}
}
impl Drop for Subscription {
fn drop(&mut self) {
if let Some(cancel) = self.cancel.take() {
cancel();
}
}
}
impl<T: Clone + 'static> Published<T> {
pub fn new(value: T) -> Self {
Self {
inner: Rc::new(RefCell::new(Inner {
value,
next_id: 0,
subscribers: Vec::new(),
})),
}
}
/// The current value.
#[must_use]
pub fn get(&self) -> T {
self.inner.borrow().value.clone()
}
/// Assign a new value and notify every subscriber with it.
pub fn set(&self, value: T) {
let subscribers: Vec<Callback<T>> = {
let mut inner = self.inner.borrow_mut();
inner.value = value.clone();
inner.subscribers.iter().map(|(_, cb)| cb.clone()).collect()
};
for cb in subscribers {
cb(&value);
}
}
/// Mutate in place (`value.field = x` on a `@Published` struct) and notify.
pub fn update(&self, f: impl FnOnce(&mut T)) {
let mut value = self.get();
f(&mut value);
self.set(value);
}
/// Subscribe (`$property.sink`). Like Combine's `@Published` publisher the
/// callback is first invoked with the current value.
pub fn sink(&self, f: impl Fn(&T) + 'static) -> Subscription {
let cb: Callback<T> = Rc::new(f);
let (id, current) = {
let mut inner = self.inner.borrow_mut();
let id = inner.next_id;
inner.next_id += 1;
inner.subscribers.push((id, cb.clone()));
(id, inner.value.clone())
};
cb(&current);
let weak = Rc::downgrade(&self.inner);
Subscription {
cancel: Some(Box::new(move || {
if let Some(inner) = weak.upgrade() {
inner.borrow_mut().subscribers.retain(|(i, _)| *i != id);
}
})),
}
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
#[test]
fn sink_gets_current_then_changes() {
let p = Published::new(1);
let seen = Rc::new(RefCell::new(Vec::new()));
let _s = p.sink({
let seen = seen.clone();
move |v| seen.borrow_mut().push(*v)
});
p.set(2);
p.update(|v| *v += 5);
assert_eq!(*seen.borrow(), vec![1, 2, 7]);
assert_eq!(p.get(), 7);
}
#[test]
fn cancel_on_drop() {
let p = Published::new(0);
let n = Rc::new(Cell::new(0));
let s = p.sink({
let n = n.clone();
move |_| n.set(n.get() + 1)
});
drop(s);
p.set(1);
assert_eq!(n.get(), 1);
}
}
@@ -0,0 +1,70 @@
//! `Helpers/VibrantLayer.{h,m}`.
//!
//! This layer can be used to recreate the "vibrant" appearance you see of
//! views placed inside `NSVisualEffectView`s. When a light NSAppearance is
//! active, we will use the private "plus darker" blend mode. For dark
//! appearances we use "plus lighter".
// SAFETY: every `unsafe` block in this module carries its own justification (FFI / objc2 calls).
#![allow(unsafe_code)]
use std::cell::Cell;
use objc2::{AnyThread, DefinedClass, define_class, msg_send, rc::Retained, runtime::AnyObject};
use objc2_foundation::NSString;
use objc2_quartz_core::CALayer;
/// `VibrantLayerType`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VibrantLayerType {
Light,
Dark,
}
/// The `compositingFilter` name for a layer type (`@"plusD"` / `@"plusL"`).
#[must_use]
pub fn compositing_filter_name(ty: VibrantLayerType) -> &'static str {
match ty {
VibrantLayerType::Light => "plusD",
VibrantLayerType::Dark => "plusL",
}
}
define_class!(
// SAFETY: CALayer has no subclassing requirements we violate; no Drop.
#[unsafe(super(CALayer))]
#[name = "VibrantLayer"]
#[ivars = Cell<VibrantLayerType>]
pub struct VibrantLayer;
impl VibrantLayer {
// SAFETY: matches `- (id)compositingFilter`.
#[unsafe(method_id(compositingFilter))]
fn compositing_filter(&self) -> Option<Retained<AnyObject>> {
let name = NSString::from_str(compositing_filter_name(self.ivars().get()));
// SAFETY: NSString is an object; upcasting to AnyObject is valid.
Some(unsafe { Retained::cast_unchecked::<AnyObject>(name) })
}
}
);
impl VibrantLayer {
/// `init(forAppearance:)`.
#[must_use]
pub fn init_for_appearance(ty: VibrantLayerType) -> Retained<Self> {
let this = Self::alloc().set_ivars(Cell::new(ty));
// SAFETY: `init` is the designated initializer of CALayer.
unsafe { msg_send![super(this), init] }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_names() {
assert_eq!(compositing_filter_name(VibrantLayerType::Light), "plusD");
assert_eq!(compositing_filter_name(VibrantLayerType::Dark), "plusL");
}
}
+28
View File
@@ -0,0 +1,28 @@
//! Ghostty's macOS app: a Rust/AppKit port of the Swift app in `macos/Sources`.
//!
//! The Swift app is a client of `libghostty` (`include/ghostty.h`). This port
//! drives the *same* API surface in-process by calling the Rust functions of
//! `ghostty-lib` (see [`ghostty::capi`]) with the same runtime configuration
//! (wakeup, action, clipboard, close-surface callbacks), so behaviour matches
//! the Swift app.
//!
//! Layout mirrors `Sources/`:
//!
//! | Swift | Rust |
//! |-------------------------------|---------------------------|
//! | `Sources/App/macOS` | [`app`] |
//! | `Sources/Ghostty` | [`ghostty`] |
//! | `Sources/Helpers` | [`helpers`] |
//! | `Sources/Features/<Area>` | [`features`]`::<area>` |
//!
//! Everything that touches AppKit is `cfg(target_vendor = "apple")`; on other
//! targets the crate only contains the platform independent logic (config
//! value parsing, shell quoting, menu tables, bundle metadata, ...) so it is
//! tested on Linux and the workspace stays green. The AppKit code can only be
//! compile-checked off macOS (`scripts/macos-crosscheck.sh`); see
//! `docs/rewrite/notes/macos-app-design.md`.
pub mod app;
pub mod features;
pub mod ghostty;
pub mod helpers;
+13
View File
@@ -0,0 +1,13 @@
//! `ghostty-macos-app`: the executable inside `Ghostty.app` (Swift `main.swift`).
//! An empty stub on non-Apple targets.
#[cfg(target_vendor = "apple")]
fn main() {
ghostty_macos::app::entry::run();
}
#[cfg(not(target_vendor = "apple"))]
fn main() {
eprintln!("ghostty-macos-app only runs on macOS");
std::process::exit(1);
}
+1 -1
View File
@@ -89,7 +89,7 @@ Only entries with an observable behavioural difference are listed.
| Area | Zig/Ghostty behaviour | Rust behaviour | Where | Commit |
|------|-----------------------|----------------|-------|--------|
| Swift macOS app | Ships `macos/` | Not ported; the Rust side provides the `libghostty` C API, Metal/CoreText code, XCFramework script. Only compile-checked (`just check-macos`), never run (no macOS here). | `ARCHITECTURE.md` (Out of scope), `notes/macos-M1*.md`, `final-Z4.md` | bab49ee, 75bf604, 231ee35 |
| Swift macOS app | Ships `macos/` (Swift/SwiftUI/xib, Xcode, Sparkle, App Intents) | Ported to Rust/AppKit in `crates/ghostty-macos` (programmatic AppKit instead of xib/SwiftUI; menu tables generated from `MainMenu.xib`; bundle assembled by `scripts/macos-bundle.sh`). Only compile-checked (`just check-macos`), never run (no macOS here). Deviations (menu quirks kept, no nib loading, config background fallback, byte-length URL in `open_config`, non-compatible `CodableBridge` archives, ...) are listed in `notes/macos-app-MA1.md`; App Intents is unportable from Rust (`notes/macos-app-design.md`). The Rust side also still provides the `libghostty` C API for the unchanged Swift app. | `notes/macos-app-design.md`, `notes/macos-app-MA1.md`, `ARCHITECTURE.md`, `notes/macos-M1*.md` | (uncommitted) |
| Windows (and wasm natively) | Native targets | Windows is compile-checked only (`just check-windows`), never run. wasm is built and its Node smoke/ABI tests run (`just check-wasm`, `scripts/wasm-check.sh`), but not in a real browser. | `notes/windows-WIN.md`, `notes/wasm-W1.md` | 76771be, d89691b |
| Crash reporting | Sentry with Breakpad on macOS, `SIGSEGV` handlers, local DB/cache, sessions, init thread | In-process reporter: `.ghosttycrash` envelopes in `$XDG_STATE_HOME/ghostty/crash`, panic hook and signal handler, minidumps on macOS/Windows. No Sentry DB/cache dir, no sessions, no init thread; reports are written on any panic; the crash dir is resolved at init; the signal path writes no attachment. | `notes/crash-CR.md`, `ARCHITECTURE.md` (Scope changes) | b3145ad, 72bfac1 |
| Surface copy-on-select on release | `setSelection` returns early when the new selection equals the old one | Left-release copies directly via `copy_on_select`, because the early return would never copy | `notes/ghostty-core.md` | ad76ae2 |
+45
View File
@@ -0,0 +1,45 @@
# macOS app, MA1: foundation
Design and work split: `macos-app-design.md`. Crate `crates/ghostty-macos` (lib `ghostty_macos`, bin `ghostty-macos-app`;
empty stub off Apple). Compile-checked only (`just check-macos`, aarch64 + x86_64, clippy `-D warnings`, lib + tests + bin);
never run on a Mac. Platform independent logic is unit-tested on Linux (125+ tests).
## Ported (Swift -> Rust)
* `App/macOS/main.swift` -> `app/entry.rs`; `AppDelegate.swift` -> `app/app_delegate.rs` (all delegate methods, lifecycle,
dock menu, open-file with execute confirmation, quit confirmation incl. shutdown/restart AppleEvent check, SIGUSR2 reload,
local key monitor, bell/dock badge, user notifications via objc2-user-notifications, appearance KVO, config-change handling,
menu shortcut sync + `performGhosttyBindingMenuKeyEquivalent`, float-on-top, set-as-default-terminal, `validateMenuItem`,
toggle visibility); `AppDelegate+Ghostty.swift` -> `app/app_delegate_ghostty.rs`.
* `MainMenu.xib` -> `app/menu_spec/` (tables generated by `scripts/gen-macos-menu-spec.py`, plus `Outlet`, `SYNCED_SHORTCUTS`,
`MENU_IMAGES`, `MenuShortcutKey`, dock menu; tested) and `app/main_menu.rs` (builds the NSMenus, system menus, NSFontManager).
* `Ghostty.App/Config/ConfigTypes/Command/Action/Error/Event/Shell/Surface`, `GhosttyPackage`, `NSEvent+Extension`, top of
`Ghostty.Input`, `SurfaceConfiguration`, a model-only `SurfaceView` -> `ghostty/*` (by forks F1-F3 of MA1; see design doc).
* `Helpers/*` generic ones -> `helpers/*`; unportable SwiftUI-only helpers listed in the design doc.
* `scripts/macos-bundle.sh` (Ghostty.app assembly, dry-run off macOS), `scripts/gen-macos-capi-consts.py`,
`scripts/gen-macos-menu-spec.py`, `scripts/macos-crosscheck.sh` now includes `ghostty-macos` (+ its bin).
* Stubs with the surface `AppDelegate`/`Ghostty.App` use (owners replace them): `features/terminal` (MA3), `quick_terminal::controller`,
`settings`, `about`, `secure_input`, `global_keybinds`, `services`, `custom_app_icon` (MA4), `update` (MA5).
## Deviations
* Menu outlets: the xib's `menuSelectionForSearch` outlet does not exist in Swift (and "Jump to Selection" is unconnected), so
those two items never get a config shortcut -- kept as upstream (module docs of `menu_spec`).
* `NSApplicationMain` + nib -> explicit NSApplication/delegate/menu setup in `entry::run` (no `NSMainNibFile`).
* `application(_:willEncode/didDecodeRestorableState:)`: quick terminal state not encoded yet (TODO(MA4)).
* `updateAppIcon`: AppIcon persistence is MA4's; only the distributed `iconDidChange` notification is posted.
* Fork-reported deviations: `Ghostty.Config.backgroundColor` falls back to a fixed light gray (Swift: dynamic
`windowBackgroundColor`); `window_fullscreen*` getters return raw strings until MA3 (typed `FullscreenMode`);
`open_config` passes the byte length of the URL (Swift `String.count` truncates non-ASCII paths); desktop notifications hop to the
main queue before touching the view; `CodableBridge` archives are not byte-compatible with Swift's; `ExpiringTarget` drop only
invalidates the timer; MIME->UTI lookups use CoreServices C functions; tab-add error is `ObjCException{name,reason}`;
`PasteboardHandle` is an identity `usize`; user-visible duration format "1 hr, 2 min, 3 sec" matches Foundation en_US
abbreviated style but the `.hide` fractional rounding is unverified off macOS.
## Known gaps / TODO(integration)
* Nothing here has run on a Mac; objc2 method signatures were only type-checked.
* `TODO(MA2)` in `ghostty/surface_view.rs`/`surface.rs`, `TODO(MA3)` terminal stubs and `fullscreen::set_is_tab_bar_hook`,
`TODO(MA4)` feature stubs, `TODO(MA5)` update.
* `SurfaceView::set_password_input` does not yet drive `SecureInput` (MA4).
* The bundle script does not embed Sparkle/the DockTilePlugin (MA5/MA4); no `.lproj` localizations exist in the Swift tree either.
+147
View File
@@ -0,0 +1,147 @@
# macOS app (Rust/AppKit port of `macos/Sources`): design
Crate `crates/ghostty-macos` (lib `ghostty_macos` + bin `ghostty-macos-app`). Written by MA1 (foundation); the
feature areas are ported by MA2..MA5 on top of it (split at the end). Nothing here has ever run on a Mac: it is
compile-checked with `scripts/macos-crosscheck.sh` (`just check-macos`, both Apple targets, `-D warnings`).
## Principles
* **Same API surface as the Swift app.** The Swift app is a client of `libghostty` through `ghostty.h`. This app calls the
*same entry points* in-process: `ghostty-lib` is a normal dependency (renamed `libghostty`) and `ghostty::capi`
re-exports its `extern "C"` functions (`ghostty_app_new`, `ghostty_surface_key`, ...), `#[repr(C)]` types and the header's
enumerators (`capi::consts`, generated from `ghostty.h` by `scripts/gen-macos-capi-consts.py`). The runtime config
(`wakeup`, `action`, `read/confirm_read/write_clipboard`, `close_surface` callbacks, userdata) is built exactly like
`Ghostty.App.init`. libghostty is not embedded or linked as a separate library: no XCFramework needed.
* **Programmatic AppKit.** No `ibtool`/Xcode on Linux, so xibs/SwiftUI become code (below). The crate compiles to an empty
stub off Apple; platform independent logic is *not* gated, so it is unit-tested on Linux.
* **Faithful port.** Swift logic is ported 1:1 (same names in snake_case, doc comments kept). Deviations: `DEVIANT_FIXES.md`
("Other deliberate deviations") and `macos-app-MA1.md`.
## Module tree (mirrors `Sources/`)
| Swift | Rust (`crates/ghostty-macos/src`) |
|---|---|
| `App/macOS/main.swift` | `app/entry.rs` (`run()`, called by `main.rs`) |
| `App/macOS/AppDelegate.swift`, `AppDelegate+Ghostty.swift` | `app/app_delegate.rs`, `app/app_delegate_ghostty.rs` |
| `App/macOS/MainMenu.xib` | `app/menu_spec/` (generated tables + pure logic) and `app/main_menu.rs` (builds the `NSMenu`s) |
| `Ghostty/Ghostty.App.swift` | `ghostty/app.rs` (`App`) |
| `Ghostty/Ghostty.Config*.swift`, `Ghostty.Command/Action/Error/Event/Shell`, `GhosttyPackage*.swift` | `ghostty/{config,config_types,command,action,error,event,shell,package,notification}.rs` |
| `Ghostty/Ghostty.Surface.swift`, `Surface View/*` | `ghostty/{surface,surface_view,surface_configuration}.rs` (+ MA2) |
| `Ghostty/Ghostty.Input.swift`, `NSEvent+Extension.swift` | `ghostty/input/`, `ghostty/ns_event.rs` |
| `Helpers/**` | `helpers/**` (extensions: `helpers/extensions/<ns_type>.rs`, one extension trait `XExt` per Swift extension) |
| `Features/<Area>/**` | `features/<area>/**` |
Platform independent modules (tested on Linux): `ghostty::{capi, config, config_types, command, action, shell, input,
notification, package, error, surface_configuration}`, `helpers::{keyboard_shortcut, color, notification_center, published,
...}`, `app::menu_spec`, `features::quick_terminal::{position,size,screen,space_behavior}`.
## Swift type -> Rust type
| Swift | Rust |
|---|---|
| `class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate` | `define_class!` `AppDelegate` (`#[thread_kind = MainThreadOnly]`); Swift stored properties live in the `Ivars` struct (`RefCell`/`Cell`); `@IBAction`/delegate methods are `#[unsafe(method(sel:))]`s with the same selectors |
| `@IBOutlet menuXxx` | `menu_spec::Outlet` enum + `HashMap<Outlet, Retained<NSMenuItem>>` filled by `main_menu::install` |
| `Ghostty.App` (`ObservableObject`) | `Rc<ghostty::app::App>`; `@Published` -> `helpers::published::Published<T>` |
| `Ghostty.Config` (owns `ghostty_config_t`) | `ghostty::config::Config` (`Drop` frees; shared as `Rc<Config>`) |
| `Ghostty.SurfaceView: NSView` | `define_class!` `SurfaceView` (`ghostty/surface_view.rs`; MA2) |
| `Ghostty.Surface` | `ghostty::surface::Surface` |
| `BaseTerminalController/TerminalController: NSWindowController` | `define_class!` classes in `features/terminal` (MA3) |
| `Weak<T>` | `objc2::rc::Weak<T>` (ObjC objects) / `std::rc::Weak` |
| Swift enums with raw strings (`init?(rawValue:)`) | Rust enums with `from_raw_value(&str)` |
| `OptionSet` | `bitflags!` |
| `SwiftUI.Color`, `KeyboardShortcut`, `KeyEquivalent`, `EventModifiers` | `helpers::{color::Color, keyboard_shortcut::*}` (same raw values, so menu key equivalents are identical) |
| `Duration` | `std::time::Duration` |
| `UserDefaults.ghostty` | `helpers::extensions::user_defaults::ghostty()` |
| `Logger` (OSLog) | `log` crate (`ghostty_init` installs the os_log logger like Zig's lib mode) |
## Threading
Everything that touches AppKit/Rust-side state runs on the **main thread** (the Swift code is `@MainActor`/main queue):
objects are `!Send` (`Rc`, `Retained<..>` of main-thread classes), `MainThreadMarker`s come from `helpers::main_thread()`.
The one cross-thread entry from libghostty is the `wakeup` callback: it only forwards the address of the `App` allocation to
`DispatchQueue.main.async`, where it is resolved through a main-thread registry of live apps and `ghostty_app_tick` runs
(`ghostty/app.rs`). UNUserNotificationCenter callbacks arrive on arbitrary queues: they only capture `Send` data and hop to
the main queue (`main_async_local` for closures with main-only captures; `AppDelegate::shared(mtm)` re-resolves the delegate).
`DispatchQueue.main.async` -> `dispatch2::DispatchQueue::main().exec_async`, `asyncAfter` -> `exec_after`, signal sources
(`SIGUSR2` config reload) -> `dispatch2::DispatchSource` with `_dispatch_source_type_signal`.
## Notifications, observers, Combine
* AppKit/Foundation notifications (`NSWindow.didBecomeKeyNotification`, ...): block based observers
(`helpers::extensions::ns_notification::observe`) returning an RAII token (dropping it = `removeObserver`).
* Ghostty's own notifications (`Notification.Name.ghosttyConfigDidChange`, `Ghostty.Notification.ghosttyNewWindow`, ...):
`helpers::notification_center::NotificationCenter` (names/keys with the same strings in `ghostty::notification`). Payloads stay
typed: `UserInfo` is a string-keyed map of `Rc<dyn Any>`, the sender (`object`) is a `Sender` (identity + downcastable handle,
e.g. `Retained<SurfaceView>`). Delivery is synchronous on the main thread, observer semantics follow Foundation (filter by
sender, tokens, snapshot on post). `Ghostty.App` posts these exactly where the Swift code does.
* Combine `$property.sink` / `@Published` / `ObservableObject`: `Published<T>` (`get/set/update/sink`, `Subscription` = cancellable).
* KVO (`NSApplication.observe(\.effectiveAppearance)`): the delegate registers itself via `addObserver:forKeyPath:` and
implements `observeValueForKeyPath:ofObject:change:context:`.
* Distributed notifications (icon change): `NSDistributedNotificationCenter` directly.
## SwiftUI views and xibs
* **xib** (`MainMenu.xib`, `About.xib`, `ConfigurationErrors.xib`, `ClipboardConfirmation.xib`, `QuickTerminal.xib`,
`Terminal*.xib`): reproduced in code. For the main menu the xib is *parsed by a script*
(`scripts/gen-macos-menu-spec.py` -> `app/menu_spec/generated.rs`: titles, key equivalents, modifier masks, selectors, targets,
outlets, system menus), and `main_menu.rs` instantiates the objects; a unit test pins the structure. Window xibs are small
(a window, style mask, content rect, connections): port them by hand as `NSWindowController::init` code with the same style
mask/frame/flags (read the XML; `scripts/gen-macos-menu-spec.py` shows how to parse it).
* **SwiftUI views** (`AboutView`, `ConfigurationErrorsView`, `ClipboardConfirmationView`, update pill/popover, `TerminalView`,
split views, command palette, `SurfaceView` overlays): rebuilt with AppKit views (`NSStackView`/Auto Layout, `NSTextField`,
`NSVisualEffectView`, `NSScrollView`, `NSPopover`, `NSHostingView` is *not* available). `@State`/`@Published` become
`Published<T>` subscriptions that update the views; `EnvironmentObject` becomes explicit references. Visual fidelity target:
same layout, text, spacing and behaviour as far as AppKit allows; record gaps in the owner's notes.
* **Unportable SwiftUI-only helpers** (no AppKit equivalent, not ported): `Backport` (view modifiers), `HostingWindow`,
`NonDraggableHostingView`, `MetalView`/`MTKView` wrapper, `View+Extension`, `Transferable+Extension`/drag-and-drop via
`Transferable` (the AppKit `NSDraggingSource/Destination` equivalent is implemented by MA2/MA3 where needed).
## Menu bar
`menu_spec` holds `MAIN_MENU` (generated from the xib), `Outlet`, `SYNCED_SHORTCUTS` (the `syncMenuShortcut(config, action:,
menuItem:)` list), `MENU_IMAGES` (SF Symbols), `DOCK_MENU` and `MenuShortcutKey` (the shortcut lookup key). `AppDelegate` ports
`syncMenuShortcuts`, `performGhosttyBindingMenuKeyEquivalent`, `validateMenuItem`, the dock menu and float-on-top handling.
Upstream quirk kept: the xib connects "Use Selection for Find" to a non-existent outlet `menuSelectionForSearch` and "Jump to
Selection" to none, so those two items never get a config shortcut (see the `menu_spec` module docs).
## Bundle (`scripts/macos-bundle.sh`)
Builds `ghostty-macos-app`, creates `Ghostty.app` (executable `ghostty`), expands `Ghostty-Info.plist` plus the Xcode
`INFOPLIST_KEY_*` settings with PlistBuddy, copies `Ghostty.sdef`, builds `AppIcon.icns` from `Assets.xcassets` with
`sips`/`iconutil` and copies the alternate/custom icon PNGs and `ResetZoom.pdf`, installs shell-integration/themes/terminfo where
`ghostty_os::resources_dir()` looks on macOS (`Contents/Resources/ghostty`, `Contents/Resources/terminfo/78/xterm-ghostty`),
signs with `Ghostty.entitlements` (`GhosttyDebug.entitlements` with `--debug`). Runs for real only on macOS; elsewhere it is a dry
run that prints every step. `NSMainNibFile` is not set (menu built in code); the DockTilePlugin is a separate bundle (MA4,
`--dock-tile-plugin`); Sparkle.framework would be copied to `Contents/Frameworks` by MA5.
## Assessments
* **App Intents** (`Features/App/Intents`, `Ghostty.Input.*: AppEnum`): the AppIntents framework is Swift-only. Intents,
entities and enums are Swift types conforming to generic Swift protocols (`AppIntent`, `AppEntity`, `AppEnum`,
`EntityQuery`, `@Parameter` property wrappers); the system discovers them from `Metadata.appintents` that the *Swift compiler
toolchain* extracts (`appintentsmetadataprocessor`) from the module. There is no Objective-C surface and no runtime registration
API, so a Rust binary cannot declare intents. Verdict: **unportable from Rust** (would need a Swift shim, i.e. not a Rust port);
MA5 documents it, ports the non-framework logic that is useful elsewhere (e.g. the `Ghostty.Input.Action/MouseButton/...`
enums, already needed by AppleScript) and records the Shortcuts/Siri loss in DEVIANT_FIXES.
* **Sparkle** (`Features/Update`): `Sparkle.framework` is Objective-C; load it at runtime (`NSBundle(path:).load()` from
`Contents/Frameworks`), look classes/protocols up with `AnyClass::get`/`AnyProtocol::get`, create `SPUStandardUpdaterController`-style
objects with `msg_send!`, and declare the `SPUUserDriver` / `SPUUpdaterDelegate` implementations with
`objc2::runtime::ClassBuilder` + `add_protocol` (a `define_class!` cannot name a protocol that is not linked at build time).
Weak linking = the lookup returns `None` when the framework is absent. `UpdateController`'s surface used by `AppDelegate` is
already in place (`start_updater`, `check_for_updates`, `is_installing`, the two automatic-update flags).
* **AppleScript** (`Features/AppleScript`): `Ghostty.sdef` + `OSAScriptingDefinition` in Info.plist (handled by the bundle
script); Cocoa scripting needs `NSObject` subclasses whose KVC keys match the sdef (`define_class!` with property getters
as methods named after the keys), `NSScriptCommand` subclasses (`performDefaultImplementation`), and the `NSApplication`
scripting hooks (`application:delegateHandlesKey:` etc.) on the delegate.
## Work split
| Agent | Area (Swift) | Notes |
|---|---|---|
| **MA2** | `Ghostty/Surface View/*` (SurfaceView AppKit: rendering host, input, IME, mouse, drag source, scroll view, progress bar, grab handle, search overlay, inspector view), rest of `Ghostty.Input.swift` (KeyEvent, Mouse*, Mods, Key enum), `Ghostty.Inspector`, `GhosttyPackage` remaining pieces | fills the `TODO(MA2)` in `ghostty/surface_view.rs`; the model fields App.swift needs already exist |
| **MA3** | `Features/Terminal/*` (BaseTerminalController, TerminalController, TerminalView, windows + `Styles/*.xib` variants, restorable state, tab color), `Features/Splits/*` (SplitTree, split views), Helpers `TabTitleEditor`, `TabGroupCloseCoordinator` | replaces the stubs in `features/terminal/mod.rs` (API already used by `AppDelegate`/`Ghostty.App`) |
| **MA4** | `Features/QuickTerminal`, `Command Palette`, `Settings` (ConfigurationErrors window + SettingsView), `About`, `ClipboardConfirmation`, `Global Keybinds` (event tap, Helpers `PermissionRequest`), `Secure Input` (+overlay), `Services` (ServiceProvider), `Custom App Icon` (AppIcon, ColorizedGhosttyIcon, DockTilePlugin, UserDefaults+AppIcon) | replace the surface stubs in each `features/<area>/mod.rs`; pure value types of quick terminal are already ported |
| **MA5** | `Features/AppleScript` (sdef + scripting classes), `Features/Update` (Sparkle via the ObjC runtime, weak-linked), `Features/App` App Intents (assess: unportable, see above) | |
Each owner: port tests of `macos/Tests/*` for the area (pure logic portable and tested on Linux, AppKit logic compile-checked),
write `docs/rewrite/notes/macos-app-<MAn>.md`, keep `just check-macos` green, list deviations.
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Generate crates/ghostty-macos/src/ghostty/capi/consts.rs from
crates/ghostty-lib/include/ghostty.h: every enumerator becomes a
`pub const NAME: c_int` (the header's enums are C `int`s)."""
import re, sys, pathlib
root = pathlib.Path(__file__).resolve().parent.parent
hdr = (root / "crates/ghostty-lib/include/ghostty.h").read_text()
hdr = re.sub(r"/\*.*?\*/", "", hdr, flags=re.S)
hdr = re.sub(r"//[^\n]*", "", hdr)
out = [
"//! GENERATED by scripts/gen-macos-capi-consts.py from include/ghostty.h; do not edit.",
"//!",
"//! The enumerators of every C enum of the header (all `int`).",
"#![allow(missing_docs)]",
"",
"use std::ffi::c_int;",
"",
]
for m in re.finditer(r"typedef\s+enum\s*(?:\w+\s*)?\{(.*?)\}\s*(\w+)\s*;", hdr, flags=re.S):
body, name = m.group(1), m.group(2)
out.append(f"// {name}")
nxt = 0
for item in [x.strip() for x in body.split(",") if x.strip()]:
if "=" in item:
n, v = [x.strip() for x in item.split("=", 1)]
v = v.replace("UL", "").replace("U", "")
nxt = eval(v)
else:
n = item
out.append(f"pub const {n}: c_int = {nxt};")
nxt += 1
out.append("")
sys.stdout.write("\n".join(out))
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Generate the data tables of crates/ghostty-macos/src/app/menu_spec/generated.rs
from Sources/App/macOS/MainMenu.xib of the Swift app (no ibtool needed).
Usage: scripts/gen-macos-menu-spec.py [path/to/MainMenu.xib] > generated.rs"""
import sys, pathlib
import xml.etree.ElementTree as ET
root = pathlib.Path(__file__).resolve().parent.parent
default = root.parent / "ghostty/macos/Sources/App/macOS/MainMenu.xib"
xib = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else default
r = ET.parse(xib).getroot()
ids = {} # object id -> customObject class
for c in r.find("objects").findall("customObject"):
ids[c.get("id")] = c.get("customClass")
outlets = {} # menuItem id -> property
for c in r.find("objects").findall("customObject"):
for o in c.iter("outlet"):
if o.get("property", "").startswith("menu"):
outlets[o.get("destination")] = o.get("property")
used = []
def camel(p):
return p[0].upper() + p[1:]
def rs(s):
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
def mods(item):
m = item.find("modifierMask")
if m is None:
return "None" # IB default: command
parts = [n for n in ("shift", "control", "option", "command") if m.get(n) == "YES"]
if not parts:
return "Some(Mods::NONE)"
return "Some(Mods(" + " | ".join("Mods::" + p.upper() + ".0" for p in parts) + "))"
def target(t):
if t == "-1":
return "Target::FirstResponder"
if t == "bbz-4X-AYv":
return "Target::AppDelegate"
raise SystemExit("unknown target " + str(t))
def menu(m, ind):
pad = " " * ind
sysm = {"apple": "Some(SystemMenu::Apple)", "window": "Some(SystemMenu::Window)",
"help": "Some(SystemMenu::Help)", "main": "Some(SystemMenu::Main)"}.get(m.get("systemMenu"), "None")
mo = outlets.get(m.get("id"))
if mo:
used.append(mo)
mo = ("Some(Outlet::" + camel(mo) + ")") if mo else "None"
out = [f"MenuSpec {{ title: {rs(m.get('title'))}, system_menu: {sysm}, outlet: {mo}, items: &["]
items = m.find("items")
for i in (items.findall("menuItem") if items is not None else []):
if i.get("isSeparatorItem") == "YES":
out.append(f"{pad} ItemSpec::Separator,")
continue
act = i.find("connections/action")
action = "None"
if act is not None:
action = f"Some(Action {{ selector: {rs(act.get('selector'))}, target: {target(act.get('target'))} }})"
outlet = outlets.get(i.get("id"))
if outlet:
used.append(outlet)
sub = i.find("menu")
out.append(f"{pad} ItemSpec::Item(Item {{")
out.append(f"{pad} title: {rs(i.get('title'))},")
out.append(f"{pad} key_equivalent: {rs(i.get('keyEquivalent', ''))},")
out.append(f"{pad} modifiers: {mods(i)},")
out.append(f"{pad} action: {action},")
out.append(f"{pad} outlet: {('Some(Outlet::' + camel(outlet) + ')') if outlet else 'None'},")
if sub is not None:
out.append(f"{pad} submenu: Some(&" + menu(sub, ind + 2) + "),")
else:
out.append(f"{pad} submenu: None,")
out.append(f"{pad} }}),")
out.append(f"{pad}] }}")
return "\n".join(out)
top = r.find("objects").find("menu")
body = menu(top, 1)
print("//! GENERATED by scripts/gen-macos-menu-spec.py from MainMenu.xib; do not edit.")
print("//!")
print("//! Every menu item of the xib: title, key equivalent, modifier mask, action")
print("//! selector + target (`-1` first responder / the AppDelegate) and the AppDelegate")
print("//! outlet it is connected to.")
print()
print("use super::{Action, Item, ItemSpec, MenuSpec, Mods, Outlet, SystemMenu, Target};")
print()
print("/// The main menu (`systemMenu=\"main\"`).")
print("pub const MAIN_MENU: MenuSpec = " + body + ";")
print()
print("/// All outlets connected in the xib (the Swift names).")
print("pub const CONNECTED_OUTLETS: &[Outlet] = &[")
for o in sorted(set(used)):
print(f" Outlet::{camel(o)},")
print("];")
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Assemble Ghostty.app from the Rust macOS app (crate ghostty-macos).
#
# Mirrors what Xcode does for the Swift app's `Ghostty` target
# (macos/Ghostty.xcodeproj): compile the executable, expand Ghostty-Info.plist
# (+ the INFOPLIST_KEY_* build settings), compile Assets.xcassets, copy
# Ghostty.sdef and the resources, sign with the entitlements. libghostty is NOT
# linked or embedded: the Rust app drives ghostty-lib in-process.
#
# Usage: scripts/macos-bundle.sh [--profile PROFILE] [--target TRIPLE]
# [--out DIR] [--debug] [--dock-tile-plugin PATH]
# [--sign IDENTITY] [--dry-run]
# PROFILE cargo profile (default: release; "dev" is the debug build)
# TRIPLE aarch64-apple-darwin or x86_64-apple-darwin (default: host)
# --out output directory (default: target/macos-app/<profile>)
# --debug use the debug identity: bundle id com.mitchellh.ghostty.debug,
# display name "Ghostty[DEBUG]", GhosttyDebug.entitlements
# --sign codesign identity (default "-", ad hoc)
#
# It only runs for real on macOS (PlistBuddy, iconutil, sips, codesign, cargo for
# an Apple target). On any other host it is a dry run: every step is printed
# and the Swift-side inputs are checked to exist. DRY_RUN=1 forces that on macOS.
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
mac="$root/../ghostty/macos"
profile=release
target=
out=
debug=0
sign=-
plugin=
dry=${DRY_RUN:-0}
while [ $# -gt 0 ]; do
case $1 in
--profile) profile=$2; shift 2 ;;
--target) target=$2; shift 2 ;;
--out) out=$2; shift 2 ;;
--debug) debug=1; shift ;;
--sign) sign=$2; shift 2 ;;
--dock-tile-plugin) plugin=$2; shift 2 ;;
--dry-run) dry=1; shift ;;
-h | --help) sed -n '2,/^set -e/p' "$0" | sed '$d'; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [ "$(uname -s)" != Darwin ]; then
dry=1
fi
run() {
if [ "$dry" = 1 ]; then
printf '+'
printf ' %q' "$@"
printf '\n'
else
"$@"
fi
}
# Inputs from the Swift app tree (Xcode project settings in comments).
version=$(sed -n 's/^app-version = "\(.*\)"/\1/p' "$root/Cargo.toml")
[ -n "$version" ] || version=1.3.1
if [ "$debug" = 1 ]; then
bundle_id=com.mitchellh.ghostty.debug
display_name='Ghostty[DEBUG]'
entitlements="$mac/GhosttyDebug.entitlements"
else
bundle_id=com.mitchellh.ghostty
display_name=Ghostty
entitlements="$mac/Ghostty.entitlements"
fi
min_os=13.0 # MACOSX_DEPLOYMENT_TARGET of the Ghostty target
case $(uname -m) in arm64 | aarch64) host=aarch64-apple-darwin ;; *) host=x86_64-apple-darwin ;; esac
target=${target:-$host}
pdir=$profile
[ "$profile" = dev ] && pdir=debug
tdir=${CARGO_TARGET_DIR:-$root/target}
exe="$tdir/$target/$pdir/ghostty-macos-app"
out=${out:-$root/target/macos-app/$pdir}
app="$out/Ghostty.app"
contents="$app/Contents"
res="$contents/Resources"
plist="$contents/Info.plist"
pb=/usr/libexec/PlistBuddy
for f in "$mac/Ghostty-Info.plist" "$mac/Ghostty.sdef" "$mac/Assets.xcassets" "$entitlements" \
"$root/resources/terminfo" "$root/resources/themes" "$root/resources/shell-integration"; do
[ -e "$f" ] || { echo "missing input: $f" >&2; exit 1; }
done
echo "== Ghostty.app ($version, $bundle_id, $target, profile $profile) -> $app"
[ "$dry" = 1 ] && echo "== dry run: nothing is built or written"
# 1. The executable (CFBundleExecutable = ghostty, like the Swift product name).
run cargo build --profile "$profile" -p ghostty-macos --bin ghostty-macos-app --target "$target"
run rm -rf "$app"
run mkdir -p "$contents/MacOS" "$res"
run cp "$exe" "$contents/MacOS/ghostty"
# 2. Info.plist: Ghostty-Info.plist plus the build settings Xcode folds in
# (GENERATE_INFOPLIST_FILE = YES, INFOPLIST_KEY_*). NSMainNibFile is not set:
# the Rust app builds MainMenu.xib in code (app/main_menu.rs).
run cp "$mac/Ghostty-Info.plist" "$plist"
plist_set() { # key type value
if [ "$dry" = 1 ]; then
printf '+ PlistBuddy -c %q %q\n' "Add :$1 $2 $3" "$plist"
else
"$pb" -c "Delete :$1" "$plist" 2>/dev/null || true
"$pb" -c "Add :$1 $2 $3" "$plist"
fi
}
plist_set CFBundleDevelopmentRegion string en
plist_set CFBundleExecutable string ghostty
plist_set CFBundleIdentifier string "$bundle_id"
plist_set CFBundleInfoDictionaryVersion string 6.0
plist_set CFBundleName string Ghostty
plist_set CFBundleDisplayName string "$display_name"
plist_set CFBundlePackageType string APPL
plist_set CFBundleShortVersionString string "$version"
plist_set CFBundleVersion string 1
plist_set CFBundleIconFile string AppIcon
plist_set CFBundleIconName string Ghostty
plist_set LSMinimumSystemVersion string "$min_os"
plist_set LSApplicationCategoryType string public.app-category.developer-tools
plist_set NSPrincipalClass string NSApplication
plist_set NSHumanReadableCopyright string ""
plist_set GhosttyBuild string "$version"
commit=$(git -C "$root" rev-parse --short HEAD 2>/dev/null || true)
plist_set GhosttyCommit string "$commit"
while IFS='|' read -r key text; do
plist_set "$key" string "$text"
done <<'KEYS'
NSAppleEventsUsageDescription|A program running within Ghostty would like to use AppleScript.
NSAudioCaptureUsageDescription|A program running within Ghostty would like to access your system's audio.
NSBluetoothAlwaysUsageDescription|A program running within Ghostty would like to use Bluetooth.
NSCalendarsUsageDescription|A program running within Ghostty would like to access your Calendar.
NSCameraUsageDescription|A program running within Ghostty would like to use the camera.
NSContactsUsageDescription|A program running within Ghostty would like to access your Contacts.
NSLocalNetworkUsageDescription|A program running within Ghostty would like to access the local network.
NSLocationUsageDescription|A program running within Ghostty would like to access your location information.
NSMicrophoneUsageDescription|A program running within Ghostty would like to use your microphone.
NSMotionUsageDescription|A program running within Ghostty would like to access motion data.
NSPhotoLibraryUsageDescription|A program running within Ghostty would like to access your Photo Library.
NSRemindersUsageDescription|A program running within Ghostty would like to access your reminders.
NSSpeechRecognitionUsageDescription|A program running within Ghostty would like to use speech recognition.
NSSystemAdministrationUsageDescription|A program running within Ghostty requires elevated privileges.
KEYS
# NSLocationTemporaryUsageDescriptionDictionary (a dict, not a string)
if [ "$dry" = 1 ]; then
printf '+ PlistBuddy -c %q %q\n' 'Add :NSLocationTemporaryUsageDescriptionDictionary dict' "$plist"
else
"$pb" -c 'Add :NSLocationTemporaryUsageDescriptionDictionary dict' "$plist" || true
fi
# NSServices menu item titles use $(INFOPLIST_KEY_CFBundleDisplayName).
if [ "$dry" = 1 ]; then
echo "+ substitute \$(INFOPLIST_KEY_CFBundleDisplayName) -> $display_name in $plist"
else
sed -i '' "s/\$(INFOPLIST_KEY_CFBundleDisplayName)/$display_name/g" "$plist"
fi
# 3. Dock tile plugin (a separate bundle target in Xcode; ported by MA4).
if [ -n "$plugin" ]; then
run mkdir -p "$contents/PlugIns"
run cp -R "$plugin" "$contents/PlugIns/DockTilePlugin.plugin"
else
echo "-- no --dock-tile-plugin: dropping NSDockTilePlugIn from Info.plist"
if [ "$dry" = 1 ]; then
printf '+ PlistBuddy -c %q %q\n' 'Delete :NSDockTilePlugIn' "$plist"
else
"$pb" -c 'Delete :NSDockTilePlugIn' "$plist" || true
fi
fi
# 4. AppleScript definition (OSAScriptingDefinition = Ghostty.sdef).
run cp "$mac/Ghostty.sdef" "$res/Ghostty.sdef"
# 5. Icons from Assets.xcassets (actool equivalent). AppIcon.icns for the app;
# the alternate/custom icon images are copied as plain resources so
# `Bundle.image(forResource:)` finds them.
iconset="$out/AppIcon.iconset"
src="$mac/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png"
run rm -rf "$iconset"
run mkdir -p "$iconset"
for s in 16 32 128 256 512; do
run sips -z $s $s "$src" --out "$iconset/icon_${s}x${s}.png"
run sips -z $((s * 2)) $((s * 2)) "$src" --out "$iconset/icon_${s}x${s}@2x.png"
done
run iconutil -c icns "$iconset" -o "$res/AppIcon.icns"
run cp "$src" "$res/AppIconImage.png"
for img in "$mac"/Assets.xcassets/Alternate\ Icons/*.imageset; do
name=$(basename "$img" .imageset)
run cp "$img/macOS-AppIcon-1024px.png" "$res/$name.png"
done
for img in "$mac"/Assets.xcassets/Custom\ Icon/*.imageset; do
name=$(basename "$img" .imageset)
png=$(cd "$img" 2>/dev/null && ls ./*.png 2>/dev/null | head -n1 || true)
[ -n "$png" ] && run cp "$img/${png#./}" "$res/$name.png"
done
run cp "$mac/Assets.xcassets/ResetZoom.imageset/ResetZoom.pdf" "$res/ResetZoom.pdf"
# 6. Ghostty resources (shell integration, themes, terminfo). The lookup done by
# ghostty-os `resources_dir()` on macOS: Contents/Resources/terminfo/78/xterm-ghostty
# is the sentinel and the resources dir is Contents/Resources/ghostty.
run mkdir -p "$res/ghostty" "$res/terminfo/78" "$res/terminfo/67"
run cp -R "$root/resources/shell-integration" "$res/ghostty/shell-integration"
run cp -R "$root/resources/themes" "$res/ghostty/themes"
run cp "$root/resources/terminfo/x/xterm-ghostty" "$res/terminfo/78/xterm-ghostty"
run cp "$root/resources/terminfo/g/ghostty" "$res/terminfo/67/ghostty"
# 7. Sign (entitlements of the Xcode target).
run codesign --force --deep --sign "$sign" --entitlements "$entitlements" "$app"
echo "== done: $app"
+10 -1
View File
@@ -5,6 +5,8 @@
# Usage: scripts/macos-crosscheck.sh [check|clippy] [target]
# MACOS_CHECK_FEATURES="ghostty-font/font-backend-freetype" adds cargo features
# (for example the FreeType-only font backend on Apple).
# MACOS_CHECK_ONLY="ghostty-macos" restricts the check to the given crates
# (space separated), for quick iteration.
set -euo pipefail
cd "$(dirname "$0")/.."
MODE=${1:-clippy}
@@ -55,13 +57,20 @@ export GETTEXT_DIR=/nonexistent ZIG_GLOBAL_CACHE_DIR="$W/zig-cache" ZIG_LOCAL_CA
CRATES=(-p ghostty-vt -p ghostty-unicode -p ghostty-datastruct -p ghostty-os -p ghostty-input
-p ghostty-config -p ghostty-terminal -p ghostty-font -p ghostty-renderer -p ghostty-termio
-p ghostty-core -p ghostty-apprt -p ghostty-inspector -p ghostty-lib -p ghostty-crash
-p ghostty-bench -p ghostty-vt-capi)
-p ghostty-bench -p ghostty-vt-capi -p ghostty-macos)
if [ -n "${MACOS_CHECK_ONLY:-}" ]; then
CRATES=()
for c in $MACOS_CHECK_ONLY; do CRATES+=(-p "$c"); done
fi
FEATURES=()
if [ -n "${MACOS_CHECK_FEATURES:-}" ]; then
FEATURES=(--features "$MACOS_CHECK_FEATURES")
fi
if [ "$MODE" = clippy ]; then
cargo clippy --target "$TARGET" "${CRATES[@]}" "${FEATURES[@]}" --lib --tests -- -D warnings
# The macOS app executable (an Apple-only binary).
cargo clippy --target "$TARGET" -p ghostty-macos --bins -- -D warnings
else
cargo check --target "$TARGET" "${CRATES[@]}" "${FEATURES[@]}" --lib --tests
cargo check --target "$TARGET" -p ghostty-macos --bins
fi