diff --git a/Cargo.lock b/Cargo.lock index 1d7280c..cd755e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1611,27 +1611,6 @@ 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" @@ -2791,7 +2770,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", - "objc2-exception-helper", ] [[package]] @@ -2876,16 +2854,6 @@ 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" @@ -2921,15 +2889,6 @@ 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" @@ -2988,19 +2947,6 @@ 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 deleted file mode 100644 index 8b632ca..0000000 --- a/crates/ghostty-macos/Cargo.toml +++ /dev/null @@ -1,84 +0,0 @@ -[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 deleted file mode 100644 index c0a480b..0000000 --- a/crates/ghostty-macos/scratch.log +++ /dev/null @@ -1,815 +0,0 @@ - Compiling ghostty-build-info v0.1.0 (../../crates/ghostty-build-info) - Checking ghostty-config v0.1.0 (../../crates/ghostty-config) - Checking ghostty-font v0.1.0 (../../crates/ghostty-font) - Checking ghostty-renderer v0.1.0 (../../crates/ghostty-renderer) - Checking ghostty-inspector v0.1.0 (../../crates/ghostty-inspector) - Checking ghostty-apprt v0.1.0 (../../crates/ghostty-apprt) - Checking ghostty-termio v0.1.0 (../../crates/ghostty-termio) - Checking ghostty-cli v0.1.0 (../../crates/ghostty-cli) - Checking ghostty-core v0.1.0 (../../crates/ghostty-core) - Checking ghostty-lib v0.1.0 (../../crates/ghostty-lib) - Checking ghostty-macos v0.1.0 (../../crates/ghostty-macos) -error[E0432]: unresolved import `crate::ghostty::capi::c_int` - --> crates/ghostty-macos/src/ghostty/surface_view.rs:52:5 - | -52 | c_int, ghostty_surface_needs_confirm_quit, - | ^^^^^ no `c_int` in `ghostty::capi` - | - = help: consider importing one of these type aliases instead: - std::ffi::c_int - std::os::raw::c_int - core::ffi::c_int - libc::c_int - -error[E0432]: unresolved import `crate::ghostty::capi::c_int` - --> crates/ghostty-macos/src/ghostty/surface_view.rs:52:5 - | -52 | c_int, ghostty_surface_needs_confirm_quit, - | ^^^^^ no `c_int` in `ghostty::capi` - | - = help: consider importing one of these type aliases instead: - std::ffi::c_int - std::os::raw::c_int - core::ffi::c_int - libc::c_int - = note: type alias `crate::helpers::extensions::ns_pasteboard::tests::c_int` exists but is inaccessible - -error[E0425]: cannot find function `remove_persistent_domain_for_ghostty_suite` in module `user_defaults` - --> crates/ghostty-macos/src/app/app_delegate.rs:227:40 - | -227 | user_defaults::remove_persistent_domain_for_ghostty_suite(); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `user_defaults` - -error[E0425]: cannot find function `register_defaults` in module `user_defaults` - --> crates/ghostty-macos/src/app/app_delegate.rs:231:28 - | -231 | user_defaults::register_defaults(&[ - | ^^^^^^^^^^^^^^^^^ not found in `user_defaults` - -error[E0425]: cannot find function `register_defaults` in module `user_defaults` - --> crates/ghostty-macos/src/app/app_delegate.rs:670:24 - | -670 | user_defaults::register_defaults(&[ - | ^^^^^^^^^^^^^^^^^ not found in `user_defaults` - -error[E0425]: cannot find function `name_window_did_become_key` in module `ns_notification` - --> crates/ghostty-macos/src/app/app_delegate.rs:1072:30 - | -1072 | ns_notification::name_window_did_become_key(), - | ^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `ns_notification` - -error[E0425]: cannot find value `TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION` in module `gn` - --> crates/ghostty-macos/src/app/app_delegate.rs:1104:38 - | -1104 | bus.push(nc.add_observer(gn::TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION, None, move |n| { - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `gn` - -error[E0425]: cannot find function `bundle_info_bool` in module `crate::helpers::app_info` - --> crates/ghostty-macos/src/app/app_delegate.rs:1321:38 - | -1321 | if crate::helpers::app_info::bundle_info_bool("SUEnableAutomaticChecks") == Some(false) { - | ^^^^^^^^^^^^^^^^ not found in `crate::helpers::app_info` - -error[E0425]: cannot find function `post_icon_did_change` in module `crate::features::custom_app_icon` - --> crates/ghostty-macos/src/app/app_delegate.rs:1416:43 - | -1416 | crate::features::custom_app_icon::post_icon_did_change(); - | ^^^^^^^^^^^^^^^^^^^^ not found in `crate::features::custom_app_icon` - -error[E0425]: cannot find function `set_default_application_for_unix_executable` in module `crate::helpers::extensions::ns_workspace` - --> crates/ghostty-macos/src/app/app_delegate.rs:1500:51 - | -1500 | crate::helpers::extensions::ns_workspace::set_default_application_for_unix_executable( - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::ns_workspace` - -error[E0425]: cannot find function `ns_flags_from_swiftui` in module `crate::helpers::extensions::event_modifiers` - --> crates/ghostty-macos/src/app/app_delegate.rs:1572:74 - | -1572 | let modifier_mask = crate::helpers::extensions::event_modifiers::ns_flags_from_swiftui(shortcut.modifiers); - | ^^^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::event_modifiers` - -error[E0425]: cannot find function `default_application_url_for_extension` in module `crate::helpers::extensions::ns_workspace` - --> crates/ghostty-macos/src/ghostty/app.rs:962:68 - | -962 | let editor = crate::helpers::extensions::ns_workspace::default_application_url_for_extension(&ext) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::ns_workspace` - -error[E0425]: cannot find value `default_text_editor` in module `crate::helpers::extensions::ns_workspace` - --> crates/ghostty-macos/src/ghostty/app.rs:963:68 - | -963 | .or_else(crate::helpers::extensions::ns_workspace::default_text_editor); - | ^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::ns_workspace` - -error[E0425]: cannot find function `from_ghostty` in module `fullscreen_mode` - --> crates/ghostty-macos/src/ghostty/app.rs:1141:39 - | -1141 | let Some(mode) = fullscreen_mode::from_ghostty(raw) else { - | ^^^^^^^^^^^^ not found in `fullscreen_mode` - | -help: consider importing this function - | - 20 + use crate::helpers::extensions::os_color::from_ghostty; - | -help: if you import `from_ghostty`, refer to it directly - | -1141 - let Some(mode) = fullscreen_mode::from_ghostty(raw) else { -1141 + let Some(mode) = from_ghostty(raw) else { - | - -warning: unused import: `NSNumber` - --> crates/ghostty-macos/src/app/app_delegate.rs:34:74 - | -34 | NSArray, NSCoder, NSDictionary, NSKeyValueChangeKey, NSNotification, NSNumber, NSObject, - | ^^^^^^^^ - | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default - -warning: unused import: `Sender` - --> crates/ghostty-macos/src/app/app_delegate.rs:76:92 - | -76 | use crate::helpers::notification_center::{Notification, NotificationCenter, ObserverToken, Sender}; - | ^^^^^^ - -error: usage of an `unsafe` block - --> crates/ghostty-macos/src/features/quick_terminal/controller.rs:64:9 - | -64 | unsafe { msg_send![super(this), init] } - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - | - = note: requested on the command line with `-D unsafe-code` - -error: usage of an `unsafe` block - --> crates/ghostty-macos/src/features/services/mod.rs:26:9 - | -26 | unsafe { msg_send![this, init] } - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -warning: unused import: `NSPasteboardNameGeneral` - --> crates/ghostty-macos/src/ghostty/app.rs:36:19 - | -36 | NSPasteboard, NSPasteboardNameGeneral, NSPasteboardTypeString, - | ^^^^^^^^^^^^^^^^^^^^^^^ - -warning: unused import: `NSUserInterfaceItemSearching` - --> crates/ghostty-macos/src/app/app_delegate.rs:31:52 - | -31 | NSWindowLevel, NSWindowStyleMask, NSWorkspace, NSUserInterfaceItemSearching, - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default - -warning: unused import: `NSNumber` - --> crates/ghostty-macos/src/app/app_delegate.rs:34:74 - | -34 | NSArray, NSCoder, NSDictionary, NSKeyValueChangeKey, NSNotification, NSNumber, NSObject, - | ^^^^^^^^ - -error[E0308]: mismatched types - --> crates/ghostty-macos/src/app/app_delegate.rs:288:24 - | -199 | / define_class!( -200 | | // SAFETY: NSObject has no subclassing requirements; AppDelegate does not -201 | | // implement Drop. -202 | | #[unsafe(super(NSObject))] -... | -288 | | return true; - | | ^^^^ expected `Bool`, found `bool` -... | -523 | | ); - | |_- expected `objc2::runtime::Bool` because of return type - | -help: call `Into::into` on this expression to convert `bool` into `objc2::runtime::Bool` - | -288 | return true.into(); - | +++++++ - -error[E0599]: no method named `surface_tree` found for struct `Retained` 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 - | - ::: ~/.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 - --> ~/.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 - --> ~/.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 - --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/macros/extern_methods.rs:267:14 - | -267 | ($v fn $fn_name($($params)*) $(-> $ret)?) - | ^^ - | - ::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-app-kit-0.3.2/src/generated/NSColor.rs:112:5 - | -112 | / extern_methods!( -113 | | #[unsafe(method(init))] -114 | | #[unsafe(method_family = init)] -115 | | pub fn init(this: Allocated) -> 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 - --> ~/.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 - --> ~/.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 deleted file mode 100644 index d5cc551..0000000 --- a/crates/ghostty-macos/src/app/app_delegate.rs +++ /dev/null @@ -1,1935 +0,0 @@ -//! `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 deleted file mode 100644 index 3aa5088..0000000 --- a/crates/ghostty-macos/src/app/app_delegate_ghostty.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! `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 deleted file mode 100644 index bed379a..0000000 --- a/crates/ghostty-macos/src/app/entry.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! `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 deleted file mode 100644 index f841853..0000000 --- a/crates/ghostty-macos/src/app/main_menu.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! `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 deleted file mode 100644 index 5a68090..0000000 --- a/crates/ghostty-macos/src/app/menu_spec/generated.rs +++ /dev/null @@ -1,916 +0,0 @@ -//! 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 deleted file mode 100644 index 6c41dae..0000000 --- a/crates/ghostty-macos/src/app/menu_spec/mod.rs +++ /dev/null @@ -1,426 +0,0 @@ -//! `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 deleted file mode 100644 index 59f88d8..0000000 --- a/crates/ghostty-macos/src/app/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! `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 deleted file mode 100644 index cb7bd63..0000000 --- a/crates/ghostty-macos/src/app/support.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! 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 deleted file mode 100644 index 2aa2886..0000000 --- a/crates/ghostty-macos/src/features/about/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! `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 deleted file mode 100644 index 4d9d84f..0000000 --- a/crates/ghostty-macos/src/features/app_intents/mod.rs +++ /dev/null @@ -1 +0,0 @@ -//! 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 deleted file mode 100644 index 4d9d84f..0000000 --- a/crates/ghostty-macos/src/features/applescript/mod.rs +++ /dev/null @@ -1 +0,0 @@ -//! 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 deleted file mode 100644 index 4d9d84f..0000000 --- a/crates/ghostty-macos/src/features/clipboard_confirmation/mod.rs +++ /dev/null @@ -1 +0,0 @@ -//! 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 deleted file mode 100644 index 4d9d84f..0000000 --- a/crates/ghostty-macos/src/features/command_palette/mod.rs +++ /dev/null @@ -1 +0,0 @@ -//! 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 deleted file mode 100644 index e021deb..0000000 --- a/crates/ghostty-macos/src/features/custom_app_icon/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! `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 deleted file mode 100644 index 95284de..0000000 --- a/crates/ghostty-macos/src/features/global_keybinds/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! `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 deleted file mode 100644 index 532838b..0000000 --- a/crates/ghostty-macos/src/features/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! `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 deleted file mode 100644 index 2c87fd9..0000000 --- a/crates/ghostty-macos/src/features/quick_terminal/controller.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! 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 deleted file mode 100644 index 1e3750b..0000000 --- a/crates/ghostty-macos/src/features/quick_terminal/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! `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 deleted file mode 100644 index b95227d..0000000 --- a/crates/ghostty-macos/src/features/quick_terminal/position.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! `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 deleted file mode 100644 index d971115..0000000 --- a/crates/ghostty-macos/src/features/quick_terminal/screen.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! `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 deleted file mode 100644 index 397062d..0000000 --- a/crates/ghostty-macos/src/features/quick_terminal/size.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! `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 deleted file mode 100644 index 6a71251..0000000 --- a/crates/ghostty-macos/src/features/quick_terminal/space_behavior.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! `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 deleted file mode 100644 index 2a2cbd4..0000000 --- a/crates/ghostty-macos/src/features/secure_input/mod.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! `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 deleted file mode 100644 index 3168adc..0000000 --- a/crates/ghostty-macos/src/features/services/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! `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 deleted file mode 100644 index 6dedf04..0000000 --- a/crates/ghostty-macos/src/features/settings/mod.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! `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 deleted file mode 100644 index 4d9d84f..0000000 --- a/crates/ghostty-macos/src/features/splits/mod.rs +++ /dev/null @@ -1 +0,0 @@ -//! 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 deleted file mode 100644 index c0eca1a..0000000 --- a/crates/ghostty-macos/src/features/terminal/mod.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! `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 deleted file mode 100644 index d25fe86..0000000 --- a/crates/ghostty-macos/src/features/update/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! `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 deleted file mode 100644 index 64e2b83..0000000 --- a/crates/ghostty-macos/src/ghostty/action.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! `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 deleted file mode 100644 index 212bb0e..0000000 --- a/crates/ghostty-macos/src/ghostty/app.rs +++ /dev/null @@ -1,2111 +0,0 @@ -//! `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 deleted file mode 100644 index deb7310..0000000 --- a/crates/ghostty-macos/src/ghostty/capi/consts.rs +++ /dev/null @@ -1,503 +0,0 @@ -//! 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 deleted file mode 100644 index 5d755f5..0000000 --- a/crates/ghostty-macos/src/ghostty/capi/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! 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 deleted file mode 100644 index cc0a082..0000000 --- a/crates/ghostty-macos/src/ghostty/command.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! `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 deleted file mode 100644 index f3bbbf0..0000000 --- a/crates/ghostty-macos/src/ghostty/config.rs +++ /dev/null @@ -1,1368 +0,0 @@ -//! `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 deleted file mode 100644 index a23396c..0000000 --- a/crates/ghostty-macos/src/ghostty/config_types.rs +++ /dev/null @@ -1,393 +0,0 @@ -//! 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 deleted file mode 100644 index 0924663..0000000 --- a/crates/ghostty-macos/src/ghostty/duration_format.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! 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 deleted file mode 100644 index 2cb3f14..0000000 --- a/crates/ghostty-macos/src/ghostty/error.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! `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 deleted file mode 100644 index 4960476..0000000 --- a/crates/ghostty-macos/src/ghostty/event.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! `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 deleted file mode 100644 index 289121d..0000000 --- a/crates/ghostty-macos/src/ghostty/fullscreen_mode.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! `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 deleted file mode 100644 index d534a6b..0000000 --- a/crates/ghostty-macos/src/ghostty/input/mod.rs +++ /dev/null @@ -1,334 +0,0 @@ -//! 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 deleted file mode 100644 index a767ad1..0000000 --- a/crates/ghostty-macos/src/ghostty/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! `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 deleted file mode 100644 index 4174d9a..0000000 --- a/crates/ghostty-macos/src/ghostty/notification.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! 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 deleted file mode 100644 index e8c0752..0000000 --- a/crates/ghostty-macos/src/ghostty/ns_event.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! `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 deleted file mode 100644 index bbfcbf5..0000000 --- a/crates/ghostty-macos/src/ghostty/package.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! `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 deleted file mode 100644 index 186901a..0000000 --- a/crates/ghostty-macos/src/ghostty/shell.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! `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 deleted file mode 100644 index d2d5a0f..0000000 --- a/crates/ghostty-macos/src/ghostty/surface.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! `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 deleted file mode 100644 index 4a0759e..0000000 --- a/crates/ghostty-macos/src/ghostty/surface_configuration.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! `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 deleted file mode 100644 index a4a6d71..0000000 --- a/crates/ghostty-macos/src/ghostty/surface_view.rs +++ /dev/null @@ -1,734 +0,0 @@ -//! `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 deleted file mode 100644 index a2a1d3e..0000000 --- a/crates/ghostty-macos/src/helpers/any_sort_key.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! `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 deleted file mode 100644 index e30c1db..0000000 --- a/crates/ghostty-macos/src/helpers/app_info.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! `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 deleted file mode 100644 index 59a6afb..0000000 --- a/crates/ghostty-macos/src/helpers/codable_bridge.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! `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 deleted file mode 100644 index e7ff5e9..0000000 --- a/crates/ghostty-macos/src/helpers/color.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! 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 deleted file mode 100644 index 4d3b5a5..0000000 --- a/crates/ghostty-macos/src/helpers/cross_kit.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! `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 deleted file mode 100644 index 533f1d8..0000000 --- a/crates/ghostty-macos/src/helpers/cursor.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! `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 deleted file mode 100644 index 6dc0c94..0000000 --- a/crates/ghostty-macos/src/helpers/cursor_counter.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! 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 deleted file mode 100644 index ac5d8c1..0000000 --- a/crates/ghostty-macos/src/helpers/dispatch.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! 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 deleted file mode 100644 index 4c551fe..0000000 --- a/crates/ghostty-macos/src/helpers/expiring_undo_manager.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! `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 deleted file mode 100644 index cfd7dc3..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/array.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! `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 deleted file mode 100644 index e03bd35..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/double.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! `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 deleted file mode 100644 index 469ab8c..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/duration.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! `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 deleted file mode 100644 index 43f07d3..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/event_modifiers.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! `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 deleted file mode 100644 index 90e7550..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/file_handle.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! `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 deleted file mode 100644 index b1da96e..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/mod.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! `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 deleted file mode 100644 index 0edc329..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_appearance.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! `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 deleted file mode 100644 index 2e9144e..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_application.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! `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 deleted file mode 100644 index feb8f34..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_color.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! `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 deleted file mode 100644 index b7b78ef..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_image.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! `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 deleted file mode 100644 index 280801f..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_menu.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! `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 deleted file mode 100644 index 194891a..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_menu_item.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! `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 deleted file mode 100644 index e0edbc1..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_notification.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! 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 deleted file mode 100644 index bb8c9e2..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_pasteboard.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! `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 deleted file mode 100644 index cdc0b5c..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_screen.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! `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 deleted file mode 100644 index be0e6bc..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_screen_logic.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! 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 deleted file mode 100644 index c36a5ad..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_view.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! `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 deleted file mode 100644 index 6ffa088..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_window.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! `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 deleted file mode 100644 index 3bea385..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_window_logic.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! 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 deleted file mode 100644 index 3d94180..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/ns_workspace.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! `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 deleted file mode 100644 index 2fcf96f..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/object_identifier.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! `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 deleted file mode 100644 index d37abee..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/optional.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! `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 deleted file mode 100644 index 4d50fe0..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/os_color.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! 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 deleted file mode 100644 index ea59e04..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/string.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! `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 deleted file mode 100644 index 3f1c7be..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/undo_manager.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! `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 deleted file mode 100644 index 747ebf7..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/user_defaults.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! `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 deleted file mode 100644 index 3147fa9..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/uti.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! 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 deleted file mode 100644 index 003cb3c..0000000 --- a/crates/ghostty-macos/src/helpers/extensions/uuid.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! `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 deleted file mode 100644 index 16fe73a..0000000 --- a/crates/ghostty-macos/src/helpers/fullscreen.rs +++ /dev/null @@ -1,690 +0,0 @@ -//! `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 deleted file mode 100644 index ac659bf..0000000 --- a/crates/ghostty-macos/src/helpers/fullscreen_logic.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! 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 deleted file mode 100644 index a43941e..0000000 --- a/crates/ghostty-macos/src/helpers/geometry.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! 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 deleted file mode 100644 index e4680ec..0000000 --- a/crates/ghostty-macos/src/helpers/keyboard_layout.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! `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 deleted file mode 100644 index a679feb..0000000 --- a/crates/ghostty-macos/src/helpers/keyboard_shortcut.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! 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 deleted file mode 100644 index 2c36905..0000000 --- a/crates/ghostty-macos/src/helpers/last_window_position.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! `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 deleted file mode 100644 index 53b4c0f..0000000 --- a/crates/ghostty-macos/src/helpers/last_window_position_logic.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! 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 deleted file mode 100644 index 5a6e9d5..0000000 --- a/crates/ghostty-macos/src/helpers/mod.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! `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 deleted file mode 100644 index af0f8c0..0000000 --- a/crates/ghostty-macos/src/helpers/notification_center.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! 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 deleted file mode 100644 index bdf9e74..0000000 --- a/crates/ghostty-macos/src/helpers/objc_exception_catcher.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! `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 deleted file mode 100644 index 311c700..0000000 --- a/crates/ghostty-macos/src/helpers/private/cgs.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! `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 deleted file mode 100644 index 7900281..0000000 --- a/crates/ghostty-macos/src/helpers/private/dock.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! `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 deleted file mode 100644 index 3a6d1ea..0000000 --- a/crates/ghostty-macos/src/helpers/private/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! `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 deleted file mode 100644 index 8b4d0f2..0000000 --- a/crates/ghostty-macos/src/helpers/published.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! 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 deleted file mode 100644 index bc44eab..0000000 --- a/crates/ghostty-macos/src/helpers/vibrant_layer.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! `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 deleted file mode 100644 index 2bef407..0000000 --- a/crates/ghostty-macos/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! 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 deleted file mode 100644 index b7d2a55..0000000 --- a/crates/ghostty-macos/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! `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 bc1b235..515b591 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/` (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) | +| 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 | | 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 deleted file mode 100644 index 33c52da..0000000 --- a/docs/rewrite/notes/macos-app-MA1.md +++ /dev/null @@ -1,45 +0,0 @@ -# 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 deleted file mode 100644 index cf0f96f..0000000 --- a/docs/rewrite/notes/macos-app-design.md +++ /dev/null @@ -1,147 +0,0 @@ -# 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 deleted file mode 100644 index 749cf6f..0000000 --- a/scripts/gen-macos-capi-consts.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/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 deleted file mode 100644 index 921b009..0000000 --- a/scripts/gen-macos-menu-spec.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/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 deleted file mode 100755 index fe5afa0..0000000 --- a/scripts/macos-bundle.sh +++ /dev/null @@ -1,217 +0,0 @@ -#!/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 c68c313..d16cbff 100755 --- a/scripts/macos-crosscheck.sh +++ b/scripts/macos-crosscheck.sh @@ -5,8 +5,6 @@ # 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} @@ -57,20 +55,13 @@ 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-macos) -if [ -n "${MACOS_CHECK_ONLY:-}" ]; then - CRATES=() - for c in $MACOS_CHECK_ONLY; do CRATES+=(-p "$c"); done -fi + -p ghostty-bench -p ghostty-vt-capi) 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