diff --git a/Cargo.lock b/Cargo.lock index cd755e8..1d7280c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/ghostty-macos/Cargo.toml b/crates/ghostty-macos/Cargo.toml new file mode 100644 index 0000000..8b632ca --- /dev/null +++ b/crates/ghostty-macos/Cargo.toml @@ -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" diff --git a/crates/ghostty-macos/scratch.log b/crates/ghostty-macos/scratch.log new file mode 100644 index 0000000..319621d --- /dev/null +++ b/crates/ghostty-macos/scratch.log @@ -0,0 +1,815 @@ + Compiling ghostty-build-info v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-build-info) + Checking ghostty-config v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-config) + Checking ghostty-font v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-font) + Checking ghostty-renderer v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-renderer) + Checking ghostty-inspector v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-inspector) + Checking ghostty-apprt v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-apprt) + Checking ghostty-termio v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-termio) + Checking ghostty-cli v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-cli) + Checking ghostty-core v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-core) + Checking ghostty-lib v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-lib) + Checking ghostty-macos v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/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` 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` 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` + +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 + | + ::: /home/pfs/.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: + `::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` 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` 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` + +error[E0308]: mismatched types + --> crates/ghostty-macos/src/ghostty/app.rs:103:45 + | +103 | unsafe { self.center.removeObserver(&self.token) }; + | -------------- ^^^^^^^^^^^ expected `&AnyObject`, found `&Retained>` + | | + | arguments to this method are incorrect + | + = note: expected reference `&AnyObject` + found reference `&Retained>` +note: method defined here + --> /home/pfs/.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> { + | ------------------------------------------------- expected `Option>` because of return type +989 | match target.tag { +990 | GHOSTTY_TARGET_APP => app_delegate().map(|d| d.undo_manager()), + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Option>`, found `Option>` + | + = note: expected enum `Option>` + found enum `Option>` + +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::from_retained_objects( + | ----------------------------------- arguments to this function are incorrect +513 | &[&*surface_key, &*focus_key], +514 | vec![surface_val, focus_val], + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `&[Retained]`, found `Vec>` + | + = note: expected reference `&[Retained]` + found struct `Vec>` +note: associated function defined here + --> /home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-foundation-0.3.2/src/dictionary.rs:100:12 + | +100 | pub fn from_retained_objects( + | ^^^^^^^^^^^^^^^^^^^^^ + +error[E0308]: mismatched types + --> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:22:14 + | +20 | fn cg_image(image: &NSImage) -> Option> { + | ----------------------------------------------------------------------- expected `Option>` 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>`, found `Option>` + | + = note: expected enum `Option>` + found enum `Option>` + +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` 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 + --> /home/pfs/.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)?) + | ^^ + | + ::: /home/pfs/.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) -> Retained; +... | +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: + `::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::::from_raw + Retained::::new + Retained::::cast_unchecked + Retained::::cast + and 2 others + --> /home/pfs/.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 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +... +229 | pub unsafe fn new(ptr: *mut T) -> Option { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +... +381 | pub unsafe fn cast_unchecked(this: Self) -> Retained { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +... +396 | pub unsafe fn cast(this: Self) -> Retained { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +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::::from_raw + Retained::::new + Retained::::cast_unchecked + Retained::::cast + and 2 others + --> /home/pfs/.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 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +... +229 | pub unsafe fn new(ptr: *mut T) -> Option { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +... +381 | pub unsafe fn cast_unchecked(this: Self) -> Retained { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +... +396 | pub unsafe fn cast(this: Self) -> Retained { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +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 = 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 diff --git a/crates/ghostty-macos/src/app/app_delegate.rs b/crates/ghostty-macos/src/app/app_delegate.rs new file mode 100644 index 0000000..d5cc551 --- /dev/null +++ b/crates/ghostty-macos/src/app/app_delegate.rs @@ -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`; +//! * `@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), +} + +/// `AppDelegate.ToggleVisibilityState`. +pub struct ToggleVisibilityState { + hidden_windows: Vec>, + key_window: Option>, +} + +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> = 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, + /// The `@IBOutlet` menu items. + outlets: RefCell>>, + services_menu: RefCell>>, + /// The dock menu. + dock_menu: Retained, + /// This is only true before application has become active. + application_has_become_active: Cell, + /// Set in applicationDidFinishLaunching with the system uptime. + application_launch_time: Cell, + derived_config: Cell, + /// The global undo manager for app-level state such as window restoration. + undo_manager: Retained, + quick_terminal_state: RefCell, + /// Manages updates. + update_controller: UpdateController, + /// Tracks the windows that we hid for toggleVisibility. + hidden_state: RefCell>, + /// Signals. + signals: RefCell>>, + /// 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>>, + /// Observation tokens (NotificationCenter observers). + observers: RefCell>, + bus_observers: RefCell>, + local_monitor: RefCell>>, +} + +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> { + 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, + ) { + 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, + ) { + 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>, + _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 { + 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 = 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> { + 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 = unsafe { Retained::cast_unchecked(delegate) }; + obj.downcast::().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 { + self.ivars().ghostty.clone() + } + + /// `undoManager`. + #[must_use] + pub fn undo_manager( + &self, + ) -> Retained { + 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> { + 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 { + 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> { + 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::() + ]; + } + + // 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> = + 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 + // `; 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 { + 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| -> *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::().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::>()) + 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::>(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::>()) + .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| { + // 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::(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::>()) + 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::().is_ok()); + if !is_terminal { + return; + } + + let config = n + .user_info + .get::(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) { + 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::() + }); + 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::().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::().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); + // 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 Send for MainOnly {} + let wrapped = MainOnly(f); + DispatchQueue::main().exec_async(move || { + let w = wrapped; + (w.0)(); + }); +} + +#[link(name = "AppKit", kind = "framework")] +unsafe extern "C" { + fn NSBeep(); +} diff --git a/crates/ghostty-macos/src/app/app_delegate_ghostty.rs b/crates/ghostty-macos/src/app/app_delegate_ghostty.rs new file mode 100644 index 0000000..3aa5088 --- /dev/null +++ b/crates/ghostty-macos/src/app/app_delegate_ghostty.rs @@ -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> { + for window in NSApplication::sharedApplication(self.mtm()).windows() { + let Some(controller) = window + .windowController() + .and_then(|c| c.downcast::().ok()) + else { + continue; + }; + + for surface in controller.surface_tree() { + if surface.id().isEqual(Some(id)) { + return Some(surface); + } + } + } + + None + } +} diff --git a/crates/ghostty-macos/src/app/entry.rs b/crates/ghostty-macos/src/app/entry.rs new file mode 100644 index 0000000..bed379a --- /dev/null +++ b/crates/ghostty-macos/src/app/entry.rs @@ -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 = 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); +} diff --git a/crates/ghostty-macos/src/app/main_menu.rs b/crates/ghostty-macos/src/app/main_menu.rs new file mode 100644 index 0000000..f841853 --- /dev/null +++ b/crates/ghostty-macos/src/app/main_menu.rs @@ -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>, + /// `menuServices`, the submenu of the Services item. + pub services_menu: Option>, +} + +/// 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 { + // ``: 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 { + 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 = 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, 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) +} diff --git a/crates/ghostty-macos/src/app/menu_spec/generated.rs b/crates/ghostty-macos/src/app/menu_spec/generated.rs new file mode 100644 index 0000000..5a68090 --- /dev/null +++ b/crates/ghostty-macos/src/app/menu_spec/generated.rs @@ -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, +]; diff --git a/crates/ghostty-macos/src/app/menu_spec/mod.rs b/crates/ghostty-macos/src/app/menu_spec/mod.rs new file mode 100644 index 0000000..6c41dae --- /dev/null +++ b/crates/ghostty-macos/src/app/menu_spec/mod.rs @@ -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 ``. + pub modifiers: Option, + pub action: Option, + pub outlet: Option, + pub submenu: Option<&'static MenuSpec>, +} + +/// A menu. +#[derive(Clone, Copy, Debug)] +pub struct MenuSpec { + pub title: &'static str, + pub system_menu: Option, + /// An outlet connected to the menu itself (`menuServices`). + pub outlet: Option, + 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 { + 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 { + 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()); + } +} diff --git a/crates/ghostty-macos/src/app/mod.rs b/crates/ghostty-macos/src/app/mod.rs new file mode 100644 index 0000000..59f88d8 --- /dev/null +++ b/crates/ghostty-macos/src/app/mod.rs @@ -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; diff --git a/crates/ghostty-macos/src/app/support.rs b/crates/ghostty-macos/src/app/support.rs new file mode 100644 index 0000000..cb7bd63 --- /dev/null +++ b/crates/ghostty-macos/src/app/support.rs @@ -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> = defaults + .iter() + .map(|(k, _)| NSString::from_str(k)) + .collect(); + let key_refs: Vec<&NSString> = keys.iter().map(|k| &**k).collect(); + let values: Vec> = defaults + .iter() + .map(|(_, v)| { + let n = NSNumber::new_bool(*v); + // SAFETY: an NSNumber is an Objective-C object. + unsafe { Retained::cast_unchecked::(n) } + }) + .collect(); + let dict: Retained> = + 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 { + let info = NSBundle::mainBundle().infoDictionary()?; + let value = info.objectForKey(&NSString::from_str(key))?; + let number = value.downcast_ref::()?; + Some(number.boolValue()) +} + +/// `NSWorkspace.shared.setDefaultApplication(at: Bundle.main.bundleURL, +/// toOpen: .unixExecutable) { error in ... }`. +pub fn set_default_terminal(handler: &RcBlock) { + 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> = + unsafe { msg_send![ut_type, typeWithIdentifier: &*identifier] }; + let Some(content_type) = content_type else { + return; + }; + let url: Retained = 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 + ] + }; +} diff --git a/crates/ghostty-macos/src/features/about/mod.rs b/crates/ghostty-macos/src/features/about/mod.rs new file mode 100644 index 0000000..2aa2886 --- /dev/null +++ b/crates/ghostty-macos/src/features/about/mod.rs @@ -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 = std::rc::Rc::default(); +} + +impl AboutController { + #[must_use] + pub fn shared() -> std::rc::Rc { + 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) {} +} diff --git a/crates/ghostty-macos/src/features/app_intents/mod.rs b/crates/ghostty-macos/src/features/app_intents/mod.rs new file mode 100644 index 0000000..4d9d84f --- /dev/null +++ b/crates/ghostty-macos/src/features/app_intents/mod.rs @@ -0,0 +1 @@ +//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner). diff --git a/crates/ghostty-macos/src/features/applescript/mod.rs b/crates/ghostty-macos/src/features/applescript/mod.rs new file mode 100644 index 0000000..4d9d84f --- /dev/null +++ b/crates/ghostty-macos/src/features/applescript/mod.rs @@ -0,0 +1 @@ +//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner). diff --git a/crates/ghostty-macos/src/features/clipboard_confirmation/mod.rs b/crates/ghostty-macos/src/features/clipboard_confirmation/mod.rs new file mode 100644 index 0000000..4d9d84f --- /dev/null +++ b/crates/ghostty-macos/src/features/clipboard_confirmation/mod.rs @@ -0,0 +1 @@ +//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner). diff --git a/crates/ghostty-macos/src/features/command_palette/mod.rs b/crates/ghostty-macos/src/features/command_palette/mod.rs new file mode 100644 index 0000000..4d9d84f --- /dev/null +++ b/crates/ghostty-macos/src/features/command_palette/mod.rs @@ -0,0 +1 @@ +//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner). diff --git a/crates/ghostty-macos/src/features/custom_app_icon/mod.rs b/crates/ghostty-macos/src/features/custom_app_icon/mod.rs new file mode 100644 index 0000000..e021deb --- /dev/null +++ b/crates/ghostty-macos/src/features/custom_app_icon/mod.rs @@ -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, + ); + } +} diff --git a/crates/ghostty-macos/src/features/global_keybinds/mod.rs b/crates/ghostty-macos/src/features/global_keybinds/mod.rs new file mode 100644 index 0000000..95284de --- /dev/null +++ b/crates/ghostty-macos/src/features/global_keybinds/mod.rs @@ -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 = Rc::default(); +} + +impl GlobalEventTap { + #[must_use] + pub fn shared() -> Rc { + SHARED.with(Rc::clone) + } + + /// `enable()`. TODO(MA4) + pub fn enable(&self) { + log::warn!("TODO(MA4): GlobalEventTap.enable"); + } + + /// `disable()`. TODO(MA4) + pub fn disable(&self) {} +} diff --git a/crates/ghostty-macos/src/features/mod.rs b/crates/ghostty-macos/src/features/mod.rs new file mode 100644 index 0000000..532838b --- /dev/null +++ b/crates/ghostty-macos/src/features/mod.rs @@ -0,0 +1,20 @@ +//! `Sources/Features/`: 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()`. +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; diff --git a/crates/ghostty-macos/src/features/quick_terminal/controller.rs b/crates/ghostty-macos/src/features/quick_terminal/controller.rs new file mode 100644 index 0000000..2c87fd9 --- /dev/null +++ b/crates/ghostty-macos/src/features/quick_terminal/controller.rs @@ -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 { + 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, + position: Cell, +} + +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, + position: QuickTerminalPosition, + _base_config: Option, + _restoration_state: Option<&QuickTerminalRestorableState>, + ) -> Retained { + 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"); + } +} diff --git a/crates/ghostty-macos/src/features/quick_terminal/mod.rs b/crates/ghostty-macos/src/features/quick_terminal/mod.rs new file mode 100644 index 0000000..1e3750b --- /dev/null +++ b/crates/ghostty-macos/src/features/quick_terminal/mod.rs @@ -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"); diff --git a/crates/ghostty-macos/src/features/quick_terminal/position.rs b/crates/ghostty-macos/src/features/quick_terminal/position.rs new file mode 100644 index 0000000..b95227d --- /dev/null +++ b/crates/ghostty-macos/src/features/quick_terminal/position.rs @@ -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 { + 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", + } + } +} diff --git a/crates/ghostty-macos/src/features/quick_terminal/screen.rs b/crates/ghostty-macos/src/features/quick_terminal/screen.rs new file mode 100644 index 0000000..d971115 --- /dev/null +++ b/crates/ghostty-macos/src/features/quick_terminal/screen.rs @@ -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 { + match s { + "main" => Some(Self::Main), + "mouse" => Some(Self::Mouse), + "macos-menu-bar" => Some(Self::MenuBar), + _ => None, + } + } +} diff --git a/crates/ghostty-macos/src/features/quick_terminal/size.rs b/crates/ghostty-macos/src/features/quick_terminal/size.rs new file mode 100644 index 0000000..397062d --- /dev/null +++ b/crates/ghostty-macos/src/features/quick_terminal/size.rs @@ -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 { + 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, + pub secondary: Option, +} + +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 + } + ); + } +} diff --git a/crates/ghostty-macos/src/features/quick_terminal/space_behavior.rs b/crates/ghostty-macos/src/features/quick_terminal/space_behavior.rs new file mode 100644 index 0000000..6a71251 --- /dev/null +++ b/crates/ghostty-macos/src/features/quick_terminal/space_behavior.rs @@ -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 { + 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, + } + } +} diff --git a/crates/ghostty-macos/src/features/secure_input/mod.rs b/crates/ghostty-macos/src/features/secure_input/mod.rs new file mode 100644 index 0000000..2a2cbd4 --- /dev/null +++ b/crates/ghostty-macos/src/features/secure_input/mod.rs @@ -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, + enabled: Cell, +} + +thread_local! { + static SHARED: Rc = Rc::default(); +} + +impl SecureInput { + /// `shared`. + #[must_use] + pub fn shared() -> Rc { + 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() + } +} diff --git a/crates/ghostty-macos/src/features/services/mod.rs b/crates/ghostty-macos/src/features/services/mod.rs new file mode 100644 index 0000000..3168adc --- /dev/null +++ b/crates/ghostty-macos/src/features/services/mod.rs @@ -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 { + let this = Self::alloc(crate::helpers::main_thread()); + // SAFETY: NSObject's `init`. + unsafe { msg_send![this, init] } + } +} diff --git a/crates/ghostty-macos/src/features/settings/mod.rs b/crates/ghostty-macos/src/features/settings/mod.rs new file mode 100644 index 0000000..6dedf04 --- /dev/null +++ b/crates/ghostty-macos/src/features/settings/mod.rs @@ -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>, + window_visible: std::cell::Cell, +} + +thread_local! { + static SHARED: std::rc::Rc = std::rc::Rc::default(); +} + +impl ConfigurationErrorsController { + /// `sharedInstance`. + #[must_use] + pub fn shared_instance() -> std::rc::Rc { + SHARED.with(std::rc::Rc::clone) + } + + /// `errors` getter. + #[must_use] + pub fn errors(&self) -> Vec { + self.errors.borrow().clone() + } + + /// `errors` setter: with no errors the window closes (`didSet`). + pub fn set_errors(&self, errors: Vec) { + 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); + } +} diff --git a/crates/ghostty-macos/src/features/splits/mod.rs b/crates/ghostty-macos/src/features/splits/mod.rs new file mode 100644 index 0000000..4d9d84f --- /dev/null +++ b/crates/ghostty-macos/src/features/splits/mod.rs @@ -0,0 +1 @@ +//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner). diff --git a/crates/ghostty-macos/src/features/terminal/mod.rs b/crates/ghostty-macos/src/features/terminal/mod.rs new file mode 100644 index 0000000..c0eca1a --- /dev/null +++ b/crates/ghostty-macos/src/features/terminal/mod.rs @@ -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, + /// `titleOverride`. + pub title_override: RefCell>, +} + +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> { + warn!("TODO(MA3): BaseTerminalController::surface_tree"); + Vec::new() + } + + /// `focusedSurface`. + #[must_use] + pub fn focused_surface(&self) -> Option> { + 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) { + *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> { + Vec::new() + } + + /// `TerminalController.newWindow(_:withBaseConfig:)`. + #[must_use] + pub fn new_window( + _ghostty: &Rc, + _base_config: Option<&SurfaceConfiguration>, + ) -> Option> { + warn!("TODO(MA3): TerminalController::new_window"); + None + } + + /// `TerminalController.newTab(_:from:withBaseConfig:)`. + #[must_use] + pub fn new_tab( + _ghostty: &Rc, + _from: Option<&NSWindow>, + _base_config: Option<&SurfaceConfiguration>, + ) -> Option> { + warn!("TODO(MA3): TerminalController::new_tab"); + None + } + + /// `TerminalController.preferredParent`. + #[must_use] + pub fn preferred_parent() -> Option> { + 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; +} diff --git a/crates/ghostty-macos/src/features/update/mod.rs b/crates/ghostty-macos/src/features/update/mod.rs new file mode 100644 index 0000000..d25fe86 --- /dev/null +++ b/crates/ghostty-macos/src/features/update/mod.rs @@ -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, + pub automatically_downloads_updates: Cell, + installing: Cell, +} + +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() + } +} diff --git a/crates/ghostty-macos/src/ghostty/action.rs b/crates/ghostty-macos/src/ghostty/action.rs new file mode 100644 index 0000000..64e2b83 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/action.rs @@ -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::(), 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, +} + +#[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, +} + +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 { + 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::(), 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 + ); + } +} diff --git a/crates/ghostty-macos/src/ghostty/app.rs b/crates/ghostty-macos/src/ghostty/app.rs new file mode 100644 index 0000000..212bb0e --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/app.rs @@ -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>> = RefCell::new(HashMap::new()); +} + +fn registered(addr: usize) -> Option> { + 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>>, + /// The readiness value of the state. + readiness: Published, + /// 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>, + /// Preferred config file than the default ones. + config_path: Option, + /// The ghostty app instance. We only have one of these for the entire app. + handle: Cell, + /// The AppKit observers (removed on drop). + observers: RefCell>, +} + +impl App { + /// `init(configPath:)`. + #[must_use] + pub fn new(config_path: Option<&str>) -> Rc { + // 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) { + *self.delegate.borrow_mut() = Some(delegate); + } + + fn delegate(&self) -> Option> { + 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 { + self.readiness.clone() + } + + /// `config`. + #[must_use] + pub fn config(&self) -> Rc { + self.config.get() + } + + /// `$config`. + #[must_use] + pub fn config_published(&self) -> Published> { + self.config.clone() + } + + /// `app` (the `ghostty_app_t`), if created. + #[must_use] + pub fn app_ptr(&self) -> Option { + 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::().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(¬ification, true); + } else if action_identifier == dismiss_id { + // The user dismissed the notification + surface.handle_user_notification(¬ification, 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> { + let s = user_info + .objectForKey(&NSString::from_str("surface"))? + .downcast::() + .ok()?; + NSUUID::initWithUUIDString(NSUUID::alloc(), &s) +} + +// MARK: Pointer helpers + +/// `Unmanaged.fromOpaque(userdata!).takeUnretainedValue()` +/// (retained for the duration of the use). +fn surface_userdata(userdata: *mut c_void) -> Option> { + // 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(from: surface)`. +fn surface_view(surface: ghostty_surface_t) -> Option> { + // 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> { + 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.fromOpaque(ghostty_app_userdata(app)).takeUnretainedValue()`. +fn app_from_handle(app: ghostty_app_t) -> Option> { + // 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) -> Sender { + Sender::new(Retained::as_ptr(view) as usize, view.clone()) +} + +fn post(name: Name, view: Option<&Retained>, user_info: UserInfo) { + NotificationCenter::default_center().post(name, view.map(sender), user_info); +} + +fn app_delegate() -> Option> { + AppDelegate::shared(crate::helpers::main_thread()) +} + +fn base_controller(view: &SurfaceView) -> Option> { + view.window()? + .windowController()? + .downcast::() + .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> { + 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 { + 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 = 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> = 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> { + 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 { + // 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> = NSApplication::sharedApplication(mtm) + .windows() + .iter() + .filter(|window| { + let is_terminal = window + .windowController() + .is_some_and(|c| c.downcast::().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::().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, + 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| { + // 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::() 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::().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::(), 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)), + ); +} diff --git a/crates/ghostty-macos/src/ghostty/capi/consts.rs b/crates/ghostty-macos/src/ghostty/capi/consts.rs new file mode 100644 index 0000000..deb7310 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/capi/consts.rs @@ -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; diff --git a/crates/ghostty-macos/src/ghostty/capi/mod.rs b/crates/ghostty-macos/src/ghostty/capi/mod.rs new file mode 100644 index 0000000..5d755f5 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/capi/mod.rs @@ -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() +} diff --git a/crates/ghostty-macos/src/ghostty/command.rs b/crates/ghostty-macos/src/ghostty/command.rs new file mode 100644 index 0000000..cc0a082 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/command.rs @@ -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()); + } +} diff --git a/crates/ghostty-macos/src/ghostty/config.rs b/crates/ghostty-macos/src/ghostty/config.rs new file mode 100644 index 0000000..f3bbbf0 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/config.rs @@ -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`. +//! +//! 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, +} + +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 { + 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 { + 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(&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::(), + 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(&self, key: &str, init: T) -> Option { + 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?`). + fn get_string(&self, key: &str) -> Option { + 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 { + 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::("bell-features", 0) + .map_or_else(BellFeatures::empty, BellFeatures::from_bits_retain) + } + + #[must_use] + pub fn bell_audio_path(&self) -> Option { + 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::("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::("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 { + 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 { + if !self.loaded() { + return None; + } + self.get_value::("window-position-x", 0) + } + + #[must_use] + pub fn window_position_y(&self) -> Option { + if !self.loaded() { + return None; + } + self.get_value::("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 { + 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` once `helpers::fullscreen` exists. + #[must_use] + pub fn window_fullscreen(&self) -> Option { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 = 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 { + 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('~')); + } +} diff --git a/crates/ghostty-macos/src/ghostty/config_types.rs b/crates/ghostty-macos/src/ghostty/config_types.rs new file mode 100644 index 0000000..a23396c --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/config_types.rs @@ -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` 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 { + 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 { + 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()); + } +} diff --git a/crates/ghostty-macos/src/ghostty/duration_format.rs b/crates/ghostty-macos/src/ghostty/duration_format.rs new file mode 100644 index 0000000..0924663 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/duration_format.rs @@ -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" + ); + } +} diff --git a/crates/ghostty-macos/src/ghostty/error.rs b/crates/ghostty-macos/src/ghostty/error.rs new file mode 100644 index 0000000..2cb3f14 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/error.rs @@ -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 {} diff --git a/crates/ghostty-macos/src/ghostty/event.rs b/crates/ghostty-macos/src/ghostty/event.rs new file mode 100644 index 0000000..4960476 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/event.rs @@ -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(), + } + } +} diff --git a/crates/ghostty-macos/src/ghostty/fullscreen_mode.rs b/crates/ghostty-macos/src/ghostty/fullscreen_mode.rs new file mode 100644 index 0000000..289121d --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/fullscreen_mode.rs @@ -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 { + 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); + } +} diff --git a/crates/ghostty-macos/src/ghostty/input/mod.rs b/crates/ghostty-macos/src/ghostty/input/mod.rs new file mode 100644 index 0000000..d534a6b --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/input/mod.rs @@ -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 { + 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 { + 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); + } +} diff --git a/crates/ghostty-macos/src/ghostty/mod.rs b/crates/ghostty-macos/src/ghostty/mod.rs new file mode 100644 index 0000000..a767ad1 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/mod.rs @@ -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; diff --git a/crates/ghostty-macos/src/ghostty/notification.rs b/crates/ghostty-macos/src/ghostty/notification.rs new file mode 100644 index 0000000..4174d9a --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/notification.rs @@ -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`). +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" + ); + } +} diff --git a/crates/ghostty-macos/src/ghostty/ns_event.rs b/crates/ghostty-macos/src/ghostty/ns_event.rs new file mode 100644 index 0000000..e8c0752 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/ns_event.rs @@ -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, +) -> 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 { + // 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, +) -> Option { + 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")); + } +} diff --git a/crates/ghostty-macos/src/ghostty/package.rs b/crates/ghostty-macos/src/ghostty/package.rs new file mode 100644 index 0000000..bbfcbf5 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/package.rs @@ -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::(), 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 { + 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::(), 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 { + 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 { + 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.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 { + 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 { + 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), +} + +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 { + 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 { + 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()); + } +} diff --git a/crates/ghostty-macos/src/ghostty/shell.rs b/crates/ghostty-macos/src/ghostty/shell.rs new file mode 100644 index 0000000..186901a --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/shell.rs @@ -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\\}"), + ("", "\\"), + ("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:?}"); + } + } +} diff --git a/crates/ghostty-macos/src/ghostty/surface.rs b/crates/ghostty-macos/src/ghostty/surface.rs new file mode 100644 index 0000000..d2d5a0f --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/surface.rs @@ -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 { + 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) }; + }); + } +} diff --git a/crates/ghostty-macos/src/ghostty/surface_configuration.rs b/crates/ghostty-macos/src/ghostty/surface_configuration.rs new file mode 100644 index 0000000..4a0759e --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/surface_configuration.rs @@ -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, + /// Explicit working directory to set. + pub working_directory: Option, + /// Explicit command to set. + pub command: Option, + /// Environment variables to set for the terminal. + pub environment_variables: BTreeMap, + /// Extra input to send as stdin. + pub initial_input: Option, + /// 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 { + 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( + &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| 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 = self + .environment_variables + .keys() + .map(|k| to_cstring(k)) + .collect(); + let values: Vec = self + .environment_variables + .values() + .map(|v| to_cstring(v)) + .collect(); + let mut env_vars: Vec = 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::(); + 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); + }); + } +} diff --git a/crates/ghostty-macos/src/ghostty/surface_view.rs b/crates/ghostty-macos/src/ghostty/surface_view.rs new file mode 100644 index 0000000..a4a6d71 --- /dev/null +++ b/crates/ghostty-macos/src/ghostty/surface_view.rs @@ -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, + pub selected: Published>, + pub total: Published>, +} + +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, + /// `surfaceModel` (its C value is `surface`). + surface_model: RefCell>, + + /// `title`: the title as defined by the pty (or set manually). + pub title: Published, + /// `pwd`. + pub pwd: Published>, + /// `cellSize`. + pub cell_size: Published, + /// `healthy` (renderer health). + pub healthy: Published, + /// `hoverUrl`. + pub hover_url: Published>, + /// `progressReport`; setting it (re)starts the 15s expiry timer. + pub progress_report: Published>, + /// `keySequence`. + pub key_sequence: Published>, + /// `keyTables`. + pub key_tables: Published>, + /// `searchState`. + pub search_state: Published>>, + /// `pointerStyle`. + pub pointer_style: Published, + /// `cursorVisible`. + pub cursor_visible: Published, + /// `bell`. + pub bell: Published, + /// `readonly`. + pub readonly: Published, + /// `initialSize`. + pub initial_size: Cell>, + /// `passwordInput`. + password_input: Cell, + /// `scrollbar`. + pub scrollbar: RefCell>, + /// `notificationIdentifiers`. + pub notification_identifiers: RefCell>, + /// `focused` (default true, like Swift). + focused: Cell, + + title_change_timer: RefCell>>, + title_fallback_timer: RefCell>>, + progress_report_timer: RefCell>>, + /// `titleFromTerminal`. + title_from_terminal: RefCell>, + + // Keeps the `title` observer (which cancels the fallback timer) alive. + _title_sub: RefCell>, +} + +impl SurfaceViewIvars { + fn new(id: Retained) -> 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 { + 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 = 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) { + *self.ivars().surface_model.borrow_mut() = surface; + } + + /// `surface`: the underlying C value. + #[must_use] + pub fn surface(&self) -> Option { + 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) { + 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) { + self.ivars().initial_size.set(size); + } + + /// `hoverUrl = ...`. + pub fn set_hover_url(&self, url: Option) { + self.ivars().hover_url.set(url); + } + + /// `progressReport = ...`: also restarts the 15 second expiry timer. + pub fn set_progress_report(&self, report: Option) { + // 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| { + 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:` (debounced 300ms when 1-2 chars) and the + /// `end_search` binding action when cleared. + pub fn set_search_state(&self, state: Option>) { + 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> { + 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| { + 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 = + Retained::into_super(Retained::into_super(NSString::from_str(&self.id_string()))); + let focus_val: Retained = Retained::into_super(Retained::into_super( + Retained::into_super(NSNumber::new_bool(require_focus)), + )); + let user_info: Retained> = + 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::(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| { + 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) { + // 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| { + 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) }; + } + } +} diff --git a/crates/ghostty-macos/src/helpers/any_sort_key.rs b/crates/ghostty-macos/src/helpers/any_sort_key.rs new file mode 100644 index 0000000..a2a1d3e --- /dev/null +++ b/crates/ghostty-macos/src/helpers/any_sort_key.rs @@ -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 Ordering>; + +#[derive(Clone)] +pub struct AnySortKey { + value: Rc, + comparator: Comparator, +} + +impl AnySortKey { + pub fn new(value: T) -> Self { + Self { + value: Rc::new(value), + comparator: Rc::new(|lhs, rhs| { + let (Some(l), Some(r)) = (lhs.downcast_ref::(), rhs.downcast_ref::()) 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 { + 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)); + } +} diff --git a/crates/ghostty-macos/src/helpers/app_info.rs b/crates/ghostty-macos/src/helpers/app_info.rs new file mode 100644 index 0000000..e30c1db --- /dev/null +++ b/crates/ghostty-macos/src/helpers/app_info.rs @@ -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 { + use objc2_foundation::{NSBundle, NSNumber, NSString}; + let info = NSBundle::mainBundle().infoDictionary()?; + let value = info.objectForKey(&NSString::from_str(key))?; + value.downcast_ref::().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)); + } +} diff --git a/crates/ghostty-macos/src/helpers/codable_bridge.rs b/crates/ghostty-macos/src/helpers/codable_bridge.rs new file mode 100644 index 0000000..59a6afb --- /dev/null +++ b/crates/ghostty-macos/src/helpers/codable_bridge.rs @@ -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; + fn decode(bytes: &[u8]) -> Option; +} + +/// 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(coder: &NSCoder) -> Option { + // SAFETY: the class passed matches the expected NSData. + let data: Retained = + unsafe { coder.decodeObjectOfClass_forKey(NSData::class(), &NSString::from_str(DATA_KEY)) } + .and_then(|o| o.downcast::().ok())?; + T::decode(&data.to_vec()) +} + +/// `CodableBridge.encode(with:)`. +pub fn encode_to_coder(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)); + } +} diff --git a/crates/ghostty-macos/src/helpers/color.rs b/crates/ghostty-macos/src/helpers/color.rs new file mode 100644 index 0000000..e7ff5e9 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/color.rs @@ -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, + ) + } +} diff --git a/crates/ghostty-macos/src/helpers/cross_kit.rs b/crates/ghostty-macos/src/helpers/cross_kit.rs new file mode 100644 index 0000000..4d3b5a5 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/cross_kit.rs @@ -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; diff --git a/crates/ghostty-macos/src/helpers/cursor.rs b/crates/ghostty-macos/src/helpers/cursor.rs new file mode 100644 index 0000000..533f1d8 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/cursor.rs @@ -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 = 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 { + // 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(), + } + } +} diff --git a/crates/ghostty-macos/src/helpers/cursor_counter.rs b/crates/ghostty-macos/src/helpers/cursor_counter.rs new file mode 100644 index 0000000..6dc0c94 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/cursor_counter.rs @@ -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()); + } +} diff --git a/crates/ghostty-macos/src/helpers/dispatch.rs b/crates/ghostty-macos/src/helpers/dispatch.rs new file mode 100644 index 0000000..ac5d8c1 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/dispatch.rs @@ -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); + +// 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 Send for MainOnly {} + +/// `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)(); + }); +} diff --git a/crates/ghostty-macos/src/helpers/expiring_undo_manager.rs b/crates/ghostty-macos/src/helpers/expiring_undo_manager.rs new file mode 100644 index 0000000..4c551fe --- /dev/null +++ b/crates/ghostty-macos/src/helpers/expiring_undo_manager.rs @@ -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>>, + /// Timer that triggers expiration after the specified duration. + timer: RefCell>>, + /// The undo manager from which to remove actions when this target expires. + undo_manager: RefCell>>, +} + +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 { + 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 = 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| { + 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> { + 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>>, +} + +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::(), target_ptr)); + if is_expiring { + self.ivars() + .expiring_targets + .borrow_mut() + .retain(|e| !std::ptr::eq((&**e as *const ExpiringTarget).cast::(), target_ptr)); + } else { + // Find and remove any ExpiringTarget instances that wrap this target. + let matching: Vec> = 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 { + 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| { + // 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::(), + raw as *const AnyObject, + ) + }); + } + // SAFETY: `raw` is the ExpiringTarget we registered. + #[allow(unsafe_code)] + let expiring: &ExpiringTarget = + unsafe { &*(raw as *const AnyObject).cast::() }; + 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); + } + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/array.rs b/crates/ghostty-macos/src/helpers/extensions/array.rs new file mode 100644 index 0000000..cfd7dc3 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/array.rs @@ -0,0 +1,80 @@ +//! `Helpers/Extensions/Array+Extension.swift`. + +use std::ffi::CString; + +/// `extension Array`. +pub trait ArrayExt { + /// `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 ArrayExt 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.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>(strings: &[S]) -> Vec { + 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); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/double.rs b/crates/ghostty-macos/src/helpers/extensions/double.rs new file mode 100644 index 0000000..e03bd35 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/double.rs @@ -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; +} + +impl DoubleExt for f64 { + fn clamped_to(self, range: RangeInclusive) -> 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); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/duration.rs b/crates/ghostty-macos/src/helpers/extensions/duration.rs new file mode 100644 index 0000000..469ab8c --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/duration.rs @@ -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); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/event_modifiers.rs b/crates/ghostty-macos/src/helpers/extensions/event_modifiers.rs new file mode 100644 index 0000000..43f07d3 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/event_modifiers.rs @@ -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); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/file_handle.rs b/crates/ghostty-macos/src/helpers/extensions/file_handle.rs new file mode 100644 index 0000000..90e7550 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/file_handle.rs @@ -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 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()); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/mod.rs b/crates/ghostty-macos/src/helpers/extensions/mod.rs new file mode 100644 index 0000000..b1da96e --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/mod.rs @@ -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; diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_appearance.rs b/crates/ghostty-macos/src/helpers/extensions/ns_appearance.rs new file mode 100644 index 0000000..0edc329 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_appearance.rs @@ -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> { + 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, + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_application.rs b/crates/ghostty-macos/src/helpers/extensions/ns_application.rs new file mode 100644 index 0000000..2e9144e --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_application.rs @@ -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> = RefCell::new(HashMap::new()); +} + +/// Pure ref counting behind `acquire/releasePresentationOption`. +#[derive(Debug, Default)] +pub struct OptionCounts(HashMap); + +/// 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| 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); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_color.rs b/crates/ghostty-macos/src/helpers/extensions/ns_color.rs new file mode 100644 index 0000000..feb8f34 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_color.rs @@ -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> = + 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> { + 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 { + 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> { + 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 { + 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; + /// 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; + /// `OSColor.darken(by:)`. + fn darken(&self, amount: f64) -> Retained; +} + +#[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> { + color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace()) +} + +impl NSColorExt for NSColor { + fn adjusting_saturation(&self, factor: f64) -> Retained { + 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 { + let rgb = self.colorUsingColorSpace(&NSColorSpace::deviceRGBColorSpace())?; + Some(os_color::hex_string( + rgb.redComponent(), + rgb.greenComponent(), + rgb.blueComponent(), + )) + } + + fn darken(&self, amount: f64) -> Retained { + 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"); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_image.rs b/crates/ghostty-macos/src/helpers/extensions/ns_image.rs new file mode 100644 index 0000000..b7b78ef --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_image.rs @@ -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> { + // 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]) -> Option>; + /// Tint an NSImage with the given color by applying a basic fill on top of it. + fn tint(&self, color: &NSColor) -> Option>; +} + +/// `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], + blending_modes: &[CGBlendMode], +) -> Option> { + 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]) -> Option> { + 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> { + 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) + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_menu.rs b/crates/ghostty-macos/src/helpers/extensions/ns_menu.rs new file mode 100644 index 0000000..280801f --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_menu.rs @@ -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; + + /// Removes all menu items whose identifier is in the given set. + fn remove_items_with_identifiers(&self, identifiers: &HashSet); +} + +impl NSMenuExt for NSMenu { + fn insert_item_after(&self, item: &NSMenuItem, action: Sel) -> Option { + 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) { + 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); + } + } + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_menu_item.rs b/crates/ghostty-macos/src/helpers/extensions/ns_menu_item.rs new file mode 100644 index 0000000..194891a --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_menu_item.rs @@ -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> = + NSImage::imageWithSystemSymbolName_accessibilityDescription( + &NSString::from_str(system_symbol_name), + Some(&title), + ); + self.setImage(image.as_deref()); + } + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_notification.rs b/crates/ghostty-macos/src/helpers/extensions/ns_notification.rs new file mode 100644 index 0000000..e0edbc1 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_notification.rs @@ -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, + token: Retained>, +} + +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| { + // 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 } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_pasteboard.rs b/crates/ghostty-macos/src/helpers/extensions/ns_pasteboard.rs new file mode 100644 index 0000000..bb8c9e2 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_pasteboard.rs @@ -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 { + // 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::pasteboardWithName(&NSString::from_str("com.mitchellh.ghostty.selection")); +} + +/// The pasteboard to used for Ghostty selection. +#[must_use] +pub fn ghostty_selection() -> Retained { + GHOSTTY_SELECTION.with(Clone::clone) +} + +/// `NSPasteboard.ghostty(_:)`: the pasteboard for the Ghostty enum type. +#[must_use] +pub fn ghostty(clipboard: c_int) -> Option> { + 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; +} + +impl NSPasteboardExt for NSPasteboard { + #[allow(unsafe_code)] + fn get_opinionated_string_contents(&self) -> Option { + let classes = NSArray::from_slice(&[NSURL::class()]); + // SAFETY: `readObjectsForClasses:options:` takes an array of classes. + let objects: Option>> = unsafe { + msg_send![self, readObjectsForClasses: &*classes, options: std::ptr::null::()] + }; + if let Some(objects) = objects { + let urls: Vec = objects + .iter() + .filter_map(|o| o.downcast::().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); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_screen.rs b/crates/ghostty-macos/src/helpers/extensions/ns_screen.rs new file mode 100644 index 0000000..cdc0b5c --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_screen.rs @@ -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; + /// The stable UUID for this display, suitable for tracking across + /// reconnects and NSScreen garbage collection. + fn display_uuid(&self) -> Option>; + /// 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 { + let description = self.deviceDescription(); + let key = NSString::from_str("NSScreenNumber"); + let value = description.objectForKey(&key)?; + value + .downcast_ref::() + .map(NSNumber::unsignedIntValue) + } + + #[allow(unsafe_code)] + fn display_uuid(&self) -> Option> { + 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 = 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::().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) + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_screen_logic.rs b/crates/ghostty-macos/src/helpers/extensions/ns_screen_logic.rs new file mode 100644 index 0000000..be0e6bc --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_screen_logic.rs @@ -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)); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_view.rs b/crates/ghostty-macos/src/helpers/extensions/ns_view.rs new file mode 100644 index 0000000..c36a5ad --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_view.rs @@ -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>; + /// Returns the absolute root view by walking up the superview chain. + fn root_view(&self) -> Retained; + /// 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>; + /// Recursively finds and returns the first descendant view that has the given class name. + fn first_descendant_with_class_name(&self, name: &str) -> Option>; + /// Recursively finds and returns descendant views that have the given class name. + fn descendants_with_class_name(&self, name: &str) -> Vec>; + /// Recursively finds and returns the first descendant view that has the given identifier. + fn first_descendant_with_id(&self, id: &str) -> Option>; + /// 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>; + /// 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> = + self.window().and_then(|w| w.firstResponder()); + while let Some(current) = responder { + if std::ptr::eq( + (&*current as *const NSResponder).cast::(), + (self as *const NSView).cast::(), + ) { + return true; + } + // SAFETY: plain getter on a live responder. + responder = unsafe { current.nextResponder() }; + } + false + } + + fn screenshot(&self) -> Option> { + 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 { + 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> { + // 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> { + 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> { + 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> { + 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> { + 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 = 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 + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_window.rs b/crates/ghostty-macos/src/helpers/extensions/ns_window.rs new file mode 100644 index 0000000..6ffa088 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_window.rs @@ -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; + /// 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>; + /// Returns the [private] NSTabBar view, if it exists. + fn tab_bar_view(&self) -> Option>; + /// Returns tab button views in visual order from left to right. + fn tab_buttons_in_visual_order(&self) -> Vec>; + /// 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)>; + /// Returns the index of the tab button at the given screen point, if any. + fn tab_index(&self, screen_point: NSPoint) -> Option; +} + +impl NSWindowExt for NSWindow { + fn cg_window_id(&self) -> Option { + ns_window_logic::cg_window_id(self.windowNumber()) + } + + fn constrain_to_screen(&self) { + let screen: Option> = 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> { + 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> = unsafe { + msg_send![&*theme_frame_view, valueForKey: &*NSString::from_str("titlebarView")] + }; + value?.downcast::().ok() + } + + fn tab_bar_view(&self) -> Option> { + self.titlebar_view()? + .first_descendant_with_class_name("NSTabBar") + } + + fn tab_buttons_in_visual_order(&self) -> Vec> { + 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)> { + 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 { + 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 +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_window_logic.rs b/crates/ghostty-macos/src/helpers/extensions/ns_window_logic.rs new file mode 100644 index 0000000..3bea385 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_window_logic.rs @@ -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 { + 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 { + 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)); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/ns_workspace.rs b/crates/ghostty-macos/src/helpers/extensions/ns_workspace.rs new file mode 100644 index 0000000..3d94180 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/ns_workspace.rs @@ -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>; + + /// The URL of the default terminal (Unix Executable) application, if any. + fn default_terminal(&self) -> Option>; + + /// 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>; + + /// The URL of the default application for opening files with the + /// specified file extension. + fn default_application_url_for_extension(&self, ext: &str) -> Option>; +} + +impl NSWorkspaceExt for NSWorkspace { + fn default_text_editor(&self) -> Option> { + self.default_application_url_for_content_type(uti::PLAIN_TEXT) + } + + fn default_terminal(&self) -> Option> { + 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> { + 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::()) } + } + + fn default_application_url_for_extension(&self, ext: &str) -> Option> { + let uti = uti::identifier_for_extension(ext)?; + self.default_application_url_for_content_type(&uti) + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/object_identifier.rs b/crates/ghostty-macos/src/helpers/extensions/object_identifier.rs new file mode 100644 index 0000000..2fcf96f --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/object_identifier.rs @@ -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"); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/optional.rs b/crates/ghostty-macos/src/helpers/extensions/optional.rs new file mode 100644 index 0000000..d37abee --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/optional.rs @@ -0,0 +1,34 @@ +//! `Helpers/Extensions/Optional+Extension.swift`. + +use std::ffi::{CString, c_char}; + +/// `Optional.withCString`: run `body` with a NUL terminated pointer, +/// or null for `None`. +pub fn with_c_string(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)); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/os_color.rs b/crates/ghostty-macos/src/helpers/extensions/os_color.rs new file mode 100644 index 0000000..4d50fe0 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/os_color.rs @@ -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)); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/string.rs b/crates/ghostty-macos/src/helpers/extensions/string.rs new file mode 100644 index 0000000..ea59e04 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/string.rs @@ -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 `/.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); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/undo_manager.rs b/crates/ghostty-macos/src/helpers/extensions/undo_manager.rs new file mode 100644 index 0000000..3f1c7be --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/undo_manager.rs @@ -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(); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/user_defaults.rs b/crates/ghostty-macos/src/helpers/extensions/user_defaults.rs new file mode 100644 index 0000000..747ebf7 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/user_defaults.rs @@ -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 { + 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 { + 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> = defaults + .iter() + .map(|(k, _)| NSString::from_str(k)) + .collect(); + let values: Vec> = 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)); + } +} diff --git a/crates/ghostty-macos/src/helpers/extensions/uti.rs b/crates/ghostty-macos/src/helpers/extensions/uti.rs new file mode 100644 index 0000000..3147fa9 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/uti.rs @@ -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 { + 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 = 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 { + // 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 { + // SAFETY: reading an immutable constant exported by CoreServices. + identifier_for_tag(unsafe { kUTTagClassMIMEType }, mime).filter(|id| !id.starts_with("dyn.")) +} diff --git a/crates/ghostty-macos/src/helpers/extensions/uuid.rs b/crates/ghostty-macos/src/helpers/extensions/uuid.rs new file mode 100644 index 0000000..003cb3c --- /dev/null +++ b/crates/ghostty-macos/src/helpers/extensions/uuid.rs @@ -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> { + let s = CFUUID::new_string(None, Some(cfuuid))?; + NSUUID::initWithUUIDString(NSUUID::alloc(), &NSString::from_str(&s.to_string())) +} diff --git a/crates/ghostty-macos/src/helpers/fullscreen.rs b/crates/ghostty-macos/src/helpers/fullscreen.rs new file mode 100644 index 0000000..16fe73a --- /dev/null +++ b/crates/ghostty-macos/src/helpers/fullscreen.rs @@ -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`). +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>); + 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> { + 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> = 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, + delegate: RefCell>>, + observers: RefCell>, +} + +impl FullscreenBase { + fn new(window: &NSWindow) -> Rc { + 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, +} + +impl NativeFullscreen { + #[must_use] + pub fn new(window: &NSWindow) -> Rc { + // 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>) { + *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, + tab_group: Option>, + tab_group_index: Option, + content_frame: NSRect, + style_mask: NSWindowStyleMask, + toolbar: Option>, + toolbar_style: NSWindowToolbarStyle, + titlebar_accessory_view_controllers: Vec>, + dock: bool, + menu: bool, +} + +impl SavedState { + fn new(window: &NSWindow) -> Option { + 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, + mode: FullscreenMode, + saved_state: RefCell>, + screen_observer: RefCell>, + close_observer: RefCell>, +} + +/// Non-native fullscreen, with the variants selected by [`FullscreenMode`] +/// (`NonNativeFullscreen`, `...VisibleMenu`, `...PaddedNotch`). +pub struct NonNativeFullscreen { + inner: Rc, +} + +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 { + 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 { + &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) { + // 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> = 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> = 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>) { + *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(); + } +} diff --git a/crates/ghostty-macos/src/helpers/fullscreen_logic.rs b/crates/ghostty-macos/src/helpers/fullscreen_logic.rs new file mode 100644 index 0000000..ac659bf --- /dev/null +++ b/crates/ghostty-macos/src/helpers/fullscreen_logic.rs @@ -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 { + 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); + } +} diff --git a/crates/ghostty-macos/src/helpers/geometry.rs b/crates/ghostty-macos/src/helpers/geometry.rs new file mode 100644 index 0000000..a43941e --- /dev/null +++ b/crates/ghostty-macos/src/helpers/geometry.rs @@ -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() + } +} diff --git a/crates/ghostty-macos/src/helpers/keyboard_layout.rs b/crates/ghostty-macos/src/helpers/keyboard_layout.rs new file mode 100644 index 0000000..e4680ec --- /dev/null +++ b/crates/ghostty-macos/src/helpers/keyboard_layout.rs @@ -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 { + // 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 = CFRetained::from_raw(raw.cast()); + let ptr = TISGetInputSourceProperty(raw.as_ptr(), kTISPropertyInputSourceID); + let id = (!ptr.is_null()).then(|| (*ptr.cast::()).to_string()); + drop(source); + id + } + } +} diff --git a/crates/ghostty-macos/src/helpers/keyboard_shortcut.rs b/crates/ghostty-macos/src/helpers/keyboard_shortcut.rs new file mode 100644 index 0000000..a679feb --- /dev/null +++ b/crates/ghostty-macos/src/helpers/keyboard_shortcut.rs @@ -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 { + 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(), "⌫"); + } +} diff --git a/crates/ghostty-macos/src/helpers/last_window_position.rs b/crates/ghostty-macos/src/helpers/last_window_position.rs new file mode 100644 index 0000000..2c36905 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/last_window_position.rs @@ -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> = 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::() else { + return false; + }; + values.push(n.doubleValue()); + } + if values.len() < 2 { + return false; + } + + let screen: Option> = 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 + } +} diff --git a/crates/ghostty-macos/src/helpers/last_window_position_logic.rs b/crates/ghostty-macos/src/helpers/last_window_position_logic.rs new file mode 100644 index 0000000..53b4c0f --- /dev/null +++ b/crates/ghostty-macos/src/helpers/last_window_position_logic.rs @@ -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 { + 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]); + } +} diff --git a/crates/ghostty-macos/src/helpers/mod.rs b/crates/ghostty-macos/src/helpers/mod.rs new file mode 100644 index 0000000..5a6e9d5 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/mod.rs @@ -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` 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") +} diff --git a/crates/ghostty-macos/src/helpers/notification_center.rs b/crates/ghostty-macos/src/helpers/notification_center.rs new file mode 100644 index 0000000..af0f8c0 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/notification_center.rs @@ -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` and the +//! sender is a [`Sender`] (identity + a type-erased strong handle that +//! [`Sender::downcast_ref`] turns back into e.g. `Retained`). +//! +//! 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, +} + +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(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(&self) -> Option<&T> { + self.handle.downcast_ref::() + } +} + +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>); + +impl UserInfo { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Builder style insert. + #[must_use] + pub fn with(mut self, key: &'static str, value: T) -> Self { + self.insert(key, value); + self + } + + pub fn insert(&mut self, key: &'static str, value: T) { + self.0.insert(key, Rc::new(value)); + } + + /// `userInfo?[key] as? T`. + #[must_use] + pub fn get(&self, key: &str) -> Option<&T> { + self.0.get(key).and_then(|v| v.downcast_ref::()) + } + + #[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, + pub user_info: UserInfo, +} + +/// An observer callback. +pub type Handler = Rc; + +struct Observer { + id: u64, + name: Name, + object: Option, + handler: Handler, +} + +#[derive(Default)] +struct Inner { + next_id: u64, + observers: Vec, +} + +/// 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>, +} + +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>, + 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, 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::()), + 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::("k").map(String::as_str), Some("v")); + assert_eq!(info.get::("n"), Some(&5)); + assert!(info.get::("n").is_none()); + assert!(info.get::("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>> = 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); + } +} diff --git a/crates/ghostty-macos/src/helpers/objc_exception_catcher.rs b/crates/ghostty-macos/src/helpers/objc_exception_catcher.rs new file mode 100644 index 0000000..bdf9e74 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/objc_exception_catcher.rs @@ -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::().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(), + ), + } + }) +} diff --git a/crates/ghostty-macos/src/helpers/private/cgs.rs b/crates/ghostty-macos/src/helpers/private/cgs.rs new file mode 100644 index 0000000..311c700 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/private/cgs.rs @@ -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, + ) -> *const CFArray; +} + +/// +/// 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 { + 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 { + 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> = 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 { + // 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)); + } +} diff --git a/crates/ghostty-macos/src/helpers/private/dock.rs b/crates/ghostty-macos/src/helpers/private/dock.rs new file mode 100644 index 0000000..7900281 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/private/dock.rs @@ -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 { + 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 { + 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); + } +} diff --git a/crates/ghostty-macos/src/helpers/private/mod.rs b/crates/ghostty-macos/src/helpers/private/mod.rs new file mode 100644 index 0000000..3a6d1ea --- /dev/null +++ b/crates/ghostty-macos/src/helpers/private/mod.rs @@ -0,0 +1,4 @@ +//! `Helpers/Private/{CGS,Dock}.swift`: private CoreGraphics / CoreDock APIs. + +pub mod cgs; +pub mod dock; diff --git a/crates/ghostty-macos/src/helpers/published.rs b/crates/ghostty-macos/src/helpers/published.rs new file mode 100644 index 0000000..8b4d0f2 --- /dev/null +++ b/crates/ghostty-macos/src/helpers/published.rs @@ -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 = Rc; + +struct Inner { + value: T, + next_id: u64, + subscribers: Vec<(u64, Callback)>, +} + +/// `@Published var value: T`. Cloning shares the same cell. +pub struct Published { + inner: Rc>>, +} + +impl Clone for Published { + 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>, +} + +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 Published { + 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> = { + 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 = 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(¤t); + 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); + } +} diff --git a/crates/ghostty-macos/src/helpers/vibrant_layer.rs b/crates/ghostty-macos/src/helpers/vibrant_layer.rs new file mode 100644 index 0000000..bc44eab --- /dev/null +++ b/crates/ghostty-macos/src/helpers/vibrant_layer.rs @@ -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] + pub struct VibrantLayer; + + impl VibrantLayer { + // SAFETY: matches `- (id)compositingFilter`. + #[unsafe(method_id(compositingFilter))] + fn compositing_filter(&self) -> Option> { + 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::(name) }) + } + } +); + +impl VibrantLayer { + /// `init(forAppearance:)`. + #[must_use] + pub fn init_for_appearance(ty: VibrantLayerType) -> Retained { + 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"); + } +} diff --git a/crates/ghostty-macos/src/lib.rs b/crates/ghostty-macos/src/lib.rs new file mode 100644 index 0000000..2bef407 --- /dev/null +++ b/crates/ghostty-macos/src/lib.rs @@ -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/` | [`features`]`::` | +//! +//! 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; diff --git a/crates/ghostty-macos/src/main.rs b/crates/ghostty-macos/src/main.rs new file mode 100644 index 0000000..b7d2a55 --- /dev/null +++ b/crates/ghostty-macos/src/main.rs @@ -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); +} diff --git a/docs/rewrite/DEVIANT_FIXES.md b/docs/rewrite/DEVIANT_FIXES.md index 515b591..bc1b235 100644 --- a/docs/rewrite/DEVIANT_FIXES.md +++ b/docs/rewrite/DEVIANT_FIXES.md @@ -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 | diff --git a/docs/rewrite/notes/macos-app-MA1.md b/docs/rewrite/notes/macos-app-MA1.md new file mode 100644 index 0000000..33c52da --- /dev/null +++ b/docs/rewrite/notes/macos-app-MA1.md @@ -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. diff --git a/docs/rewrite/notes/macos-app-design.md b/docs/rewrite/notes/macos-app-design.md new file mode 100644 index 0000000..cf0f96f --- /dev/null +++ b/docs/rewrite/notes/macos-app-design.md @@ -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/.rs`, one extension trait `XExt` per Swift extension) | +| `Features//**` | `features//**` | + +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>` filled by `main_menu::install` | +| `Ghostty.App` (`ObservableObject`) | `Rc`; `@Published` -> `helpers::published::Published` | +| `Ghostty.Config` (owns `ghostty_config_t`) | `ghostty::config::Config` (`Drop` frees; shared as `Rc`) | +| `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` | `objc2::rc::Weak` (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`, the sender (`object`) is a `Sender` (identity + downcastable handle, + e.g. `Retained`). 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` (`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` 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//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-.md`, keep `just check-macos` green, list deviations. diff --git a/scripts/gen-macos-capi-consts.py b/scripts/gen-macos-capi-consts.py new file mode 100644 index 0000000..749cf6f --- /dev/null +++ b/scripts/gen-macos-capi-consts.py @@ -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)) diff --git a/scripts/gen-macos-menu-spec.py b/scripts/gen-macos-menu-spec.py new file mode 100644 index 0000000..921b009 --- /dev/null +++ b/scripts/gen-macos-menu-spec.py @@ -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("];") diff --git a/scripts/macos-bundle.sh b/scripts/macos-bundle.sh new file mode 100755 index 0000000..fe5afa0 --- /dev/null +++ b/scripts/macos-bundle.sh @@ -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/) +# --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" diff --git a/scripts/macos-crosscheck.sh b/scripts/macos-crosscheck.sh index d16cbff..c68c313 100755 --- a/scripts/macos-crosscheck.sh +++ b/scripts/macos-crosscheck.sh @@ -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