revert: drop the Rust port of the Swift macOS app (phase 9)
Reverts 4861015 ("feat(macos): foundation of the macOS app ported from
Swift to Rust"). Decision with the user: keep Ghostty's original Swift
macOS app unchanged and link it against the Rust core instead of the
Zig core. The Rust libghostty exports the identical include/ghostty.h
ABI (ABI-checked), so the Swift app does not need to be rewritten; a
second, Rust-based macOS app would duplicate it without being runnable
or testable here.
This commit is contained in:
1 parent
8254c82a43
commit
cfded0e95a
112 files changed
+2
-18438
No files matched your search
@@ -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"
|
||||
@@ -1,815 +0,0 @@
|
||||
Compiling ghostty-build-info v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-build-info)
|
||||
Checking ghostty-config v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-config)
|
||||
Checking ghostty-font v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-font)
|
||||
Checking ghostty-renderer v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-renderer)
|
||||
Checking ghostty-inspector v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-inspector)
|
||||
Checking ghostty-apprt v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-apprt)
|
||||
Checking ghostty-termio v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-termio)
|
||||
Checking ghostty-cli v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-cli)
|
||||
Checking ghostty-core v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-core)
|
||||
Checking ghostty-lib v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-lib)
|
||||
Checking ghostty-macos v0.1.0 (/pantheon/pfs/git/rust/ghostty-rewrite/ghostty-rs/crates/ghostty-macos)
|
||||
error[E0432]: unresolved import `crate::ghostty::capi::c_int`
|
||||
--> crates/ghostty-macos/src/ghostty/surface_view.rs:52:5
|
||||
|
|
||||
52 | c_int, ghostty_surface_needs_confirm_quit,
|
||||
| ^^^^^ no `c_int` in `ghostty::capi`
|
||||
|
|
||||
= help: consider importing one of these type aliases instead:
|
||||
std::ffi::c_int
|
||||
std::os::raw::c_int
|
||||
core::ffi::c_int
|
||||
libc::c_int
|
||||
|
||||
error[E0432]: unresolved import `crate::ghostty::capi::c_int`
|
||||
--> crates/ghostty-macos/src/ghostty/surface_view.rs:52:5
|
||||
|
|
||||
52 | c_int, ghostty_surface_needs_confirm_quit,
|
||||
| ^^^^^ no `c_int` in `ghostty::capi`
|
||||
|
|
||||
= help: consider importing one of these type aliases instead:
|
||||
std::ffi::c_int
|
||||
std::os::raw::c_int
|
||||
core::ffi::c_int
|
||||
libc::c_int
|
||||
= note: type alias `crate::helpers::extensions::ns_pasteboard::tests::c_int` exists but is inaccessible
|
||||
|
||||
error[E0425]: cannot find function `remove_persistent_domain_for_ghostty_suite` in module `user_defaults`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:227:40
|
||||
|
|
||||
227 | user_defaults::remove_persistent_domain_for_ghostty_suite();
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `user_defaults`
|
||||
|
||||
error[E0425]: cannot find function `register_defaults` in module `user_defaults`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:231:28
|
||||
|
|
||||
231 | user_defaults::register_defaults(&[
|
||||
| ^^^^^^^^^^^^^^^^^ not found in `user_defaults`
|
||||
|
||||
error[E0425]: cannot find function `register_defaults` in module `user_defaults`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:670:24
|
||||
|
|
||||
670 | user_defaults::register_defaults(&[
|
||||
| ^^^^^^^^^^^^^^^^^ not found in `user_defaults`
|
||||
|
||||
error[E0425]: cannot find function `name_window_did_become_key` in module `ns_notification`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1072:30
|
||||
|
|
||||
1072 | ns_notification::name_window_did_become_key(),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `ns_notification`
|
||||
|
||||
error[E0425]: cannot find value `TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION` in module `gn`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1104:38
|
||||
|
|
||||
1104 | bus.push(nc.add_observer(gn::TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION, None, move |n| {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `gn`
|
||||
|
||||
error[E0425]: cannot find function `bundle_info_bool` in module `crate::helpers::app_info`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1321:38
|
||||
|
|
||||
1321 | if crate::helpers::app_info::bundle_info_bool("SUEnableAutomaticChecks") == Some(false) {
|
||||
| ^^^^^^^^^^^^^^^^ not found in `crate::helpers::app_info`
|
||||
|
||||
error[E0425]: cannot find function `post_icon_did_change` in module `crate::features::custom_app_icon`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1416:43
|
||||
|
|
||||
1416 | crate::features::custom_app_icon::post_icon_did_change();
|
||||
| ^^^^^^^^^^^^^^^^^^^^ not found in `crate::features::custom_app_icon`
|
||||
|
||||
error[E0425]: cannot find function `set_default_application_for_unix_executable` in module `crate::helpers::extensions::ns_workspace`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1500:51
|
||||
|
|
||||
1500 | crate::helpers::extensions::ns_workspace::set_default_application_for_unix_executable(
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::ns_workspace`
|
||||
|
||||
error[E0425]: cannot find function `ns_flags_from_swiftui` in module `crate::helpers::extensions::event_modifiers`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1572:74
|
||||
|
|
||||
1572 | let modifier_mask = crate::helpers::extensions::event_modifiers::ns_flags_from_swiftui(shortcut.modifiers);
|
||||
| ^^^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::event_modifiers`
|
||||
|
||||
error[E0425]: cannot find function `default_application_url_for_extension` in module `crate::helpers::extensions::ns_workspace`
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:962:68
|
||||
|
|
||||
962 | let editor = crate::helpers::extensions::ns_workspace::default_application_url_for_extension(&ext)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::ns_workspace`
|
||||
|
||||
error[E0425]: cannot find value `default_text_editor` in module `crate::helpers::extensions::ns_workspace`
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:963:68
|
||||
|
|
||||
963 | .or_else(crate::helpers::extensions::ns_workspace::default_text_editor);
|
||||
| ^^^^^^^^^^^^^^^^^^^ not found in `crate::helpers::extensions::ns_workspace`
|
||||
|
||||
error[E0425]: cannot find function `from_ghostty` in module `fullscreen_mode`
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:1141:39
|
||||
|
|
||||
1141 | let Some(mode) = fullscreen_mode::from_ghostty(raw) else {
|
||||
| ^^^^^^^^^^^^ not found in `fullscreen_mode`
|
||||
|
|
||||
help: consider importing this function
|
||||
|
|
||||
20 + use crate::helpers::extensions::os_color::from_ghostty;
|
||||
|
|
||||
help: if you import `from_ghostty`, refer to it directly
|
||||
|
|
||||
1141 - let Some(mode) = fullscreen_mode::from_ghostty(raw) else {
|
||||
1141 + let Some(mode) = from_ghostty(raw) else {
|
||||
|
|
||||
|
||||
warning: unused import: `NSNumber`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:34:74
|
||||
|
|
||||
34 | NSArray, NSCoder, NSDictionary, NSKeyValueChangeKey, NSNotification, NSNumber, NSObject,
|
||||
| ^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: unused import: `Sender`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:76:92
|
||||
|
|
||||
76 | use crate::helpers::notification_center::{Notification, NotificationCenter, ObserverToken, Sender};
|
||||
| ^^^^^^
|
||||
|
||||
error: usage of an `unsafe` block
|
||||
--> crates/ghostty-macos/src/features/quick_terminal/controller.rs:64:9
|
||||
|
|
||||
64 | unsafe { msg_send![super(this), init] }
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= note: requested on the command line with `-D unsafe-code`
|
||||
|
||||
error: usage of an `unsafe` block
|
||||
--> crates/ghostty-macos/src/features/services/mod.rs:26:9
|
||||
|
|
||||
26 | unsafe { msg_send![this, init] }
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
warning: unused import: `NSPasteboardNameGeneral`
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:36:19
|
||||
|
|
||||
36 | NSPasteboard, NSPasteboardNameGeneral, NSPasteboardTypeString,
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
warning: unused import: `NSUserInterfaceItemSearching`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:31:52
|
||||
|
|
||||
31 | NSWindowLevel, NSWindowStyleMask, NSWorkspace, NSUserInterfaceItemSearching,
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: unused import: `NSNumber`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:34:74
|
||||
|
|
||||
34 | NSArray, NSCoder, NSDictionary, NSKeyValueChangeKey, NSNotification, NSNumber, NSObject,
|
||||
| ^^^^^^^^
|
||||
|
||||
error[E0308]: mismatched types
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:288:24
|
||||
|
|
||||
199 | / define_class!(
|
||||
200 | | // SAFETY: NSObject has no subclassing requirements; AppDelegate does not
|
||||
201 | | // implement Drop.
|
||||
202 | | #[unsafe(super(NSObject))]
|
||||
... |
|
||||
288 | | return true;
|
||||
| | ^^^^ expected `Bool`, found `bool`
|
||||
... |
|
||||
523 | | );
|
||||
| |_- expected `objc2::runtime::Bool` because of return type
|
||||
|
|
||||
help: call `Into::into` on this expression to convert `bool` into `objc2::runtime::Bool`
|
||||
|
|
||||
288 | return true.into();
|
||||
| +++++++
|
||||
|
||||
error[E0599]: no method named `surface_tree` found for struct `Retained<TerminalController>` in the current scope
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:653:27
|
||||
|
|
||||
653 | for view in c.surface_tree() {
|
||||
| ^^^^^^^^^^^^
|
||||
|
|
||||
help: there is a method `surface_tree_is_split` with a similar name
|
||||
|
|
||||
653 | for view in c.surface_tree_is_split() {
|
||||
| +++++++++
|
||||
|
||||
error[E0599]: no method named `attributeDescriptorForKeyword` found for struct `Retained<NSAppleEventDescriptor>` in the current scope
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:807:38
|
||||
|
|
||||
807 | if let Some(why) = event.attributeDescriptorForKeyword(keyword) {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ method not found in `Retained<NSAppleEventDescriptor>`
|
||||
|
||||
error[E0599]: no method named `retain` found for reference `&AppDelegate` in the current scope
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:939:46
|
||||
|
|
||||
939 | let weak = Weak::from_retained(&self.retain());
|
||||
| ^^^^^^
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `Message` which provides `retain` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
16 + use objc2::Message;
|
||||
|
|
||||
help: there is a method `retainCount` with a similar name
|
||||
|
|
||||
939 | let weak = Weak::from_retained(&self.retainCount());
|
||||
| +++++
|
||||
|
||||
error[E0599]: no method named `retain` found for reference `&AppDelegate` in the current scope
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:957:35
|
||||
|
|
||||
957 | Weak::from_retained(&self.retain())
|
||||
| ^^^^^^
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `Message` which provides `retain` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
16 + use objc2::Message;
|
||||
|
|
||||
help: there is a method `retainCount` with a similar name
|
||||
|
|
||||
957 | Weak::from_retained(&self.retainCount())
|
||||
| +++++
|
||||
|
||||
error[E0599]: the associated function or constant `alloc` exists for struct `NSSound`, but its trait bounds were not satisfied
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1199:30
|
||||
|
|
||||
1199 | NSSound::alloc(),
|
||||
| ^^^^^ associated function or constant cannot be called on `NSSound` due to unsatisfied trait bounds
|
||||
|
|
||||
::: /home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/macros/extern_class.rs:251:9
|
||||
|
|
||||
251 | / $v struct $class $(<$($generic $(: $($bounds)+)? $(= $default)?),*>)? {
|
||||
252 | | __superclass: $crate::__fallback_if_not_set! {
|
||||
253 | | ($($superclass)?)
|
||||
... |
|
||||
261 | | $(__generics: $crate::__macro_helpers::PhantomData<($(*mut $generic),+)>,)?
|
||||
262 | | }
|
||||
| |_________- doesn't satisfy `<_ as ClassType>::ThreadKind = dyn MainThreadOnly` or `NSSound: MainThreadOnly`
|
||||
|
|
||||
= note: the following trait bounds were not satisfied:
|
||||
`<NSSound as ClassType>::ThreadKind = dyn MainThreadOnly`
|
||||
which is required by `NSSound: MainThreadOnly`
|
||||
`<&NSSound as ClassType>::ThreadKind = dyn MainThreadOnly`
|
||||
which is required by `&NSSound: MainThreadOnly`
|
||||
`&NSSound: ClassType`
|
||||
which is required by `&NSSound: MainThreadOnly`
|
||||
`<&mut NSSound as ClassType>::ThreadKind = dyn MainThreadOnly`
|
||||
which is required by `&mut NSSound: MainThreadOnly`
|
||||
`&mut NSSound: ClassType`
|
||||
which is required by `&mut NSSound: MainThreadOnly`
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
16 + use objc2::AnyThread;
|
||||
|
|
||||
|
||||
error[E0599]: no method named `exec_after` found for reference `&'static DispatchQueue` in the current scope
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1389:39
|
||||
|
|
||||
1389 | DispatchQueue::main().exec_after(
|
||||
| ----------------------^^^^^^^^^^
|
||||
|
|
||||
help: there is a method `after` with a similar name
|
||||
|
|
||||
1389 - DispatchQueue::main().exec_after(
|
||||
1389 + DispatchQueue::main().after(
|
||||
|
|
||||
|
||||
error[E0599]: no method named `mtm` found for reference `&AppDelegate` in the current scope
|
||||
--> crates/ghostty-macos/src/app/app_delegate_ghostty.rs:17:61
|
||||
|
|
||||
17 | for window in NSApplication::sharedApplication(self.mtm()).windows() {
|
||||
| ^^^ method not found in `&AppDelegate`
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `MainThreadOnly` which provides `mtm` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
5 + use objc2::MainThreadOnly;
|
||||
|
|
||||
|
||||
error[E0599]: no method named `surface_tree` found for struct `Retained<BaseTerminalController>` in the current scope
|
||||
--> crates/ghostty-macos/src/app/app_delegate_ghostty.rs:25:39
|
||||
|
|
||||
25 | for surface in controller.surface_tree() {
|
||||
| ^^^^^^^^^^^^
|
||||
|
|
||||
help: there is a method `surface_tree_is_split` with a similar name
|
||||
|
|
||||
25 | for surface in controller.surface_tree_is_split() {
|
||||
| +++++++++
|
||||
|
||||
error[E0599]: no associated function or constant named `alloc` found for struct `NSMenu` in the current scope
|
||||
--> crates/ghostty-macos/src/app/main_menu.rs:62:46
|
||||
|
|
||||
62 | let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str(spec.title));
|
||||
| ^^^^^ associated function or constant not found in `NSMenu`
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `MainThreadOnly` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
22 + use objc2::MainThreadOnly;
|
||||
|
|
||||
|
||||
error[E0599]: no associated function or constant named `alloc` found for struct `NSMenuItem` in the current scope
|
||||
--> crates/ghostty-macos/src/app/main_menu.rs:74:33
|
||||
|
|
||||
74 | NSMenuItem::alloc(mtm),
|
||||
| ^^^^^ associated function or constant not found in `NSMenuItem`
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `MainThreadOnly` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
22 + use objc2::MainThreadOnly;
|
||||
|
|
||||
|
||||
error[E0599]: no method named `ivars` found for struct `PartialInit<T>` in the current scope
|
||||
--> crates/ghostty-macos/src/features/quick_terminal/controller.rs:62:14
|
||||
|
|
||||
62 | this.ivars().position.set(position);
|
||||
| ^^^^^ method not found in `PartialInit<QuickTerminalController>`
|
||||
|
||||
error[E0308]: mismatched types
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:103:45
|
||||
|
|
||||
103 | unsafe { self.center.removeObserver(&self.token) };
|
||||
| -------------- ^^^^^^^^^^^ expected `&AnyObject`, found `&Retained<ProtocolObject<_>>`
|
||||
| |
|
||||
| arguments to this method are incorrect
|
||||
|
|
||||
= note: expected reference `&AnyObject`
|
||||
found reference `&Retained<ProtocolObject<(dyn NSObjectProtocol + 'static)>>`
|
||||
note: method defined here
|
||||
--> /home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-foundation-0.3.2/src/generated/NSNotification.rs:188:23
|
||||
|
|
||||
188 | pub unsafe fn removeObserver(&self, observer: &AnyObject);
|
||||
| ^^^^^^^^^^^^^^
|
||||
|
||||
error[E0599]: no associated function or constant named `alloc` found for struct `NSUUID` in the current scope
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:554:40
|
||||
|
|
||||
554 | NSUUID::initWithUUIDString(NSUUID::alloc(), &s)
|
||||
| ^^^^^ associated function or constant not found in `NSUUID`
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
20 + use objc2::AnyThread;
|
||||
|
|
||||
|
||||
error[E0599]: no associated function or constant named `class` found for struct `NSURL` in the current scope
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:660:48
|
||||
|
|
||||
660 | let classes = NSArray::from_slice(&[NSURL::class()]);
|
||||
| ^^^^^ associated function or constant not found in `NSURL`
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `ClassType` which provides `class` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
20 + use objc2::ClassType;
|
||||
|
|
||||
help: the function `class` is implemented on `AnyObject`
|
||||
|
|
||||
660 - let classes = NSArray::from_slice(&[NSURL::class()]);
|
||||
660 + let classes = NSArray::from_slice(&[AnyObject::class()]);
|
||||
|
|
||||
|
||||
error[E0599]: no method named `copy` found for reference `&NSString` in the current scope
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:690:50
|
||||
|
|
||||
690 | return unsafe { NSPasteboardTypeString }.copy();
|
||||
| ^^^^ method not found in `&NSString`
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `NSCopying` which provides `copy` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
20 + use objc2_foundation::NSCopying;
|
||||
|
|
||||
|
||||
error[E0061]: this method takes 0 arguments but 1 argument was supplied
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:938:18
|
||||
|
|
||||
938 | delegate.check_for_updates(None);
|
||||
| ^^^^^^^^^^^^^^^^^ ---- unexpected argument of type `Option<_>`
|
||||
|
|
||||
note: method defined here
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1437:12
|
||||
|
|
||||
1437 | pub fn check_for_updates(&self) {
|
||||
| ^^^^^^^^^^^^^^^^^
|
||||
help: remove the extra argument
|
||||
|
|
||||
938 - delegate.check_for_updates(None);
|
||||
938 + delegate.check_for_updates();
|
||||
|
|
||||
|
||||
error[E0308]: mismatched types
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:990:31
|
||||
|
|
||||
988 | fn undo_manager_for(target: &ghostty_target_s) -> Option<Retained<objc2_foundation::NSUndoManager>> {
|
||||
| ------------------------------------------------- expected `Option<Retained<NSUndoManager>>` because of return type
|
||||
989 | match target.tag {
|
||||
990 | GHOSTTY_TARGET_APP => app_delegate().map(|d| d.undo_manager()),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Option<Retained<NSUndoManager>>`, found `Option<Retained<ExpiringUndoManager>>`
|
||||
|
|
||||
= note: expected enum `Option<Retained<NSUndoManager>>`
|
||||
found enum `Option<Retained<ExpiringUndoManager>>`
|
||||
|
||||
error[E0061]: this method takes 0 arguments but 1 argument was supplied
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:1136:14
|
||||
|
|
||||
1136 | delegate.close_all_windows(None);
|
||||
| ^^^^^^^^^^^^^^^^^ ---- unexpected argument of type `Option<_>`
|
||||
|
|
||||
note: method defined here
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1442:12
|
||||
|
|
||||
1442 | pub fn close_all_windows(&self) {
|
||||
| ^^^^^^^^^^^^^^^^^
|
||||
help: remove the extra argument
|
||||
|
|
||||
1136 - delegate.close_all_windows(None);
|
||||
1136 + delegate.close_all_windows();
|
||||
|
|
||||
|
||||
error[E0061]: this method takes 0 arguments but 1 argument was supplied
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:1164:14
|
||||
|
|
||||
1164 | delegate.toggle_visibility(None);
|
||||
| ^^^^^^^^^^^^^^^^^ ---- unexpected argument of type `Option<_>`
|
||||
|
|
||||
note: method defined here
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1453:12
|
||||
|
|
||||
1453 | pub fn toggle_visibility(&self) {
|
||||
| ^^^^^^^^^^^^^^^^^
|
||||
help: remove the extra argument
|
||||
|
|
||||
1164 - delegate.toggle_visibility(None);
|
||||
1164 + delegate.toggle_visibility();
|
||||
|
|
||||
|
||||
error[E0061]: this method takes 0 arguments but 1 argument was supplied
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:1499:14
|
||||
|
|
||||
1499 | delegate.toggle_quick_terminal(None);
|
||||
| ^^^^^^^^^^^^^^^^^^^^^ ---- unexpected argument of type `Option<_>`
|
||||
|
|
||||
note: method defined here
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1448:12
|
||||
|
|
||||
1448 | pub fn toggle_quick_terminal(&self) {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
help: remove the extra argument
|
||||
|
|
||||
1499 - delegate.toggle_quick_terminal(None);
|
||||
1499 + delegate.toggle_quick_terminal();
|
||||
|
|
||||
|
||||
error[E0308]: mismatched types
|
||||
--> crates/ghostty-macos/src/ghostty/surface_view.rs:514:13
|
||||
|
|
||||
512 | let user_info: Retained<NSDictionary<NSString, AnyObject>> = NSDictionary::from_retained_objects(
|
||||
| ----------------------------------- arguments to this function are incorrect
|
||||
513 | &[&*surface_key, &*focus_key],
|
||||
514 | vec![surface_val, focus_val],
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `&[Retained<AnyObject>]`, found `Vec<Retained<AnyObject>>`
|
||||
|
|
||||
= note: expected reference `&[Retained<AnyObject>]`
|
||||
found struct `Vec<Retained<AnyObject>>`
|
||||
note: associated function defined here
|
||||
--> /home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-foundation-0.3.2/src/dictionary.rs:100:12
|
||||
|
|
||||
100 | pub fn from_retained_objects<CopiedKey>(
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
error[E0308]: mismatched types
|
||||
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:22:14
|
||||
|
|
||||
20 | fn cg_image(image: &NSImage) -> Option<objc2_core_foundation::CFRetained<objc2_core_graphics::CGImage>> {
|
||||
| ----------------------------------------------------------------------- expected `Option<CFRetained<CGImage>>` because of return type
|
||||
21 | // SAFETY: nil rect/context/hints are allowed.
|
||||
22 | unsafe { image.CGImageForProposedRect_context_hints(std::ptr::null_mut(), None, None) }
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Option<CFRetained<CGImage>>`, found `Option<Retained<CGImage>>`
|
||||
|
|
||||
= note: expected enum `Option<CFRetained<CGImage>>`
|
||||
found enum `Option<Retained<CGImage>>`
|
||||
|
||||
warning: use of deprecated method `objc2_app_kit::NSImage::lockFocus`: This method is incompatible with resolution-independent drawing and should not be used. Instead, use +[NSImage imageWithSize:flipped:drawingHandler:] to create a block-based image describing the desired image drawing, or use +[NSGraphicsContext graphicsContextWithBitmapImageRep:] to manipulate specific bitmap image representations.
|
||||
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:82:16
|
||||
|
|
||||
82 | result.lockFocus();
|
||||
| ^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(deprecated)]` on by default
|
||||
|
||||
warning: use of deprecated method `objc2_app_kit::NSImage::unlockFocus`: This method is incompatible with resolution-independent drawing and should not be used. Instead, use +[NSImage imageWithSize:flipped:drawingHandler:] to create a block-based image describing the desired image drawing, or use +[NSGraphicsContext graphicsContextWithBitmapImageRep:] to manipulate specific bitmap image representations.
|
||||
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:98:16
|
||||
|
|
||||
98 | result.unlockFocus();
|
||||
| ^^^^^^^^^^^
|
||||
|
||||
warning: use of deprecated method `objc2_app_kit::NSImage::lockFocus`: This method is incompatible with resolution-independent drawing and should not be used. Instead, use +[NSImage imageWithSize:flipped:drawingHandler:] to create a block-based image describing the desired image drawing, or use +[NSGraphicsContext graphicsContextWithBitmapImageRep:] to manipulate specific bitmap image representations.
|
||||
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:109:19
|
||||
|
|
||||
109 | new_image.lockFocus();
|
||||
| ^^^^^^^^^
|
||||
|
||||
warning: use of deprecated method `objc2_app_kit::NSImage::unlockFocus`: This method is incompatible with resolution-independent drawing and should not be used. Instead, use +[NSImage imageWithSize:flipped:drawingHandler:] to create a block-based image describing the desired image drawing, or use +[NSGraphicsContext graphicsContextWithBitmapImageRep:] to manipulate specific bitmap image representations.
|
||||
--> crates/ghostty-macos/src/helpers/extensions/ns_image.rs:129:19
|
||||
|
|
||||
129 | new_image.unlockFocus();
|
||||
| ^^^^^^^^^^^
|
||||
|
||||
error[E0599]: no method named `usingColorSpace` found for struct `Retained<NSColor>` in the current scope
|
||||
--> crates/ghostty-macos/src/helpers/extensions/ns_view.rs:198:27
|
||||
|
|
||||
198 | c.usingColorSpace(&objc2_app_kit::NSColorSpace::deviceRGBColorSpace())
|
||||
| ^^^^^^^^^^^^^^^
|
||||
|
|
||||
help: there is a method `colorSpace` with a similar name, but with different arguments
|
||||
--> /home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/macros/extern_methods.rs:267:14
|
||||
|
|
||||
267 | ($v fn $fn_name($($params)*) $(-> $ret)?)
|
||||
| ^^
|
||||
|
|
||||
::: /home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-app-kit-0.3.2/src/generated/NSColor.rs:112:5
|
||||
|
|
||||
112 | / extern_methods!(
|
||||
113 | | #[unsafe(method(init))]
|
||||
114 | | #[unsafe(method_family = init)]
|
||||
115 | | pub fn init(this: Allocated<Self>) -> Retained<Self>;
|
||||
... |
|
||||
901 | | pub fn setIgnoresAlpha(ignores_alpha: bool, mtm: MainThreadMarker);
|
||||
902 | | );
|
||||
| |_____- in this macro invocation
|
||||
= note: this error originates in the macro `$crate::extern_methods` which comes from the expansion of the macro `extern_methods` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error[E0599]: no associated function or constant named `alloc` found for struct `NSUserDefaults` in the current scope
|
||||
--> crates/ghostty-macos/src/helpers/extensions/user_defaults.rs:18:63
|
||||
|
|
||||
18 | NSUserDefaults::initWithSuiteName(NSUserDefaults::alloc(), Some(&NSString::from_str(&suite)))
|
||||
| ^^^^^ associated function or constant not found in `NSUserDefaults`
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
3 + use objc2::AnyThread;
|
||||
|
|
||||
|
||||
error[E0599]: no associated function or constant named `alloc` found for struct `NSUUID` in the current scope
|
||||
--> crates/ghostty-macos/src/helpers/extensions/uuid.rs:11:40
|
||||
|
|
||||
11 | NSUUID::initWithUUIDString(NSUUID::alloc(), &NSString::from_str(&s.to_string()))
|
||||
| ^^^^^ associated function or constant not found in `NSUUID`
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
3 + use objc2::AnyThread;
|
||||
|
|
||||
|
||||
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeLeftCursor`: Use either `+[NSCursor columnResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
|
||||
--> crates/ghostty-macos/src/helpers/cursor.rs:82:43
|
||||
|
|
||||
82 | Self::ResizeLeft => NSCursor::resizeLeftCursor(),
|
||||
| ^^^^^^^^^^^^^^^^
|
||||
|
||||
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeRightCursor`: Use either `+[NSCursor columnResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
|
||||
--> crates/ghostty-macos/src/helpers/cursor.rs:86:44
|
||||
|
|
||||
86 | Self::ResizeRight => NSCursor::resizeRightCursor(),
|
||||
| ^^^^^^^^^^^^^^^^^
|
||||
|
||||
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeUpCursor`: Use either `+[NSCursor rowResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
|
||||
--> crates/ghostty-macos/src/helpers/cursor.rs:90:41
|
||||
|
|
||||
90 | Self::ResizeUp => NSCursor::resizeUpCursor(),
|
||||
| ^^^^^^^^^^^^^^
|
||||
|
||||
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeDownCursor`: Use either `+[NSCursor rowResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
|
||||
--> crates/ghostty-macos/src/helpers/cursor.rs:94:43
|
||||
|
|
||||
94 | Self::ResizeDown => NSCursor::resizeDownCursor(),
|
||||
| ^^^^^^^^^^^^^^^^
|
||||
|
||||
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeUpDownCursor`: Use either `+[NSCursor rowResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
|
||||
--> crates/ghostty-macos/src/helpers/cursor.rs:96:45
|
||||
|
|
||||
96 | Self::ResizeUpDown => NSCursor::resizeUpDownCursor(),
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
|
||||
warning: use of deprecated associated function `objc2_app_kit::NSCursor::resizeLeftRightCursor`: Use either `+[NSCursor columnResizeCursorInDirections:]` or `+[NSCursor frameResizeCursorFromPosition:inDirections:]` instead, depending on whether a divider is being re-positioned or a rectangular frame is being resized.
|
||||
--> crates/ghostty-macos/src/helpers/cursor.rs:98:48
|
||||
|
|
||||
98 | Self::ResizeLeftRight => NSCursor::resizeLeftRightCursor(),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
error[E0599]: the associated function or constant `alloc` exists for struct `ExpiringTarget`, but its trait bounds were not satisfied
|
||||
--> crates/ghostty-macos/src/helpers/expiring_undo_manager.rs:74:26
|
||||
|
|
||||
56 | / define_class!(
|
||||
57 | | /// A target object for ExpiringUndoManager that removes itself from the
|
||||
58 | | /// undo manager after it expires.
|
||||
59 | | ///
|
||||
... |
|
||||
69 | | struct ExpiringTarget;
|
||||
70 | | );
|
||||
| |_- associated function or constant `alloc` not found for this struct because it doesn't satisfy `<_ as ClassType>::ThreadKind = dyn MainThreadOnly` or `ExpiringTarget: MainThreadOnly`
|
||||
...
|
||||
74 | let this = Self::alloc().set_ivars(TargetIvars {
|
||||
| ^^^^^ associated function or constant cannot be called on `ExpiringTarget` due to unsatisfied trait bounds
|
||||
|
|
||||
= note: the following trait bounds were not satisfied:
|
||||
`<ExpiringTarget as ClassType>::ThreadKind = dyn MainThreadOnly`
|
||||
which is required by `ExpiringTarget: MainThreadOnly`
|
||||
`<&ExpiringTarget as ClassType>::ThreadKind = dyn MainThreadOnly`
|
||||
which is required by `&ExpiringTarget: MainThreadOnly`
|
||||
`&ExpiringTarget: ClassType`
|
||||
which is required by `&ExpiringTarget: MainThreadOnly`
|
||||
`<&mut ExpiringTarget as ClassType>::ThreadKind = dyn MainThreadOnly`
|
||||
which is required by `&mut ExpiringTarget: MainThreadOnly`
|
||||
`&mut ExpiringTarget: ClassType`
|
||||
which is required by `&mut ExpiringTarget: MainThreadOnly`
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `AnyThread` which provides `alloc` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
25 + use objc2::AnyThread;
|
||||
|
|
||||
|
||||
error[E0599]: no associated function or constant named `ptr_eq` found for struct `Retained<_>` in the current scope
|
||||
--> crates/ghostty-macos/src/helpers/expiring_undo_manager.rs:191:86
|
||||
|
|
||||
191 | self.ivars().expiring_targets.borrow_mut().retain(|x| !Retained::ptr_eq(x, &e));
|
||||
| ^^^^^^ associated function or constant not found in `Retained<_>`
|
||||
|
|
||||
note: if you're trying to build a new `Retained<_>` consider using one of the following associated functions:
|
||||
Retained::<T>::from_raw
|
||||
Retained::<T>::new
|
||||
Retained::<T>::cast_unchecked
|
||||
Retained::<T>::cast
|
||||
and 2 others
|
||||
--> /home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/rc/retained.rs:215:5
|
||||
|
|
||||
215 | pub unsafe fn from_raw(ptr: *mut T) -> Option<Self> {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
...
|
||||
229 | pub unsafe fn new(ptr: *mut T) -> Option<Self> {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
...
|
||||
381 | pub unsafe fn cast_unchecked<U: Message>(this: Self) -> Retained<U> {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
...
|
||||
396 | pub unsafe fn cast<U: Message>(this: Self) -> Retained<U> {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
error[E0599]: no method named `retain` found for reference `&NSWindow` in the current scope
|
||||
--> crates/ghostty-macos/src/helpers/fullscreen.rs:96:28
|
||||
|
|
||||
96 | window: window.retain(),
|
||||
| ^^^^^^
|
||||
|
|
||||
= help: items from traits can only be used if the trait is in scope
|
||||
help: trait `Message` which provides `retain` is implemented but not in scope; perhaps you want to import it
|
||||
|
|
||||
8 + use objc2::Message;
|
||||
|
|
||||
help: there is a method `retainCount` with a similar name
|
||||
|
|
||||
96 | window: window.retainCount(),
|
||||
| +++++
|
||||
|
||||
error[E0599]: no associated function or constant named `ptr_eq` found for struct `Retained<_>` in the current scope
|
||||
--> crates/ghostty-macos/src/helpers/fullscreen.rs:458:94
|
||||
|
|
||||
458 | let present = window.titlebarAccessoryViewControllers().iter().any(|x| Retained::ptr_eq(&x, c));
|
||||
| ^^^^^^ associated function or constant not found in `Retained<_>`
|
||||
|
|
||||
note: if you're trying to build a new `Retained<_>` consider using one of the following associated functions:
|
||||
Retained::<T>::from_raw
|
||||
Retained::<T>::new
|
||||
Retained::<T>::cast_unchecked
|
||||
Retained::<T>::cast
|
||||
and 2 others
|
||||
--> /home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/rc/retained.rs:215:5
|
||||
|
|
||||
215 | pub unsafe fn from_raw(ptr: *mut T) -> Option<Self> {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
...
|
||||
229 | pub unsafe fn new(ptr: *mut T) -> Option<Self> {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
...
|
||||
381 | pub unsafe fn cast_unchecked<U: Message>(this: Self) -> Retained<U> {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
...
|
||||
396 | pub unsafe fn cast<U: Message>(this: Self) -> Retained<U> {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
warning: unused import: `NSUserInterfaceItemSearching`
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:31:52
|
||||
|
|
||||
31 | NSWindowLevel, NSWindowStyleMask, NSWorkspace, NSUserInterfaceItemSearching,
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
error[E0133]: call to unsafe function `NSEvent::addLocalMonitorForEventsMatchingMask_handler` is unsafe and requires unsafe block
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:973:23
|
||||
|
|
||||
973 | let monitor = NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &handler);
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
||||
|
|
||||
= note: consult the function's documentation for information on how to avoid undefined behavior
|
||||
|
||||
error[E0133]: call to unsafe function `NSMenuItem::initWithTitle_action_keyEquivalent` is unsafe and requires unsafe block
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1515:24
|
||||
|
|
||||
1515 | let item = NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
| ________________________^
|
||||
1516 | | NSMenuItem::alloc(mtm),
|
||||
1517 | | &NSString::from_str(title),
|
||||
1518 | | Some(main_menu::selector(selector)),
|
||||
1519 | | &NSString::from_str(""),
|
||||
1520 | | );
|
||||
| |_____________^ call to unsafe function
|
||||
|
|
||||
= note: consult the function's documentation for information on how to avoid undefined behavior
|
||||
|
||||
error[E0133]: call to unsafe function `NSMenuItem::menu` is unsafe and requires unsafe block
|
||||
--> crates/ghostty-macos/src/app/app_delegate.rs:1615:33
|
||||
|
|
||||
1615 | let Some(parent_menu) = item.menu() else { return false };
|
||||
| ^^^^^^^^^^^ call to unsafe function
|
||||
|
|
||||
= note: consult the function's documentation for information on how to avoid undefined behavior
|
||||
|
||||
warning: unnecessary `unsafe` block
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:340:38
|
||||
|
|
||||
340 | let s = AllocatedString::new(unsafe { ghostty_config_open_path() }).string();
|
||||
| ^^^^^^ unnecessary `unsafe` block
|
||||
|
|
||||
= note: `#[warn(unused_unsafe)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: unnecessary `unsafe` block
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:482:25
|
||||
|
|
||||
482 | let user_info = unsafe { notification.request().content().userInfo() };
|
||||
| ^^^^^^ unnecessary `unsafe` block
|
||||
|
||||
warning: unnecessary `unsafe` block
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:506:60
|
||||
|
|
||||
506 | let (user_info, action_identifier, notification) = unsafe {
|
||||
| ^^^^^^ unnecessary `unsafe` block
|
||||
|
||||
warning: unnecessary `unsafe` block
|
||||
--> crates/ghostty-macos/src/ghostty/app.rs:1453:30
|
||||
|
|
||||
1453 | let (floating, normal) = unsafe { (NSFloatingWindowLevel, NSNormalWindowLevel) };
|
||||
| ^^^^^^ unnecessary `unsafe` block
|
||||
|
||||
warning: unnecessary `unsafe` block
|
||||
--> crates/ghostty-macos/src/ghostty/surface_view.rs:577:18
|
||||
|
|
||||
577 | let id = unsafe { notification.request().identifier() }.to_string();
|
||||
| ^^^^^^ unnecessary `unsafe` block
|
||||
|
||||
error[E0133]: call to unsafe function `NSResponder::nextResponder` is unsafe and requires unsafe block
|
||||
--> crates/ghostty-macos/src/helpers/extensions/ns_view.rs:63:25
|
||||
|
|
||||
63 | responder = current.nextResponder();
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
||||
|
|
||||
= note: consult the function's documentation for information on how to avoid undefined behavior
|
||||
|
||||
error[E0133]: call to unsafe function `NSView::superview` is unsafe and requires unsafe block
|
||||
--> crates/ghostty-macos/src/helpers/extensions/ns_view.rs:80:37
|
||||
|
|
||||
80 | while let Some(superview) = root.superview() {
|
||||
| ^^^^^^^^^^^^^^^^ call to unsafe function
|
||||
|
|
||||
= note: consult the function's documentation for information on how to avoid undefined behavior
|
||||
|
||||
error[E0133]: call to unsafe function `NSView::superview` is unsafe and requires unsafe block
|
||||
--> crates/ghostty-macos/src/helpers/extensions/ns_view.rs:101:25
|
||||
|
|
||||
101 | let superview = self.superview()?;
|
||||
| ^^^^^^^^^^^^^^^^ call to unsafe function
|
||||
|
|
||||
= note: consult the function's documentation for information on how to avoid undefined behavior
|
||||
|
||||
error[E0133]: call to unsafe function `NSCoder::decodeObjectOfClass_forKey` is unsafe and requires unsafe block
|
||||
--> crates/ghostty-macos/src/helpers/codable_bridge.rs:33:34
|
||||
|
|
||||
33 | let data: Retained<NSData> = coder.decodeObjectOfClass_forKey(NSData::class(), &NSString::from_str(DATA_KEY))
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
||||
|
|
||||
= note: consult the function's documentation for information on how to avoid undefined behavior
|
||||
|
||||
Some errors have detailed explanations: E0061, E0133, E0308, E0425, E0432, E0599.
|
||||
For more information about an error, try `rustc --explain E0061`.
|
||||
warning: `ghostty-macos` (lib) generated 19 warnings
|
||||
error: could not compile `ghostty-macos` (lib) due to 52 previous errors; 19 warnings emitted
|
||||
warning: build failed, waiting for other jobs to finish...
|
||||
warning: `ghostty-macos` (lib test) generated 19 warnings (17 duplicates)
|
||||
error: could not compile `ghostty-macos` (lib test) due to 52 previous errors; 19 warnings emitted
|
||||
@@ -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<AppDelegate>`;
|
||||
//! * `@IBAction`s are Objective-C methods with the same selectors so the
|
||||
//! responder chain / menu targets resolve them exactly like in the nib.
|
||||
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
ffi::CString,
|
||||
ptr::NonNull,
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use block2::{DynBlock, RcBlock};
|
||||
use dispatch2::{DispatchObject, DispatchQueue, DispatchRetained, DispatchSource};
|
||||
use objc2::{
|
||||
AnyThread,
|
||||
DefinedClass,
|
||||
MainThreadMarker,
|
||||
MainThreadOnly,
|
||||
Message,
|
||||
define_class,
|
||||
msg_send,
|
||||
rc::{Retained, Weak},
|
||||
runtime::{AnyObject, ProtocolObject},
|
||||
sel,
|
||||
};
|
||||
use objc2_app_kit::{
|
||||
NSAlert,
|
||||
NSAlertFirstButtonReturn,
|
||||
NSAlertStyle,
|
||||
NSApplication,
|
||||
NSApplicationActivationPolicy,
|
||||
NSApplicationDelegate,
|
||||
NSApplicationTerminateReply,
|
||||
NSEvent,
|
||||
NSEventMask,
|
||||
NSEventModifierFlags,
|
||||
NSEventType,
|
||||
NSMenu,
|
||||
NSMenuItem,
|
||||
NSMenuItemValidation,
|
||||
NSModalResponse,
|
||||
NSSound,
|
||||
NSWindow,
|
||||
NSWindowLevel,
|
||||
NSWindowStyleMask,
|
||||
NSWorkspace,
|
||||
};
|
||||
use objc2_foundation::{
|
||||
NSArray,
|
||||
NSCoder,
|
||||
NSDictionary,
|
||||
NSKeyValueChangeKey,
|
||||
NSNotification,
|
||||
NSObject,
|
||||
NSObjectProtocol,
|
||||
NSProcessInfo,
|
||||
NSSet,
|
||||
NSString,
|
||||
NSURL,
|
||||
NSUUID,
|
||||
};
|
||||
use objc2_user_notifications::{
|
||||
UNAuthorizationOptions,
|
||||
UNAuthorizationStatus,
|
||||
UNNotification,
|
||||
UNNotificationAction,
|
||||
UNNotificationActionOptions,
|
||||
UNNotificationCategory,
|
||||
UNNotificationCategoryOptions,
|
||||
UNNotificationPresentationOptions,
|
||||
UNNotificationResponse,
|
||||
UNNotificationSetting,
|
||||
UNUserNotificationCenter,
|
||||
UNUserNotificationCenterDelegate,
|
||||
};
|
||||
|
||||
use super::{
|
||||
main_menu::{self, Outlets},
|
||||
menu_spec::{DOCK_MENU, MENU_IMAGES, MenuShortcutKey, Outlet, SYNCED_SHORTCUTS},
|
||||
};
|
||||
use crate::{
|
||||
features::{
|
||||
about::AboutController,
|
||||
global_keybinds::GlobalEventTap,
|
||||
quick_terminal::{
|
||||
QUICK_TERMINAL_DID_CHANGE_VISIBILITY,
|
||||
QuickTerminalPosition,
|
||||
controller::{QuickTerminalController, QuickTerminalRestorableState},
|
||||
},
|
||||
secure_input::SecureInput,
|
||||
services::ServiceProvider,
|
||||
settings::ConfigurationErrorsController,
|
||||
terminal::{BaseTerminalController, TerminalController, TerminalWindow},
|
||||
update::UpdateController,
|
||||
},
|
||||
ghostty::{
|
||||
app::{App, Readiness},
|
||||
capi::{
|
||||
consts::{GHOSTTY_ACTION_PRESS, GHOSTTY_COLOR_SCHEME_DARK, GHOSTTY_COLOR_SCHEME_LIGHT},
|
||||
ghostty_app_has_global_keybinds,
|
||||
ghostty_app_key,
|
||||
ghostty_app_key_is_binding,
|
||||
ghostty_app_set_color_scheme,
|
||||
},
|
||||
config::Config,
|
||||
config_types::{AutoUpdate, BellFeatures, MacDockDropBehavior, MacHidden},
|
||||
notification as gn,
|
||||
package::{LaunchSource, SetSecureInput, launch_source},
|
||||
shell,
|
||||
surface_configuration::SurfaceConfiguration,
|
||||
surface_view::SurfaceView,
|
||||
},
|
||||
helpers::{
|
||||
extensions::{
|
||||
ns_menu_item::NSMenuItemExt,
|
||||
ns_notification::{self, NsObserver},
|
||||
ns_workspace::NSWorkspaceExt,
|
||||
user_defaults,
|
||||
},
|
||||
notification_center::{Notification, NotificationCenter, ObserverToken},
|
||||
},
|
||||
};
|
||||
|
||||
/// `DerivedConfig`: the parts of the config the delegate caches.
|
||||
#[derive(Clone, Copy)]
|
||||
struct DerivedConfig {
|
||||
initial_window: bool,
|
||||
should_quit_after_last_window_closed: bool,
|
||||
quick_terminal_position: QuickTerminalPosition,
|
||||
}
|
||||
|
||||
impl Default for DerivedConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_window: true,
|
||||
should_quit_after_last_window_closed: false,
|
||||
quick_terminal_position: QuickTerminalPosition::Top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DerivedConfig {
|
||||
fn new(config: &Config) -> Self {
|
||||
Self {
|
||||
initial_window: config.initial_window(),
|
||||
should_quit_after_last_window_closed: config.should_quit_after_last_window_closed(),
|
||||
quick_terminal_position: config.quick_terminal_position(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `QuickTerminalState`.
|
||||
enum QuickTerminalState {
|
||||
/// Controller has not been initialized and has no pending restoration state.
|
||||
Uninitialized,
|
||||
/// Restoration state is pending; controller will use this when first accessed.
|
||||
/// (Set by `application(_:didDecodeRestorableState:)`; TODO(MA4).)
|
||||
#[allow(dead_code)]
|
||||
PendingRestore(QuickTerminalRestorableState),
|
||||
/// Controller has been initialized.
|
||||
Initialized(Retained<QuickTerminalController>),
|
||||
}
|
||||
|
||||
/// `AppDelegate.ToggleVisibilityState`.
|
||||
pub struct ToggleVisibilityState {
|
||||
hidden_windows: Vec<Weak<NSWindow>>,
|
||||
key_window: Option<Weak<NSWindow>>,
|
||||
}
|
||||
|
||||
impl ToggleVisibilityState {
|
||||
fn new(mtm: MainThreadMarker) -> Self {
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
// We need to know the key window so that we can bring focus back to the
|
||||
// right window if it was hidden.
|
||||
let key_window = app.keyWindow().map(|w| Weak::from_retained(&w));
|
||||
|
||||
// We need to keep track of the windows that were visible because we only
|
||||
// want to bring back these windows if we remove the toggle.
|
||||
//
|
||||
// We also ignore fullscreen windows because they don't hide anyways.
|
||||
let mut visible: Vec<Retained<NSWindow>> = Vec::new();
|
||||
for window in app.windows() {
|
||||
if !window.isVisible() || window.styleMask().contains(NSWindowStyleMask::FullScreen) {
|
||||
continue;
|
||||
}
|
||||
// We only keep track of selectedWindow if it's in a tabGroup,
|
||||
// so we can keep its selection state when restoring
|
||||
let to_hide = window
|
||||
.tabGroup()
|
||||
.and_then(|g| g.selectedWindow())
|
||||
.unwrap_or(window);
|
||||
if !visible
|
||||
.iter()
|
||||
.any(|w| Retained::as_ptr(w) == Retained::as_ptr(&to_hide))
|
||||
{
|
||||
visible.push(to_hide);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
hidden_windows: visible.iter().map(Weak::from_retained).collect(),
|
||||
key_window,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(&self) {
|
||||
for w in &self.hidden_windows {
|
||||
if let Some(w) = w.load() {
|
||||
w.orderFrontRegardless();
|
||||
}
|
||||
}
|
||||
if let Some(w) = self.key_window.as_ref().and_then(Weak::load) {
|
||||
w.makeKeyWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance variables of [`AppDelegate`].
|
||||
pub struct Ivars {
|
||||
/// The ghostty global state. Only one per process.
|
||||
ghostty: Rc<App>,
|
||||
/// The `@IBOutlet` menu items.
|
||||
outlets: RefCell<HashMap<Outlet, Retained<NSMenuItem>>>,
|
||||
services_menu: RefCell<Option<Retained<NSMenu>>>,
|
||||
/// The dock menu.
|
||||
dock_menu: Retained<NSMenu>,
|
||||
/// This is only true before application has become active.
|
||||
application_has_become_active: Cell<bool>,
|
||||
/// Set in applicationDidFinishLaunching with the system uptime.
|
||||
application_launch_time: Cell<f64>,
|
||||
derived_config: Cell<DerivedConfig>,
|
||||
/// The global undo manager for app-level state such as window restoration.
|
||||
undo_manager: Retained<crate::helpers::expiring_undo_manager::ExpiringUndoManager>,
|
||||
quick_terminal_state: RefCell<QuickTerminalState>,
|
||||
/// Manages updates.
|
||||
update_controller: UpdateController,
|
||||
/// Tracks the windows that we hid for toggleVisibility.
|
||||
hidden_state: RefCell<Option<ToggleVisibilityState>>,
|
||||
/// Signals.
|
||||
signals: RefCell<Vec<DispatchRetained<DispatchSource>>>,
|
||||
/// Ghostty menu items indexed by their normalized shortcut. This avoids
|
||||
/// traversing the entire menu tree on every key equivalent event. Weak so the
|
||||
/// cache can never own menu items; the most recent item wins.
|
||||
menu_items_by_shortcut: RefCell<HashMap<MenuShortcutKey, Weak<NSMenuItem>>>,
|
||||
/// Observation tokens (NotificationCenter observers).
|
||||
observers: RefCell<Vec<NsObserver>>,
|
||||
bus_observers: RefCell<Vec<ObserverToken>>,
|
||||
local_monitor: RefCell<Option<Retained<AnyObject>>>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
// SAFETY: NSObject has no subclassing requirements; AppDelegate does not
|
||||
// implement Drop.
|
||||
#[unsafe(super(NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "AppDelegate"]
|
||||
#[ivars = Ivars]
|
||||
pub struct AppDelegate;
|
||||
|
||||
// SAFETY: NSObjectProtocol has no requirements.
|
||||
unsafe impl NSObjectProtocol for AppDelegate {}
|
||||
// SAFETY: the protocol methods are implemented in the inherent block below
|
||||
// with the protocol's selectors/signatures.
|
||||
unsafe impl NSApplicationDelegate for AppDelegate {}
|
||||
// SAFETY: as above.
|
||||
unsafe impl UNUserNotificationCenterDelegate for AppDelegate {}
|
||||
// SAFETY: as above.
|
||||
unsafe impl NSMenuItemValidation for AppDelegate {}
|
||||
|
||||
impl AppDelegate {
|
||||
// MARK: - NSApplicationDelegate
|
||||
|
||||
#[unsafe(method(applicationWillFinishLaunching:))]
|
||||
fn application_will_finish_launching(&self, _notification: &NSNotification) {
|
||||
if cfg!(debug_assertions) {
|
||||
if user_defaults::ghostty_suite().is_some() {
|
||||
let clear = std::env::var("GHOSTTY_CLEAR_USER_DEFAULTS").ok();
|
||||
if clear.as_deref().is_some_and(objc_bool_value) {
|
||||
super::support::remove_persistent_domain_for_ghostty_suite();
|
||||
}
|
||||
}
|
||||
}
|
||||
super::support::register_defaults(&[
|
||||
// Disable the automatic full screen menu item because we handle
|
||||
// it manually.
|
||||
("NSFullScreenMenuItemEverywhere", false),
|
||||
// On macOS 26 RC1, the autofill heuristic controller causes unusable levels
|
||||
// of slowdowns and CPU usage in the terminal window under certain [unknown]
|
||||
// conditions. We don't know exactly why/how. This disables the full heuristic
|
||||
// controller.
|
||||
//
|
||||
// Practically, this means things like SMS autofill don't work, but that is
|
||||
// a desirable behavior to NOT have happen for a terminal, so this is a win.
|
||||
// Manual autofill via the `Edit => AutoFill` menu item still work as expected.
|
||||
("NSAutoFillHeuristicControllerEnabled", false),
|
||||
]);
|
||||
}
|
||||
|
||||
#[unsafe(method(applicationDidFinishLaunching:))]
|
||||
fn application_did_finish_launching(&self, _notification: &NSNotification) {
|
||||
self.did_finish_launching();
|
||||
}
|
||||
|
||||
#[unsafe(method(applicationDidHide:))]
|
||||
fn application_did_hide(&self, _notification: &NSNotification) {
|
||||
// Keep track of our hidden state to restore properly
|
||||
*self.ivars().hidden_state.borrow_mut() = Some(ToggleVisibilityState::new(self.mtm()));
|
||||
}
|
||||
|
||||
#[unsafe(method(applicationDidBecomeActive:))]
|
||||
fn application_did_become_active(&self, _notification: &NSNotification) {
|
||||
self.did_become_active();
|
||||
}
|
||||
|
||||
#[unsafe(method(applicationShouldTerminateAfterLastWindowClosed:))]
|
||||
fn application_should_terminate_after_last_window_closed(&self, _sender: &NSApplication) -> bool {
|
||||
self.ivars().derived_config.get().should_quit_after_last_window_closed
|
||||
}
|
||||
|
||||
#[unsafe(method(applicationShouldTerminate:))]
|
||||
fn application_should_terminate(&self, sender: &NSApplication) -> NSApplicationTerminateReply {
|
||||
self.should_terminate(sender)
|
||||
}
|
||||
|
||||
#[unsafe(method(applicationWillTerminate:))]
|
||||
fn application_will_terminate(&self, _notification: &NSNotification) {
|
||||
// We have no notifications we want to persist after death,
|
||||
// so remove them all now. In the future we may want to be
|
||||
// more selective and only remove surface-targeted notifications.
|
||||
UNUserNotificationCenter::currentNotificationCenter().removeAllDeliveredNotifications();
|
||||
}
|
||||
|
||||
/// This is called when the application is already open and someone
|
||||
/// double-clicks the icon or clicks the dock icon.
|
||||
#[unsafe(method(applicationShouldHandleReopen:hasVisibleWindows:))]
|
||||
fn application_should_handle_reopen(&self, _sender: &NSApplication, flag: bool) -> bool {
|
||||
self.should_handle_reopen(flag)
|
||||
}
|
||||
|
||||
#[unsafe(method(application:openFile:))]
|
||||
fn application_open_file(&self, _sender: &NSApplication, filename: &NSString) -> bool {
|
||||
self.open_file(&filename.to_string())
|
||||
}
|
||||
|
||||
// MARK: Restorable state
|
||||
|
||||
/// We support NSSecureCoding for restorable state. Required as of macOS
|
||||
/// Sonoma (14) but a good idea anyways.
|
||||
#[unsafe(method(applicationSupportsSecureRestorableState:))]
|
||||
fn application_supports_secure_restorable_state(&self, _app: &NSApplication) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[unsafe(method(application:willEncodeRestorableState:))]
|
||||
fn application_will_encode_restorable_state(&self, _app: &NSApplication, _coder: &NSCoder) {
|
||||
log::debug!("application will save window state");
|
||||
if self.ivars().ghostty.config().window_save_state() == "never" {
|
||||
return;
|
||||
}
|
||||
// TODO(MA4): encode the quick terminal state
|
||||
// (QuickTerminalRestorableState.encode(with:)).
|
||||
match &*self.ivars().quick_terminal_state.borrow() {
|
||||
QuickTerminalState::Initialized(c) if c.restorable() => {
|
||||
log::warn!("TODO(MA4): encode quick terminal restorable state");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(method(application:didDecodeRestorableState:))]
|
||||
fn application_did_decode_restorable_state(&self, _app: &NSApplication, _coder: &NSCoder) {
|
||||
log::debug!("application will restore window state");
|
||||
// TODO(MA4): decode QuickTerminalRestorableState(coder:) and, if present
|
||||
// and window-save-state != never, set `.pendingRestore(state)`.
|
||||
}
|
||||
|
||||
#[unsafe(method_id(applicationDockMenu:))]
|
||||
fn application_dock_menu(&self, _sender: &NSApplication) -> Option<Retained<NSMenu>> {
|
||||
Some(self.ivars().dock_menu.clone())
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
#[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))]
|
||||
fn user_notification_did_receive(
|
||||
&self,
|
||||
_center: &UNUserNotificationCenter,
|
||||
response: &UNNotificationResponse,
|
||||
completion: &DynBlock<dyn Fn()>,
|
||||
) {
|
||||
self.ivars().ghostty.handle_user_notification(response);
|
||||
completion.call(());
|
||||
}
|
||||
|
||||
#[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
|
||||
fn user_notification_will_present(
|
||||
&self,
|
||||
_center: &UNUserNotificationCenter,
|
||||
notification: &UNNotification,
|
||||
completion: &DynBlock<dyn Fn(UNNotificationPresentationOptions)>,
|
||||
) {
|
||||
let should_present = self.ivars().ghostty.should_present_notification(notification);
|
||||
let options = if should_present {
|
||||
UNNotificationPresentationOptions::Banner | UNNotificationPresentationOptions::Sound
|
||||
} else {
|
||||
UNNotificationPresentationOptions::empty()
|
||||
};
|
||||
completion.call((options,));
|
||||
}
|
||||
|
||||
// MARK: - KVO (NSApplication.effectiveAppearance)
|
||||
|
||||
#[unsafe(method(observeValueForKeyPath:ofObject:change:context:))]
|
||||
fn observe_value(
|
||||
&self,
|
||||
key_path: Option<&NSString>,
|
||||
_object: Option<&AnyObject>,
|
||||
_change: Option<&NSDictionary<NSKeyValueChangeKey, AnyObject>>,
|
||||
_context: *mut std::ffi::c_void,
|
||||
) {
|
||||
if key_path.is_some_and(|k| k.to_string() == "effectiveAppearance") {
|
||||
self.report_color_scheme();
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IB Actions
|
||||
|
||||
#[unsafe(method(openConfig:))]
|
||||
fn open_config_action(&self, _sender: Option<&AnyObject>) {
|
||||
App::open_config();
|
||||
}
|
||||
|
||||
#[unsafe(method(reloadConfig:))]
|
||||
fn reload_config_action(&self, _sender: Option<&AnyObject>) {
|
||||
self.ivars().ghostty.reload_config(false);
|
||||
}
|
||||
|
||||
#[unsafe(method(checkForUpdates:))]
|
||||
fn check_for_updates_action(&self, _sender: Option<&AnyObject>) {
|
||||
self.ivars().update_controller.check_for_updates();
|
||||
// UpdateSimulator.happyPath.simulate(with: updateViewModel)
|
||||
}
|
||||
|
||||
#[unsafe(method(newWindow:))]
|
||||
fn new_window_action(&self, _sender: Option<&AnyObject>) {
|
||||
let _ = TerminalController::new_window(&self.ivars().ghostty, None);
|
||||
}
|
||||
|
||||
#[unsafe(method(newTab:))]
|
||||
fn new_tab_action(&self, _sender: Option<&AnyObject>) {
|
||||
let parent = TerminalController::preferred_parent().and_then(|c| c.window());
|
||||
let _ = TerminalController::new_tab(&self.ivars().ghostty, parent.as_deref(), None);
|
||||
}
|
||||
|
||||
#[unsafe(method(closeAllWindows:))]
|
||||
fn close_all_windows_action(&self, _sender: Option<&AnyObject>) {
|
||||
TerminalController::close_all_windows();
|
||||
AboutController::shared().hide();
|
||||
}
|
||||
|
||||
#[unsafe(method(showAbout:))]
|
||||
fn show_about_action(&self, _sender: Option<&AnyObject>) {
|
||||
AboutController::shared().show();
|
||||
}
|
||||
|
||||
#[unsafe(method(showHelp:))]
|
||||
fn show_help_action(&self, _sender: Option<&AnyObject>) {
|
||||
if let Some(url) = NSURL::URLWithString(&NSString::from_str("https://ghostty.org/docs")) {
|
||||
NSWorkspace::sharedWorkspace().openURL(&url);
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(method(toggleSecureInput:))]
|
||||
fn toggle_secure_input_action(&self, _sender: Option<&AnyObject>) {
|
||||
self.set_secure_input(SetSecureInput::Toggle);
|
||||
}
|
||||
|
||||
#[unsafe(method(toggleQuickTerminal:))]
|
||||
fn toggle_quick_terminal_action(&self, _sender: Option<&AnyObject>) {
|
||||
self.quick_controller().toggle();
|
||||
}
|
||||
|
||||
/// Toggles visibility of all Ghosty Terminal windows. When hidden,
|
||||
/// activates Ghostty as the frontmost application.
|
||||
#[unsafe(method(toggleVisibility:))]
|
||||
fn toggle_visibility_action(&self, _sender: Option<&AnyObject>) {
|
||||
self.toggle_visibility();
|
||||
}
|
||||
|
||||
#[unsafe(method(bringAllToFront:))]
|
||||
fn bring_all_to_front_action(&self, sender: Option<&AnyObject>) {
|
||||
let app = NSApplication::sharedApplication(self.mtm());
|
||||
if !app.isActive() {
|
||||
#[allow(deprecated)]
|
||||
app.activateIgnoringOtherApps(true);
|
||||
}
|
||||
app.arrangeInFront(sender);
|
||||
}
|
||||
|
||||
#[unsafe(method(undo:))]
|
||||
fn undo_action(&self, _sender: Option<&AnyObject>) {
|
||||
self.ivars().undo_manager.undo();
|
||||
}
|
||||
|
||||
#[unsafe(method(redo:))]
|
||||
fn redo_action(&self, _sender: Option<&AnyObject>) {
|
||||
self.ivars().undo_manager.redo();
|
||||
}
|
||||
|
||||
#[unsafe(method(floatOnTop:))]
|
||||
fn float_on_top_action(&self, menu_item: &NSMenuItem) {
|
||||
use objc2_app_kit::{NSControlStateValueOff, NSControlStateValueOn};
|
||||
let on = menu_item.state() == NSControlStateValueOn;
|
||||
menu_item.setState(if on { NSControlStateValueOff } else { NSControlStateValueOn });
|
||||
let Some(window) = NSApplication::sharedApplication(self.mtm()).keyWindow() else { return };
|
||||
window.setLevel(if menu_item.state() == NSControlStateValueOn {
|
||||
FLOATING_WINDOW_LEVEL
|
||||
} else {
|
||||
NORMAL_WINDOW_LEVEL
|
||||
});
|
||||
}
|
||||
|
||||
#[unsafe(method(useAsDefault:))]
|
||||
fn use_as_default_action(&self, _sender: &NSMenuItem) {
|
||||
use objc2_app_kit::NSControlStateValueOn;
|
||||
let ud = user_defaults::ghostty();
|
||||
let key = NSString::from_str(TerminalWindow::DEFAULT_LEVEL_KEY);
|
||||
let float_on = self
|
||||
.menu_item(Outlet::MenuFloatOnTop)
|
||||
.is_some_and(|i| i.state() == NSControlStateValueOn);
|
||||
if float_on {
|
||||
ud.setInteger_forKey(FLOATING_WINDOW_LEVEL, &key);
|
||||
} else {
|
||||
ud.removeObjectForKey(&key);
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(method(setAsDefaultTerminal:))]
|
||||
fn set_as_default_terminal_action(&self, _sender: &NSMenuItem) {
|
||||
self.set_as_default_terminal();
|
||||
}
|
||||
|
||||
// MARK: NSMenuItemValidation
|
||||
|
||||
#[unsafe(method(validateMenuItem:))]
|
||||
fn validate_menu_item(&self, item: &NSMenuItem) -> bool {
|
||||
self.validate(item)
|
||||
}
|
||||
|
||||
// MARK: Services / dock helpers are in the inherent impl below.
|
||||
}
|
||||
);
|
||||
|
||||
const NORMAL_WINDOW_LEVEL: NSWindowLevel = 0;
|
||||
const FLOATING_WINDOW_LEVEL: NSWindowLevel = 3;
|
||||
|
||||
/// `(clear as NSString).boolValue`.
|
||||
fn objc_bool_value(s: &str) -> bool {
|
||||
NSString::from_str(s).boolValue()
|
||||
}
|
||||
|
||||
impl AppDelegate {
|
||||
/// `override init()`: creates the Ghostty app and the delegate.
|
||||
#[must_use]
|
||||
pub fn new(mtm: MainThreadMarker) -> Retained<Self> {
|
||||
let config_path = if cfg!(debug_assertions) {
|
||||
std::env::var("GHOSTTY_CONFIG_PATH").ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ghostty = App::new(config_path.as_deref());
|
||||
let ivars = Ivars {
|
||||
ghostty,
|
||||
outlets: RefCell::default(),
|
||||
services_menu: RefCell::default(),
|
||||
dock_menu: NSMenu::new(mtm),
|
||||
application_has_become_active: Cell::new(false),
|
||||
application_launch_time: Cell::new(0.0),
|
||||
derived_config: Cell::new(DerivedConfig::default()),
|
||||
undo_manager: crate::helpers::expiring_undo_manager::ExpiringUndoManager::new(mtm),
|
||||
quick_terminal_state: RefCell::new(QuickTerminalState::Uninitialized),
|
||||
update_controller: UpdateController::new(),
|
||||
hidden_state: RefCell::default(),
|
||||
signals: RefCell::default(),
|
||||
menu_items_by_shortcut: RefCell::default(),
|
||||
observers: RefCell::default(),
|
||||
bus_observers: RefCell::default(),
|
||||
local_monitor: RefCell::default(),
|
||||
};
|
||||
let this = Self::alloc(mtm).set_ivars(ivars);
|
||||
// SAFETY: `init` is NSObject's designated initializer.
|
||||
let this: Retained<Self> = unsafe { msg_send![super(this), init] };
|
||||
this.ivars()
|
||||
.ghostty
|
||||
.set_delegate(Weak::from_retained(&this));
|
||||
this
|
||||
}
|
||||
|
||||
/// `NSApp.delegate as? AppDelegate`.
|
||||
#[must_use]
|
||||
pub fn shared(mtm: MainThreadMarker) -> Option<Retained<Self>> {
|
||||
let delegate = NSApplication::sharedApplication(mtm).delegate()?;
|
||||
// SAFETY: a protocol object is an Objective-C object; we only use the
|
||||
// result to attempt a checked downcast.
|
||||
let obj: Retained<AnyObject> = unsafe { Retained::cast_unchecked(delegate) };
|
||||
obj.downcast::<Self>().ok()
|
||||
}
|
||||
|
||||
/// Connect the menu built from the xib spec (`@IBOutlet`s) -- done by
|
||||
/// `entry::run` before the application starts, like nib loading.
|
||||
pub fn connect_outlets(&self, outlets: Outlets) {
|
||||
*self.ivars().outlets.borrow_mut() = outlets.items;
|
||||
*self.ivars().services_menu.borrow_mut() = outlets.services_menu;
|
||||
}
|
||||
|
||||
/// `applicationShouldHandleReopen(_:hasVisibleWindows:)`: called when the
|
||||
/// application is already open and someone double-clicks the icon or
|
||||
/// clicks the dock icon.
|
||||
fn should_handle_reopen(&self, flag: bool) -> bool {
|
||||
// If we have visible windows then we allow macOS to do its default behavior
|
||||
// of focusing one of them.
|
||||
if flag {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we have any windows in our terminal manager we don't do anything.
|
||||
// This is possible with flag set to false if there a race where the
|
||||
// window is still initializing and is not visible but the user clicked
|
||||
// the dock icon.
|
||||
if !TerminalController::all().is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the application isn't active yet then we don't want to process
|
||||
// this because we're not ready. This happens sometimes in Xcode runs
|
||||
// but I haven't seen it happen in releases. I'm unsure why.
|
||||
if !self.ivars().application_has_become_active.get() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No visible windows, open a new one.
|
||||
let _ = TerminalController::new_window(&self.ivars().ghostty, None);
|
||||
false
|
||||
}
|
||||
|
||||
/// `ghostty`.
|
||||
#[must_use]
|
||||
pub fn ghostty(&self) -> Rc<App> {
|
||||
self.ivars().ghostty.clone()
|
||||
}
|
||||
|
||||
/// `undoManager`.
|
||||
#[must_use]
|
||||
pub fn undo_manager(
|
||||
&self,
|
||||
) -> Retained<crate::helpers::expiring_undo_manager::ExpiringUndoManager> {
|
||||
self.ivars().undo_manager.clone()
|
||||
}
|
||||
|
||||
/// `updateController`.
|
||||
#[must_use]
|
||||
pub fn update_controller(&self) -> &UpdateController {
|
||||
&self.ivars().update_controller
|
||||
}
|
||||
|
||||
/// `hiddenState`.
|
||||
#[must_use]
|
||||
pub fn has_hidden_state(&self) -> bool {
|
||||
self.ivars().hidden_state.borrow().is_some()
|
||||
}
|
||||
|
||||
fn menu_item(&self, outlet: Outlet) -> Option<Retained<NSMenuItem>> {
|
||||
self.ivars().outlets.borrow().get(&outlet).cloned()
|
||||
}
|
||||
|
||||
/// The elapsed time since the process was started.
|
||||
fn time_since_launch(&self) -> f64 {
|
||||
NSProcessInfo::processInfo().systemUptime() - self.ivars().application_launch_time.get()
|
||||
}
|
||||
|
||||
/// Our quick terminal. This starts out uninitialized and only initializes if used.
|
||||
#[must_use]
|
||||
pub fn quick_controller(&self) -> Retained<QuickTerminalController> {
|
||||
let ivars = self.ivars();
|
||||
let mut state = ivars.quick_terminal_state.borrow_mut();
|
||||
match &*state {
|
||||
QuickTerminalState::Initialized(controller) => controller.clone(),
|
||||
QuickTerminalState::PendingRestore(restore) => {
|
||||
let controller = QuickTerminalController::new(
|
||||
&ivars.ghostty,
|
||||
ivars.derived_config.get().quick_terminal_position,
|
||||
restore.base_config(),
|
||||
Some(restore),
|
||||
);
|
||||
*state = QuickTerminalState::Initialized(controller.clone());
|
||||
controller
|
||||
}
|
||||
QuickTerminalState::Uninitialized => {
|
||||
let controller = QuickTerminalController::new(
|
||||
&ivars.ghostty,
|
||||
ivars.derived_config.get().quick_terminal_position,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
*state = QuickTerminalState::Initialized(controller.clone());
|
||||
controller
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `findSurface(forUUID:)` (GhosttyAppDelegate) and
|
||||
/// `ghosttySurface(id:)` (Ghostty.Delegate, `AppDelegate+Ghostty.swift`).
|
||||
#[must_use]
|
||||
pub fn find_surface(&self, uuid: &NSUUID) -> Option<Retained<SurfaceView>> {
|
||||
for c in TerminalController::all() {
|
||||
for view in c.surface_tree() {
|
||||
if view.id().isEqual(Some(uuid)) {
|
||||
return Some(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// MARK: Launch
|
||||
|
||||
fn did_finish_launching(&self) {
|
||||
let mtm = self.mtm();
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
let ivars = self.ivars();
|
||||
|
||||
// System settings overrides
|
||||
super::support::register_defaults(&[
|
||||
// Disable this so that repeated key events make it through to our terminal views.
|
||||
("ApplePressAndHoldEnabled", false),
|
||||
]);
|
||||
|
||||
// Store our start time
|
||||
ivars
|
||||
.application_launch_time
|
||||
.set(NSProcessInfo::processInfo().systemUptime());
|
||||
|
||||
// Check if secure input was enabled when we last quit.
|
||||
if user_defaults::ghostty().boolForKey(&NSString::from_str("SecureInput"))
|
||||
!= SecureInput::shared().enabled()
|
||||
{
|
||||
self.set_secure_input(SetSecureInput::Toggle);
|
||||
}
|
||||
|
||||
// Initial config loading
|
||||
self.ghostty_config_did_change(&ivars.ghostty.config());
|
||||
|
||||
// Start our update checker.
|
||||
ivars.update_controller.start_updater();
|
||||
|
||||
// Register our service provider. This must happen after everything is initialized.
|
||||
let provider = ServiceProvider::new();
|
||||
// SAFETY: the provider is retained by NSApplication's `servicesProvider`.
|
||||
unsafe { app.setServicesProvider(Some(&provider)) };
|
||||
|
||||
// This registers the Ghostty => Services menu to exist.
|
||||
app.setServicesMenu(ivars.services_menu.borrow().as_deref());
|
||||
|
||||
// Setup a local event monitor for app-level keyboard shortcuts. See
|
||||
// localEventHandler for more info why.
|
||||
self.install_local_monitor();
|
||||
|
||||
// Notifications
|
||||
self.install_observers();
|
||||
|
||||
// Configure user notifications
|
||||
self.configure_user_notifications();
|
||||
|
||||
// Observe our appearance so we can report the correct value to libghostty.
|
||||
// SAFETY: KVO registration with a live observer (self lives for the whole
|
||||
// process as the application delegate).
|
||||
unsafe {
|
||||
let observer: &AnyObject = self;
|
||||
let _: () = msg_send![
|
||||
&*app,
|
||||
addObserver: observer,
|
||||
forKeyPath: &*NSString::from_str("effectiveAppearance"),
|
||||
options: objc2_foundation::NSKeyValueObservingOptions::New
|
||||
| objc2_foundation::NSKeyValueObservingOptions::Initial,
|
||||
context: std::ptr::null_mut::<std::ffi::c_void>()
|
||||
];
|
||||
}
|
||||
|
||||
// Setup our menu
|
||||
self.setup_menu_images();
|
||||
|
||||
// Setup signal handlers
|
||||
self.setup_signals();
|
||||
|
||||
match launch_source() {
|
||||
LaunchSource::App => {
|
||||
// Don't have to do anything.
|
||||
}
|
||||
LaunchSource::ZigRun | LaunchSource::Cli => {
|
||||
// Part of launch services (clicking an app, using `open`, etc.) activates
|
||||
// the application and brings it to the front. When using the CLI we don't
|
||||
// get this behavior, so we have to do it manually.
|
||||
|
||||
// This never gets called until we click the dock icon. This forces it
|
||||
// activate immediately.
|
||||
self.did_become_active();
|
||||
|
||||
// We run in the background, this forces us to the front.
|
||||
DispatchQueue::main().exec_async(|| {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
|
||||
#[allow(deprecated)]
|
||||
app.activateIgnoringOtherApps(true);
|
||||
app.unhide(None);
|
||||
app.arrangeInFront(None);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn did_become_active(&self) {
|
||||
let ivars = self.ivars();
|
||||
// If we're back manually then clear the hidden state because macOS handles it.
|
||||
*ivars.hidden_state.borrow_mut() = None;
|
||||
|
||||
// First launch stuff
|
||||
if !ivars.application_has_become_active.get() {
|
||||
ivars.application_has_become_active.set(true);
|
||||
|
||||
// Let's launch our first window. We only do this if we have no other windows. It
|
||||
// is possible to have other windows in a few scenarios:
|
||||
// - if we're opening a URL since `application(_:openFile:)` is called before this.
|
||||
// - if we're restoring from persisted state
|
||||
if TerminalController::all().is_empty() && ivars.derived_config.get().initial_window {
|
||||
ivars.undo_manager.disableUndoRegistration();
|
||||
let _ = TerminalController::new_window(&ivars.ghostty, None);
|
||||
ivars.undo_manager.enableUndoRegistration();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_terminate(&self, sender: &NSApplication) -> NSApplicationTerminateReply {
|
||||
let windows = sender.windows();
|
||||
if windows.is_empty() {
|
||||
return NSApplicationTerminateReply::TerminateNow;
|
||||
}
|
||||
|
||||
// If we've already accepted to install an update, then we don't need to
|
||||
// confirm quit. The user is already expecting the update to happen.
|
||||
if self.ivars().update_controller.is_installing() {
|
||||
return NSApplicationTerminateReply::TerminateNow;
|
||||
}
|
||||
|
||||
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
|
||||
// quite work with SwiftUI because windows are retained on close. So instead we check
|
||||
// if there are any that are visible. I'm guessing this breaks under certain scenarios.
|
||||
//
|
||||
// NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it
|
||||
// here because I don't want to remove it in a patch release cycle but we should
|
||||
// target removing it soon.
|
||||
if windows.iter().all(|w| !w.isVisible()) {
|
||||
return NSApplicationTerminateReply::TerminateNow;
|
||||
}
|
||||
|
||||
// If the user is shutting down, restarting, or logging out, we don't confirm quit.
|
||||
if let Some(event) =
|
||||
objc2_foundation::NSAppleEventManager::sharedAppleEventManager().currentAppleEvent()
|
||||
{
|
||||
// If all Ghostty windows are in the background (i.e. you Cmd-Q from the Cmd-Tab
|
||||
// view), then this is null. I don't know why (pun intended) but we have to
|
||||
// guard against it.
|
||||
let keyword = u32::from_be_bytes(*b"why?");
|
||||
// SAFETY: `attributeDescriptorForKeyword:` takes an AEKeyword (u32) and
|
||||
// returns an optional descriptor.
|
||||
let why: Option<Retained<objc2_foundation::NSAppleEventDescriptor>> =
|
||||
unsafe { msg_send![&*event, attributeDescriptorForKeyword: keyword] };
|
||||
if let Some(why) = why {
|
||||
const K_AE_SHUT_DOWN: u32 = u32::from_be_bytes(*b"shut");
|
||||
const K_AE_RESTART: u32 = u32::from_be_bytes(*b"rest");
|
||||
const K_AE_REALLY_LOG_OUT: u32 = u32::from_be_bytes(*b"rlgo");
|
||||
if matches!(
|
||||
why.typeCodeValue(),
|
||||
K_AE_SHUT_DOWN | K_AE_RESTART | K_AE_REALLY_LOG_OUT
|
||||
) {
|
||||
return NSApplicationTerminateReply::TerminateNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If our app says we don't need to confirm, we can exit now.
|
||||
if !self.ivars().ghostty.needs_confirm_quit() {
|
||||
return NSApplicationTerminateReply::TerminateNow;
|
||||
}
|
||||
|
||||
// We have some visible window. Show an app-wide modal to confirm quitting.
|
||||
let alert = NSAlert::new(self.mtm());
|
||||
alert.setMessageText(&NSString::from_str("Quit Ghostty?"));
|
||||
alert.setInformativeText(&NSString::from_str(
|
||||
"All terminal sessions will be terminated.",
|
||||
));
|
||||
alert.addButtonWithTitle(&NSString::from_str("Close Ghostty"));
|
||||
alert.addButtonWithTitle(&NSString::from_str("Cancel"));
|
||||
alert.setAlertStyle(NSAlertStyle::Warning);
|
||||
if alert.runModal() == NSAlertFirstButtonReturn {
|
||||
NSApplicationTerminateReply::TerminateNow
|
||||
} else {
|
||||
NSApplicationTerminateReply::TerminateCancel
|
||||
}
|
||||
}
|
||||
|
||||
/// `application(_:openFile:)`.
|
||||
fn open_file(&self, filename: &str) -> bool {
|
||||
// Ghostty will validate as well but we can avoid creating an entirely new
|
||||
// surface by doing our own validation here. We can also show a useful error
|
||||
// this way.
|
||||
let path = std::path::Path::new(filename);
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
let is_directory = path.is_dir();
|
||||
|
||||
// Set to true if confirmation is required before starting up the
|
||||
// new terminal.
|
||||
let mut requires_confirm = false;
|
||||
|
||||
// Initialize the surface config which will be used to create the tab or window for the opened file.
|
||||
let mut config = SurfaceConfiguration::default();
|
||||
|
||||
if is_directory {
|
||||
// When opening a directory, check the configuration to decide
|
||||
// whether to open in a new tab or new window.
|
||||
config.working_directory = Some(filename.to_string());
|
||||
} else {
|
||||
// Unconditionally require confirmation in the file execution case.
|
||||
// In the future I have ideas about making this more fine-grained if
|
||||
// we can not inherit of unsandboxed state. For now, we need to confirm
|
||||
// because there is a sandbox escape possible if a sandboxed application
|
||||
// somehow is tricked into `open`-ing a non-sandboxed application.
|
||||
requires_confirm = true;
|
||||
|
||||
// When opening a file, we want to execute the file. To do this, we
|
||||
// don't override the command directly, because it won't load the
|
||||
// profile/rc files for the shell, which is super important on macOS
|
||||
// due to things like Homebrew. Instead, we set the command to
|
||||
// `<filename>; exit` which is what Terminal and iTerm2 do.
|
||||
config.initial_input = Some(format!("{}; exit\n", shell::quote(filename)));
|
||||
|
||||
// For commands executed directly, we want to ensure we wait after exit
|
||||
// because in most cases scripts don't block on exit and we don't want
|
||||
// the window to just flash closed once complete.
|
||||
config.wait_after_command = true;
|
||||
|
||||
// Set the parent directory to our working directory so that relative
|
||||
// paths in scripts work.
|
||||
config.working_directory = Some(
|
||||
NSString::from_str(filename)
|
||||
.stringByDeletingLastPathComponent()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if requires_confirm {
|
||||
// Confirmation required. We use an app-wide NSAlert for now. In the future we
|
||||
// may want to show this as a sheet on the focused window (especially if we're
|
||||
// opening a tab). I'm not sure.
|
||||
let alert = NSAlert::new(self.mtm());
|
||||
alert.setMessageText(&NSString::from_str(&format!(
|
||||
"Allow Ghostty to execute \"{filename}\"?"
|
||||
)));
|
||||
alert.addButtonWithTitle(&NSString::from_str("Allow"));
|
||||
alert.addButtonWithTitle(&NSString::from_str("Cancel"));
|
||||
alert.setAlertStyle(NSAlertStyle::Warning);
|
||||
let response: NSModalResponse = alert.runModal();
|
||||
if response != NSAlertFirstButtonReturn {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
match self.ivars().ghostty.config().macos_dock_drop_behavior() {
|
||||
MacDockDropBehavior::NewTab => {
|
||||
let parent = TerminalController::preferred_parent().and_then(|c| c.window());
|
||||
let _ = TerminalController::new_tab(
|
||||
&self.ivars().ghostty,
|
||||
parent.as_deref(),
|
||||
Some(&config),
|
||||
);
|
||||
}
|
||||
MacDockDropBehavior::NewWindow => {
|
||||
let _ = TerminalController::new_window(&self.ivars().ghostty, Some(&config));
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Setup signal handlers.
|
||||
fn setup_signals(&self) {
|
||||
// Register a signal handler for config reloading. It appears that all
|
||||
// of this is required. I've commented each line because its a bit unclear.
|
||||
// Warning: signal handlers don't work when run via Xcode. They have to be
|
||||
// run on a real app bundle.
|
||||
|
||||
// We need to ignore signals we register with makeSignalSource or they
|
||||
// don't seem to handle.
|
||||
// SAFETY: `signal` with SIG_IGN is always sound.
|
||||
unsafe { libc::signal(libc::SIGUSR2, libc::SIG_IGN) };
|
||||
|
||||
// Make the signal source and register our event handle. We keep a weak
|
||||
// ref to ourself so we don't create a retain cycle.
|
||||
// SAFETY: `_dispatch_source_type_signal` is the documented signal
|
||||
// source type and the handle is a signal number.
|
||||
let sigusr2 = unsafe {
|
||||
DispatchSource::new(
|
||||
std::ptr::addr_of!(dispatch2::_dispatch_source_type_signal).cast_mut(),
|
||||
libc::SIGUSR2 as usize,
|
||||
0,
|
||||
Some(DispatchQueue::main()),
|
||||
)
|
||||
};
|
||||
let weak = Weak::from_retained(&self.retain());
|
||||
let handler = RcBlock::new(move || {
|
||||
let Some(this) = weak.load() else { return };
|
||||
log::info!("reloading configuration in response to SIGUSR2");
|
||||
this.ivars().ghostty.reload_config(false);
|
||||
});
|
||||
// SAFETY: the block is copied by libdispatch.
|
||||
unsafe { sigusr2.set_event_handler_with_block(RcBlock::as_ptr(&handler).cast()) };
|
||||
|
||||
// The signal source starts unactivated, so we have to resume it once
|
||||
// we setup the event handler.
|
||||
sigusr2.resume();
|
||||
|
||||
// We need to keep a strong reference to it so it isn't disabled.
|
||||
self.ivars().signals.borrow_mut().push(sigusr2);
|
||||
}
|
||||
|
||||
fn weak_self(&self) -> Weak<Self> {
|
||||
Weak::from_retained(&self.retain())
|
||||
}
|
||||
|
||||
// MARK: Notifications and Events
|
||||
|
||||
fn install_local_monitor(&self) {
|
||||
let weak = self.weak_self();
|
||||
let handler = RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
|
||||
// SAFETY: AppKit passes a valid event.
|
||||
let event_ref = unsafe { event.as_ref() };
|
||||
let Some(this) = weak.load() else {
|
||||
return event.as_ptr();
|
||||
};
|
||||
match this.local_event_handler(event_ref) {
|
||||
true => event.as_ptr(),
|
||||
false => std::ptr::null_mut(),
|
||||
}
|
||||
});
|
||||
// SAFETY: the handler block has the signature AppKit expects (event in,
|
||||
// event or nil out) and is copied by AppKit.
|
||||
let monitor = unsafe {
|
||||
NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &handler)
|
||||
};
|
||||
*self.ivars().local_monitor.borrow_mut() = monitor;
|
||||
}
|
||||
|
||||
/// `localEventHandler`: returns true if the event should continue (Swift
|
||||
/// returned the event), false if it was consumed (Swift returned nil).
|
||||
fn local_event_handler(&self, event: &NSEvent) -> bool {
|
||||
if event.r#type() == NSEventType::KeyDown {
|
||||
return self.local_event_key_down(event);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// This handles events from the NSEvent.addLocalEventMonitor. We use this so
|
||||
/// we can get events without any terminal windows open.
|
||||
fn local_event_key_down(&self, event: &NSEvent) -> bool {
|
||||
let mtm = self.mtm();
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
|
||||
// If the tab overview is visible and escape is pressed, close it.
|
||||
// This can't POSSIBLY be right and is probably a FirstResponder problem
|
||||
// that we should handle elsewhere in our program. But this works and it
|
||||
// is guarded by the tab overview currently showing.
|
||||
if event.keyCode() == 0x35 {
|
||||
if let Some(window) = app.keyWindow() {
|
||||
if let Some(tab_group) = window.tabGroup() {
|
||||
if tab_group.isOverviewVisible() {
|
||||
window.toggleTabOverview(None);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a main window then we don't process any of the keys
|
||||
// because we let it capture and propagate.
|
||||
if app.mainWindow().is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let ghostty = self.ivars().ghostty.clone();
|
||||
|
||||
// If this event as-is would result in a key binding then we send it.
|
||||
if let Some(app_ptr) = ghostty.app_ptr() {
|
||||
let mut ghostty_event =
|
||||
crate::ghostty::ns_event::ghostty_key_event(event, GHOSTTY_ACTION_PRESS, None);
|
||||
let chars = event
|
||||
.characters()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let text = CString::new(chars).unwrap_or_default();
|
||||
ghostty_event.text = text.as_ptr();
|
||||
// SAFETY: `app_ptr` is a live app handle and `text` outlives the calls.
|
||||
let matched = unsafe {
|
||||
if ghostty_app_key_is_binding(app_ptr, ghostty_event) {
|
||||
ghostty_app_key(app_ptr, ghostty_event)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// If the key was handled by Ghostty we stop the event chain. If
|
||||
// the key wasn't handled then we let it fall through and continue
|
||||
// processing. This is important because some bindings may have no
|
||||
// affect at this scope.
|
||||
if matched {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If this event would be handled by our menu then we do nothing.
|
||||
if let Some(main_menu) = app.mainMenu() {
|
||||
if main_menu.performKeyEquivalent(event) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach this point then we try to process the key event
|
||||
// through the Ghostty key mechanism.
|
||||
|
||||
// Ghostty must be loaded
|
||||
let Some(app_ptr) = ghostty.app_ptr() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Build our event input and call ghostty
|
||||
let key = crate::ghostty::ns_event::ghostty_key_event(event, GHOSTTY_ACTION_PRESS, None);
|
||||
// SAFETY: `app_ptr` is a live app handle; `text` is null.
|
||||
if unsafe { ghostty_app_key(app_ptr, key) } {
|
||||
// The key was used so we want to stop it from going to our Mac app
|
||||
log::debug!("local key event handled");
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn install_observers(&self) {
|
||||
let ivars = self.ivars();
|
||||
let mtm = self.mtm();
|
||||
let _ = mtm;
|
||||
|
||||
// AppKit notifications.
|
||||
let weak = self.weak_self();
|
||||
ivars.observers.borrow_mut().push(ns_notification::observe(
|
||||
ns_notification::name_window_did_become_key(),
|
||||
None,
|
||||
move |n| {
|
||||
if let Some(this) = weak.load() {
|
||||
this.window_did_become_key(n);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
// Ghostty's own notifications.
|
||||
let nc = NotificationCenter::default_center();
|
||||
let mut bus = ivars.bus_observers.borrow_mut();
|
||||
|
||||
let weak = self.weak_self();
|
||||
bus.push(
|
||||
nc.add_observer(QUICK_TERMINAL_DID_CHANGE_VISIBILITY, None, move |n| {
|
||||
if let Some(this) = weak.load() {
|
||||
this.quick_terminal_did_change_visibility(n);
|
||||
}
|
||||
}),
|
||||
);
|
||||
let weak = self.weak_self();
|
||||
bus.push(
|
||||
nc.add_observer(gn::GHOSTTY_CONFIG_DID_CHANGE, None, move |n| {
|
||||
if let Some(this) = weak.load() {
|
||||
this.ghostty_config_did_change_notification(n);
|
||||
}
|
||||
}),
|
||||
);
|
||||
let weak = self.weak_self();
|
||||
bus.push(nc.add_observer(gn::GHOSTTY_BELL_DID_RING, None, move |_| {
|
||||
if let Some(this) = weak.load() {
|
||||
this.ghostty_bell_did_ring();
|
||||
}
|
||||
}));
|
||||
let weak = self.weak_self();
|
||||
bus.push(nc.add_observer(
|
||||
gn::TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION,
|
||||
None,
|
||||
move |n| {
|
||||
if let Some(this) = weak.load() {
|
||||
this.terminal_window_has_bell(n);
|
||||
}
|
||||
},
|
||||
));
|
||||
let weak = self.weak_self();
|
||||
bus.push(nc.add_observer(gn::GHOSTTY_NEW_WINDOW, None, move |n| {
|
||||
if let Some(this) = weak.load() {
|
||||
this.ghostty_new_window(n);
|
||||
}
|
||||
}));
|
||||
let weak = self.weak_self();
|
||||
bus.push(nc.add_observer(gn::GHOSTTY_NEW_TAB, None, move |n| {
|
||||
if let Some(this) = weak.load() {
|
||||
this.ghostty_new_tab(n);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn configure_user_notifications(&self) {
|
||||
let show = UNNotificationAction::actionWithIdentifier_title_options(
|
||||
&NSString::from_str(crate::ghostty::package::USER_NOTIFICATION_ACTION_SHOW),
|
||||
&NSString::from_str("Show"),
|
||||
UNNotificationActionOptions::empty(),
|
||||
);
|
||||
let center = UNUserNotificationCenter::currentNotificationCenter();
|
||||
let category =
|
||||
UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options(
|
||||
&NSString::from_str(crate::ghostty::package::USER_NOTIFICATION_CATEGORY),
|
||||
&NSArray::from_retained_slice(&[show]),
|
||||
&NSArray::new(),
|
||||
UNNotificationCategoryOptions::CustomDismissAction,
|
||||
);
|
||||
center.setNotificationCategories(&NSSet::from_retained_slice(&[category]));
|
||||
center.setDelegate(Some(ProtocolObject::from_ref(self)));
|
||||
}
|
||||
|
||||
/// Report the effective appearance to libghostty (the KVO callback body).
|
||||
fn report_color_scheme(&self) {
|
||||
let Some(app) = self.ivars().ghostty.app_ptr() else {
|
||||
return;
|
||||
};
|
||||
let appearance = NSApplication::sharedApplication(self.mtm()).effectiveAppearance();
|
||||
let scheme =
|
||||
if crate::helpers::extensions::ns_appearance::NSAppearanceExt::is_dark(&*appearance) {
|
||||
GHOSTTY_COLOR_SCHEME_DARK
|
||||
} else {
|
||||
GHOSTTY_COLOR_SCHEME_LIGHT
|
||||
};
|
||||
// SAFETY: `app` is a live app handle.
|
||||
unsafe { ghostty_app_set_color_scheme(app, scheme) };
|
||||
}
|
||||
|
||||
fn window_did_become_key(&self, notification: &NSNotification) {
|
||||
let window = notification
|
||||
.object()
|
||||
.and_then(|o| o.downcast::<NSWindow>().ok());
|
||||
self.sync_float_on_top_menu(window.as_deref());
|
||||
}
|
||||
|
||||
fn quick_terminal_did_change_visibility(&self, n: &Notification) {
|
||||
use objc2_app_kit::{NSControlStateValueOff, NSControlStateValueOn};
|
||||
let Some(controller) = n
|
||||
.object
|
||||
.as_ref()
|
||||
.and_then(|o| o.downcast_ref::<Retained<QuickTerminalController>>())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(item) = self.menu_item(Outlet::MenuQuickTerminal) {
|
||||
item.setState(if controller.visible() {
|
||||
NSControlStateValueOn
|
||||
} else {
|
||||
NSControlStateValueOff
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn ghostty_config_did_change_notification(&self, n: &Notification) {
|
||||
// We only care if the configuration is a global configuration, not a surface one.
|
||||
if n.object.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get our managed configuration object out
|
||||
let Some(config) = n.user_info.get::<Rc<Config>>(gn::GHOSTTY_CONFIG_CHANGE_KEY) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.ghostty_config_did_change(config);
|
||||
}
|
||||
|
||||
fn ghostty_bell_did_ring(&self) {
|
||||
let config = self.ivars().ghostty.config();
|
||||
let features = config.bell_features();
|
||||
if features.contains(BellFeatures::SYSTEM) {
|
||||
// SAFETY: NSBeep has no preconditions.
|
||||
unsafe { NSBeep() };
|
||||
}
|
||||
|
||||
if features.contains(BellFeatures::AUDIO) {
|
||||
if let Some(path) = config.bell_audio_path() {
|
||||
let sound = NSSound::initWithContentsOfFile_byReference(
|
||||
NSSound::alloc(),
|
||||
&NSString::from_str(&path.path),
|
||||
false,
|
||||
);
|
||||
if let Some(sound) = sound {
|
||||
sound.setVolume(config.bell_audio_volume());
|
||||
sound.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if features.contains(BellFeatures::ATTENTION) {
|
||||
// Bounce the dock icon if we're not focused.
|
||||
NSApplication::sharedApplication(self.mtm()).requestUserAttention(
|
||||
objc2_app_kit::NSRequestUserAttentionType::InformationalRequest,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_window_has_bell(&self, n: &Notification) {
|
||||
if n.object
|
||||
.as_ref()
|
||||
.and_then(|o| o.downcast_ref::<Retained<BaseTerminalController>>())
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.sync_dock_badge();
|
||||
}
|
||||
|
||||
fn sync_dock_badge(&self) {
|
||||
let center = UNUserNotificationCenter::currentNotificationCenter();
|
||||
let center2 = center.clone();
|
||||
let handler = RcBlock::new(
|
||||
move |settings: NonNull<objc2_user_notifications::UNNotificationSettings>| {
|
||||
// SAFETY: valid settings object for the duration of the callback.
|
||||
let settings = unsafe { settings.as_ref() };
|
||||
match settings.authorizationStatus() {
|
||||
UNAuthorizationStatus::Authorized => {
|
||||
// If we're authorized and allow badges, then set the badge.
|
||||
if settings.badgeSetting() == UNNotificationSetting::Enabled {
|
||||
DispatchQueue::main().exec_async(set_dock_badge_on_main);
|
||||
}
|
||||
}
|
||||
UNAuthorizationStatus::NotDetermined => {
|
||||
// Not determined yet, request authorization for badge
|
||||
let completion = RcBlock::new(
|
||||
|granted: objc2::runtime::Bool,
|
||||
error: *mut objc2_foundation::NSError| {
|
||||
if !error.is_null() {
|
||||
// SAFETY: non-null error pointer provided by the framework.
|
||||
let e = unsafe { &*error };
|
||||
log::warn!(
|
||||
"Error requesting badge authorization: {}",
|
||||
e.localizedDescription()
|
||||
);
|
||||
return;
|
||||
}
|
||||
if granted.as_bool() {
|
||||
// Permission granted, set the badge
|
||||
DispatchQueue::main().exec_async(set_dock_badge_on_main);
|
||||
}
|
||||
},
|
||||
);
|
||||
center2.requestAuthorizationWithOptions_completionHandler(
|
||||
UNAuthorizationOptions::Badge,
|
||||
&completion,
|
||||
);
|
||||
}
|
||||
// In these known non-authorized states, do not attempt to set the badge;
|
||||
// future unknown states do nothing either.
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
);
|
||||
center.getNotificationSettingsWithCompletionHandler(&handler);
|
||||
}
|
||||
|
||||
fn ghostty_new_window(&self, n: &Notification) {
|
||||
let config = n
|
||||
.user_info
|
||||
.get::<SurfaceConfiguration>(gn::NEW_SURFACE_CONFIG_KEY);
|
||||
let _ = TerminalController::new_window(&self.ivars().ghostty, config);
|
||||
}
|
||||
|
||||
fn ghostty_new_tab(&self, n: &Notification) {
|
||||
let Some(surface_view) = n
|
||||
.object
|
||||
.as_ref()
|
||||
.and_then(|o| o.downcast_ref::<Retained<SurfaceView>>())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(window) = surface_view.window() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// We only want to listen to new tabs if the focused parent is
|
||||
// a regular terminal controller.
|
||||
let is_terminal = window
|
||||
.windowController()
|
||||
.is_some_and(|c| c.downcast::<TerminalController>().is_ok());
|
||||
if !is_terminal {
|
||||
return;
|
||||
}
|
||||
|
||||
let config = n
|
||||
.user_info
|
||||
.get::<SurfaceConfiguration>(gn::NEW_SURFACE_CONFIG_KEY);
|
||||
let _ = TerminalController::new_tab(&self.ivars().ghostty, Some(&window), config);
|
||||
}
|
||||
|
||||
/// `ghosttyConfigDidChange(config:)`.
|
||||
fn ghostty_config_did_change(&self, config: &Rc<Config>) {
|
||||
let ivars = self.ivars();
|
||||
let mtm = self.mtm();
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
|
||||
// Update the config we need to store
|
||||
ivars.derived_config.set(DerivedConfig::new(config));
|
||||
|
||||
// Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows
|
||||
// configuration. This is the only way to carefully control whether macOS invokes the
|
||||
// state restoration system.
|
||||
let ud = user_defaults::ghostty();
|
||||
let key = NSString::from_str("NSQuitAlwaysKeepsWindows");
|
||||
match config.window_save_state().as_str() {
|
||||
"never" => ud.setBool_forKey(false, &key),
|
||||
"always" => ud.setBool_forKey(true, &key),
|
||||
_ => ud.removeObjectForKey(&key),
|
||||
}
|
||||
|
||||
// Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is
|
||||
// explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior
|
||||
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
|
||||
// user-based defaults.
|
||||
let uc = &ivars.update_controller;
|
||||
if super::support::bundle_info_bool("SUEnableAutomaticChecks") == Some(false) {
|
||||
uc.automatically_checks_for_updates.set(false);
|
||||
uc.automatically_downloads_updates.set(false);
|
||||
} else if let Some(auto_update) = config.auto_update() {
|
||||
uc.automatically_checks_for_updates
|
||||
.set(auto_update == AutoUpdate::Check || auto_update == AutoUpdate::Download);
|
||||
uc.automatically_downloads_updates
|
||||
.set(auto_update == AutoUpdate::Download);
|
||||
// To test `auto-update` easily, uncomment the line below and
|
||||
// delete `SUEnableAutomaticChecks` in Ghostty-Info.plist.
|
||||
//
|
||||
// Note: When `auto-update = download`, you may need to
|
||||
// `Clean Build Folder` if a background install has already begun.
|
||||
// updateController.updater.checkForUpdatesInBackground()
|
||||
}
|
||||
|
||||
// Config could change keybindings, so update everything that depends on that
|
||||
self.sync_menu_shortcuts(config);
|
||||
for c in TerminalController::all() {
|
||||
c.relabel_tabs();
|
||||
}
|
||||
|
||||
// Update our badge since config can change what we show.
|
||||
self.sync_dock_badge();
|
||||
|
||||
// Config could change window appearance. We wrap this in an async queue because when
|
||||
// this is called as part of application launch it can deadlock with an internal
|
||||
// AppKit mutex on the appearance.
|
||||
let weak = self.weak_self();
|
||||
let config_for_appearance = config.clone();
|
||||
main_async_local(move || {
|
||||
if let Some(this) = weak.load() {
|
||||
this.sync_appearance(&config_for_appearance);
|
||||
}
|
||||
});
|
||||
|
||||
// Decide whether to hide/unhide app from dock and app switcher
|
||||
match config.macos_hidden() {
|
||||
MacHidden::Never => {
|
||||
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
|
||||
}
|
||||
MacHidden::Always => {
|
||||
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have configuration errors, we need to show them.
|
||||
let c = ConfigurationErrorsController::shared_instance();
|
||||
c.set_errors(config.errors());
|
||||
if !c.errors().is_empty() && !c.is_window_visible() {
|
||||
c.show_window();
|
||||
}
|
||||
|
||||
// We need to handle our global event tap depending on if there are global
|
||||
// events that we care about in Ghostty.
|
||||
let has_global = ivars
|
||||
.ghostty
|
||||
.app_ptr()
|
||||
// SAFETY: live app handle.
|
||||
.is_some_and(|a| unsafe { ghostty_app_has_global_keybinds(a) });
|
||||
if has_global {
|
||||
if self.time_since_launch() > 5.0 {
|
||||
// If the process has been running for awhile we enable right away
|
||||
// because no windows are likely to pop up.
|
||||
GlobalEventTap::shared().enable();
|
||||
} else {
|
||||
// If the process just started, we wait a couple seconds to allow
|
||||
// the initial windows and so on to load so our permissions dialog
|
||||
// doesn't get buried.
|
||||
crate::helpers::dispatch::main_async_after(
|
||||
std::time::Duration::from_secs(2),
|
||||
|| GlobalEventTap::shared().enable(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
GlobalEventTap::shared().disable();
|
||||
}
|
||||
|
||||
self.update_app_icon(config);
|
||||
}
|
||||
|
||||
/// Sync the appearance of our app with the theme specified in the config.
|
||||
fn sync_appearance(&self, config: &Config) {
|
||||
let app = NSApplication::sharedApplication(self.mtm());
|
||||
let appearance =
|
||||
crate::helpers::extensions::ns_appearance::appearance_from_ghostty_config(config);
|
||||
app.setAppearance(appearance.as_deref());
|
||||
}
|
||||
|
||||
fn update_app_icon(&self, config: &Config) {
|
||||
// Since this is called after `DockTilePlugin` has been running,
|
||||
// clean it up here to trigger a correct update of the current config.
|
||||
user_defaults::ghostty().removeObjectForKey(&NSString::from_str("CustomGhosttyIcon"));
|
||||
// TODO(MA4): `DispatchQueue.global().async { UserDefaults.ghostty.appIcon =
|
||||
// AppIcon(config: config); post .ghosttyIconDidChange on the distributed
|
||||
// notification center }` (Custom App Icon feature).
|
||||
let _ = config;
|
||||
crate::features::custom_app_icon::post_icon_did_change();
|
||||
}
|
||||
|
||||
// MARK: - Global State
|
||||
|
||||
/// `setSecureInput(_:)`.
|
||||
pub fn set_secure_input(&self, mode: SetSecureInput) {
|
||||
use objc2_app_kit::{NSControlStateValueOff, NSControlStateValueOn};
|
||||
let input = SecureInput::shared();
|
||||
match mode {
|
||||
SetSecureInput::On => input.set_global(true),
|
||||
SetSecureInput::Off => input.set_global(false),
|
||||
SetSecureInput::Toggle => input.set_global(!input.global()),
|
||||
}
|
||||
if let Some(item) = self.menu_item(Outlet::MenuSecureInput) {
|
||||
item.setState(if input.global() {
|
||||
NSControlStateValueOn
|
||||
} else {
|
||||
NSControlStateValueOff
|
||||
});
|
||||
}
|
||||
user_defaults::ghostty().setBool_forKey(input.global(), &NSString::from_str("SecureInput"));
|
||||
}
|
||||
|
||||
/// `@IBAction checkForUpdates` (called by Ghostty.App's `checkForUpdates` action).
|
||||
pub fn check_for_updates(&self) {
|
||||
self.ivars().update_controller.check_for_updates();
|
||||
}
|
||||
|
||||
/// `@IBAction closeAllWindows` (called by Ghostty.App's action).
|
||||
pub fn close_all_windows(&self) {
|
||||
TerminalController::close_all_windows();
|
||||
AboutController::shared().hide();
|
||||
}
|
||||
|
||||
/// `@IBAction toggleQuickTerminal`.
|
||||
pub fn toggle_quick_terminal(&self) {
|
||||
self.quick_controller().toggle();
|
||||
}
|
||||
|
||||
/// `@IBAction toggleVisibility`.
|
||||
pub fn toggle_visibility(&self) {
|
||||
let app = NSApplication::sharedApplication(self.mtm());
|
||||
// If we have focus, then we hide all windows.
|
||||
if app.isActive() {
|
||||
// Toggle visibility doesn't do anything if the focused window is native
|
||||
// fullscreen. This is only relevant if Ghostty is active.
|
||||
let Some(key_window) = app.keyWindow() else {
|
||||
return;
|
||||
};
|
||||
if key_window
|
||||
.styleMask()
|
||||
.contains(NSWindowStyleMask::FullScreen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
app.hide(None);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're not active, we want to become active
|
||||
#[allow(deprecated)]
|
||||
app.activateIgnoringOtherApps(true);
|
||||
|
||||
// Bring all windows to the front. Note: we don't use NSApp.unhide because
|
||||
// that will unhide ALL hidden windows. We want to only bring forward the
|
||||
// ones that we hid.
|
||||
if let Some(state) = self.ivars().hidden_state.borrow_mut().take() {
|
||||
state.restore();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_as_default_terminal(&self) {
|
||||
let mtm = self.mtm();
|
||||
let handler = RcBlock::new(|error: *mut objc2_foundation::NSError| {
|
||||
if error.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: non-null error pointer provided by the framework.
|
||||
let desc = unsafe { &*error }.localizedDescription().to_string();
|
||||
DispatchQueue::main().exec_async(move || {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let alert = NSAlert::new(mtm);
|
||||
alert.setMessageText(&NSString::from_str("Failed to Set Default Terminal"));
|
||||
alert.setInformativeText(&NSString::from_str(&format!(
|
||||
"Ghostty could not be set as the default terminal application.\n\nError: {desc}"
|
||||
)));
|
||||
alert.setAlertStyle(NSAlertStyle::Warning);
|
||||
alert.runModal();
|
||||
});
|
||||
});
|
||||
let _ = mtm;
|
||||
super::support::set_default_terminal(&handler);
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Menu
|
||||
|
||||
impl AppDelegate {
|
||||
fn reload_dock_menu(&self) {
|
||||
let mtm = self.mtm();
|
||||
let dock_menu = &self.ivars().dock_menu;
|
||||
dock_menu.removeAllItems();
|
||||
for (title, selector) in DOCK_MENU {
|
||||
// SAFETY: valid title/selector/key equivalent.
|
||||
let item = unsafe {
|
||||
NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
NSMenuItem::alloc(mtm),
|
||||
&NSString::from_str(title),
|
||||
Some(main_menu::selector(selector)),
|
||||
&NSString::from_str(""),
|
||||
)
|
||||
};
|
||||
dock_menu.addItem(&item);
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup all the images for our menu items.
|
||||
fn setup_menu_images(&self) {
|
||||
// Note: This COULD Be done all in the xib file, but I find it easier to
|
||||
// modify this stuff as code.
|
||||
for (outlet, symbol) in MENU_IMAGES {
|
||||
if let Some(item) = self.menu_item(*outlet) {
|
||||
item.set_image_if_desired(symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
|
||||
fn sync_menu_shortcuts(&self, config: &Config) {
|
||||
if self.ivars().ghostty.readiness() != Readiness::Ready {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset our shortcut index since we're about to rebuild all menu bindings.
|
||||
self.ivars().menu_items_by_shortcut.borrow_mut().clear();
|
||||
|
||||
for (action, outlet) in SYNCED_SHORTCUTS {
|
||||
self.sync_menu_shortcut(config, action, self.menu_item(*outlet).as_deref());
|
||||
}
|
||||
|
||||
// This menu item is NOT synced with the configuration because it disables macOS
|
||||
// global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue
|
||||
// to work but it won't be reflected in the menu item.
|
||||
//
|
||||
// syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
|
||||
|
||||
// Dock menu
|
||||
self.reload_dock_menu();
|
||||
}
|
||||
|
||||
/// Syncs a single menu shortcut for the given action. The action string is the same
|
||||
/// action string used for the Ghostty configuration.
|
||||
fn sync_menu_shortcut(&self, config: &Config, action: &str, menu_item: Option<&NSMenuItem>) {
|
||||
let Some(menu) = menu_item else { return };
|
||||
|
||||
let Some(shortcut) = config.keyboard_shortcut(action) else {
|
||||
// No shortcut, clear the menu item
|
||||
menu.setKeyEquivalent(&NSString::from_str(""));
|
||||
menu.setKeyEquivalentModifierMask(NSEventModifierFlags::empty());
|
||||
return;
|
||||
};
|
||||
|
||||
let key_equivalent = shortcut.key.character.to_string();
|
||||
let modifier_mask =
|
||||
crate::helpers::extensions::event_modifiers::ns_flags_from_swiftui(shortcut.modifiers);
|
||||
menu.setKeyEquivalent(&NSString::from_str(&key_equivalent));
|
||||
menu.setKeyEquivalentModifierMask(modifier_mask);
|
||||
|
||||
// Build a direct lookup for key-equivalent dispatch so we don't need to
|
||||
// linearly walk the full menu hierarchy at event time.
|
||||
let Some(key) = MenuShortcutKey::new(&key_equivalent, modifier_mask.0) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Later registrations intentionally override earlier ones for the same key.
|
||||
// SAFETY: the item is alive (we hold `menu`); Weak::new needs a retained ref.
|
||||
let retained = unsafe { Retained::retain(menu as *const NSMenuItem as *mut NSMenuItem) };
|
||||
if let Some(retained) = retained {
|
||||
self.ivars()
|
||||
.menu_items_by_shortcut
|
||||
.borrow_mut()
|
||||
.insert(key, Weak::from_retained(&retained));
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to perform a menu key equivalent only for menu items that represent
|
||||
/// Ghostty keybind actions. This is important because it lets our surface dispatch
|
||||
/// bindings through the menu so they flash but also lets our surface override macOS built-ins
|
||||
/// like Cmd+H.
|
||||
pub fn perform_ghostty_binding_menu_key_equivalent(&self, event: &NSEvent) -> bool {
|
||||
// Convert this event into the same normalized lookup key we use when
|
||||
// syncing menu shortcuts from configuration.
|
||||
let Some(chars) = event.charactersIgnoringModifiers() else {
|
||||
return false;
|
||||
};
|
||||
let Some(key) = MenuShortcutKey::new(&chars.to_string(), event.modifierFlags().0) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// If we don't have an entry for this key combo, no Ghostty-owned
|
||||
// menu shortcut exists for this event.
|
||||
let Some(weak_item) = self
|
||||
.ivars()
|
||||
.menu_items_by_shortcut
|
||||
.borrow()
|
||||
.get(&key)
|
||||
.map(|w| w.load())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Weak references can be nil if a menu item was deallocated after sync.
|
||||
let Some(item) = weak_item else {
|
||||
self.ivars()
|
||||
.menu_items_by_shortcut
|
||||
.borrow_mut()
|
||||
.remove(&key);
|
||||
return false;
|
||||
};
|
||||
|
||||
// SAFETY: plain property getter.
|
||||
let Some(parent_menu) = (unsafe { item.menu() }) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Keep enablement state fresh in case menu validation hasn't run yet.
|
||||
parent_menu.update();
|
||||
if !item.isEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let index = parent_menu.indexOfItem(&item);
|
||||
if index < 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
parent_menu.performActionForItemAtIndex(index);
|
||||
true
|
||||
}
|
||||
|
||||
// MARK: Floating Windows
|
||||
|
||||
/// `syncFloatOnTopMenu`.
|
||||
pub fn sync_float_on_top_menu(&self, window: Option<&NSWindow>) {
|
||||
use objc2_app_kit::{NSControlStateValueOff, NSControlStateValueOn};
|
||||
let Some(item) = self.menu_item(Outlet::MenuFloatOnTop) else {
|
||||
return;
|
||||
};
|
||||
let key = NSApplication::sharedApplication(self.mtm()).keyWindow();
|
||||
let window: Option<&NSWindow> = window.or(key.as_deref());
|
||||
let terminal = window.and_then(|w| {
|
||||
let obj: &AnyObject = w;
|
||||
obj.downcast_ref::<TerminalWindow>()
|
||||
});
|
||||
let Some(window) = terminal else {
|
||||
// If some other window became key we always turn this off
|
||||
item.setState(NSControlStateValueOff);
|
||||
return;
|
||||
};
|
||||
item.setState(if window.level() == FLOATING_WINDOW_LEVEL {
|
||||
NSControlStateValueOn
|
||||
} else {
|
||||
NSControlStateValueOff
|
||||
});
|
||||
}
|
||||
|
||||
// MARK: NSMenuItemValidation
|
||||
|
||||
fn validate(&self, item: &NSMenuItem) -> bool {
|
||||
let Some(action) = item.action() else {
|
||||
return true;
|
||||
};
|
||||
let app = NSApplication::sharedApplication(self.mtm());
|
||||
if action == sel!(setAsDefaultTerminal:) {
|
||||
let current = NSWorkspace::sharedWorkspace().default_terminal();
|
||||
let bundle = objc2_foundation::NSBundle::mainBundle().bundleURL();
|
||||
return current.map(|u| u.isEqual(Some(&bundle))) != Some(true);
|
||||
}
|
||||
if action == sel!(floatOnTop:) || action == sel!(useAsDefault:) {
|
||||
// Float on top items only active if the key window is a primary
|
||||
// terminal window (not quick terminal).
|
||||
return app.keyWindow().is_some_and(|w| {
|
||||
let obj: &AnyObject = &w;
|
||||
obj.downcast_ref::<TerminalWindow>().is_some()
|
||||
});
|
||||
}
|
||||
let um = &self.ivars().undo_manager;
|
||||
if action == sel!(undo:) {
|
||||
if um.canUndo() {
|
||||
item.setTitle(&NSString::from_str(&format!(
|
||||
"Undo {}",
|
||||
um.undoActionName()
|
||||
)));
|
||||
} else {
|
||||
item.setTitle(&NSString::from_str("Undo"));
|
||||
}
|
||||
return um.canUndo();
|
||||
}
|
||||
if action == sel!(redo:) {
|
||||
if um.canRedo() {
|
||||
item.setTitle(&NSString::from_str(&format!(
|
||||
"Redo {}",
|
||||
um.redoActionName()
|
||||
)));
|
||||
} else {
|
||||
item.setTitle(&NSString::from_str("Redo"));
|
||||
}
|
||||
return um.canRedo();
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// `setDockBadge()` on the main thread.
|
||||
fn set_dock_badge_on_main() {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let Some(delegate) = AppDelegate::shared(mtm) else {
|
||||
return;
|
||||
};
|
||||
delegate.set_dock_badge();
|
||||
}
|
||||
|
||||
impl AppDelegate {
|
||||
fn set_dock_badge(&self) {
|
||||
let app = NSApplication::sharedApplication(self.mtm());
|
||||
let bell_count = app
|
||||
.windows()
|
||||
.iter()
|
||||
.filter_map(|w| w.windowController())
|
||||
.filter_map(|c| c.downcast::<BaseTerminalController>().ok())
|
||||
.filter(|c| c.bell())
|
||||
.count();
|
||||
let wants_badge = self
|
||||
.ivars()
|
||||
.ghostty
|
||||
.config()
|
||||
.bell_features()
|
||||
.contains(BellFeatures::ATTENTION)
|
||||
&& bell_count > 0;
|
||||
let label = if wants_badge {
|
||||
Some(if bell_count > 99 {
|
||||
"99+".to_string()
|
||||
} else {
|
||||
bell_count.to_string()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let tile = app.dockTile();
|
||||
tile.setBadgeLabel(label.map(|l| NSString::from_str(&l)).as_deref());
|
||||
tile.display();
|
||||
}
|
||||
}
|
||||
|
||||
/// `DispatchQueue.main.async { ... }` for closures that capture main-thread
|
||||
/// only state (weak refs to AppKit objects). Must be called on the main thread.
|
||||
fn main_async_local(f: impl FnOnce() + 'static) {
|
||||
/// Wrapper asserting the closure is only run on the main queue.
|
||||
struct MainOnly<F>(F);
|
||||
// SAFETY: the closure is created on the main thread and only ever executed
|
||||
// on the main queue (i.e. the main thread), so its captured state never
|
||||
// crosses threads.
|
||||
unsafe impl<F> Send for MainOnly<F> {}
|
||||
let wrapped = MainOnly(f);
|
||||
DispatchQueue::main().exec_async(move || {
|
||||
let w = wrapped;
|
||||
(w.0)();
|
||||
});
|
||||
}
|
||||
|
||||
#[link(name = "AppKit", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn NSBeep();
|
||||
}
|
||||
@@ -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<Retained<SurfaceView>> {
|
||||
for window in NSApplication::sharedApplication(self.mtm()).windows() {
|
||||
let Some(controller) = window
|
||||
.windowController()
|
||||
.and_then(|c| c.downcast::<BaseTerminalController>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for surface in controller.surface_tree() {
|
||||
if surface.id().isEqual(Some(id)) {
|
||||
return Some(surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -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<CString> = std::env::args_os()
|
||||
.map(|a| CString::new(a.as_bytes()).unwrap_or_default())
|
||||
.collect();
|
||||
let mut argv: Vec<*mut c_char> = args.iter().map(|a| a.as_ptr().cast_mut()).collect();
|
||||
argv.push(std::ptr::null_mut());
|
||||
// SAFETY: `argv` holds `args.len()` valid NUL terminated strings followed by
|
||||
// a null terminator and outlives the call.
|
||||
let rc = unsafe { ghostty_init(args.len(), argv.as_mut_ptr()) };
|
||||
if rc != GHOSTTY_SUCCESS {
|
||||
log::error!("ghostty_init failed");
|
||||
|
||||
// We also write to stderr if this is executed from the CLI or zig run
|
||||
match launch_source() {
|
||||
LaunchSource::Cli | LaunchSource::ZigRun => {
|
||||
let _ = std::io::stderr().write_all(INIT_FAILED_MESSAGE.as_bytes());
|
||||
std::process::exit(1);
|
||||
}
|
||||
// For the app we exit immediately. We should handle this case more
|
||||
// gracefully in the future.
|
||||
LaunchSource::App => std::process::exit(1),
|
||||
}
|
||||
}
|
||||
|
||||
// This will run the CLI action and exit if one was specified. A CLI
|
||||
// action is a command starting with a `+`, such as `ghostty +boo`.
|
||||
ghostty_cli_try_action();
|
||||
|
||||
// NSApplicationMain(...)
|
||||
let mtm = MainThreadMarker::new().expect("main() runs on the main thread");
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
let delegate = AppDelegate::new(mtm);
|
||||
let outlets = main_menu::install(mtm, &app, &delegate);
|
||||
delegate.connect_outlets(outlets);
|
||||
app.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
|
||||
app.run();
|
||||
// `-[NSApplication run]` only returns after `stop:`; NSApplicationMain
|
||||
// exits the process in that case.
|
||||
drop(delegate);
|
||||
std::process::exit(0);
|
||||
}
|
||||
@@ -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<Outlet, Retained<NSMenuItem>>,
|
||||
/// `menuServices`, the submenu of the Services item.
|
||||
pub services_menu: Option<Retained<NSMenu>>,
|
||||
}
|
||||
|
||||
/// Build the main menu from the spec and register it (and the system menus)
|
||||
/// with `app`. Also instantiates the `NSFontManager` the xib contains.
|
||||
pub fn install(mtm: MainThreadMarker, app: &NSApplication, delegate: &AppDelegate) -> Outlets {
|
||||
// `<customObject customClass="NSFontManager"/>`: make sure the shared
|
||||
// font manager exists as the nib's instantiation would.
|
||||
let _ = NSFontManager::sharedFontManager(mtm);
|
||||
|
||||
let mut outlets = Outlets::default();
|
||||
let main = build_menu(
|
||||
mtm,
|
||||
app,
|
||||
delegate,
|
||||
&super::menu_spec::MAIN_MENU,
|
||||
&mut outlets,
|
||||
);
|
||||
app.setMainMenu(Some(&main));
|
||||
outlets
|
||||
}
|
||||
|
||||
fn build_menu(
|
||||
mtm: MainThreadMarker,
|
||||
app: &NSApplication,
|
||||
delegate: &AppDelegate,
|
||||
spec: &MenuSpec,
|
||||
outlets: &mut Outlets,
|
||||
) -> Retained<NSMenu> {
|
||||
let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str(spec.title));
|
||||
register_system_menu(app, spec.system_menu, &menu);
|
||||
if spec.outlet == Some(Outlet::MenuServices) {
|
||||
outlets.services_menu = Some(menu.clone());
|
||||
}
|
||||
|
||||
for entry in spec.items {
|
||||
match entry {
|
||||
ItemSpec::Separator => menu.addItem(&NSMenuItem::separatorItem(mtm)),
|
||||
ItemSpec::Item(item) => {
|
||||
let action: Option<Sel> = item.action.map(|a| selector(a.selector));
|
||||
// SAFETY: a valid title, selector (or none) and key equivalent.
|
||||
let ns_item = unsafe {
|
||||
NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
NSMenuItem::alloc(mtm),
|
||||
&NSString::from_str(item.title),
|
||||
action,
|
||||
&NSString::from_str(item.key_equivalent),
|
||||
)
|
||||
};
|
||||
if let Some(mods) = item.modifiers {
|
||||
ns_item.setKeyEquivalentModifierMask(NSEventModifierFlags(mods.0));
|
||||
}
|
||||
if let Some(a) = item.action {
|
||||
match a.target {
|
||||
// SAFETY: a nil target / the delegate (which outlives the menu).
|
||||
Target::FirstResponder => unsafe { ns_item.setTarget(None) },
|
||||
Target::AppDelegate => {
|
||||
let target: &AnyObject = delegate;
|
||||
// SAFETY: the delegate outlives the menu.
|
||||
unsafe { ns_item.setTarget(Some(target)) };
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(sub) = item.submenu {
|
||||
let submenu = build_menu(mtm, app, delegate, sub, outlets);
|
||||
ns_item.setSubmenu(Some(&submenu));
|
||||
}
|
||||
if let Some(o) = item.outlet {
|
||||
outlets.items.insert(o, ns_item.clone());
|
||||
}
|
||||
menu.addItem(&ns_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
menu
|
||||
}
|
||||
|
||||
fn register_system_menu(app: &NSApplication, kind: Option<SystemMenu>, menu: &NSMenu) {
|
||||
match kind {
|
||||
Some(SystemMenu::Window) => app.setWindowsMenu(Some(menu)),
|
||||
Some(SystemMenu::Help) => app.setHelpMenu(Some(menu)),
|
||||
Some(SystemMenu::Apple) => {
|
||||
// `systemMenu="apple"`: Interface Builder calls the private
|
||||
// `-[NSApplication setAppleMenu:]`.
|
||||
let sel = sel!(setAppleMenu:);
|
||||
let responds: bool = unsafe { msg_send![app, respondsToSelector: sel] };
|
||||
if responds {
|
||||
// SAFETY: `setAppleMenu:` takes an NSMenu and returns void.
|
||||
let _: () = unsafe { msg_send![app, setAppleMenu: menu] };
|
||||
}
|
||||
}
|
||||
Some(SystemMenu::Main) | None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a selector by name (`#selector(name)`).
|
||||
#[must_use]
|
||||
pub fn selector(name: &str) -> Sel {
|
||||
let c = CString::new(name).expect("selector names contain no NUL");
|
||||
Sel::register(&c)
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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 `<modifierMask key="keyEquivalentModifierMask"/>`.
|
||||
pub modifiers: Option<Mods>,
|
||||
pub action: Option<Action>,
|
||||
pub outlet: Option<Outlet>,
|
||||
pub submenu: Option<&'static MenuSpec>,
|
||||
}
|
||||
|
||||
/// A menu.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MenuSpec {
|
||||
pub title: &'static str,
|
||||
pub system_menu: Option<SystemMenu>,
|
||||
/// An outlet connected to the menu itself (`menuServices`).
|
||||
pub outlet: Option<Outlet>,
|
||||
pub items: &'static [ItemSpec],
|
||||
}
|
||||
|
||||
impl MenuSpec {
|
||||
/// Visit every item of this menu and its submenus, depth first.
|
||||
pub fn walk(&self, f: &mut impl FnMut(&Item)) {
|
||||
for item in self.items {
|
||||
if let ItemSpec::Item(item) = item {
|
||||
f(item);
|
||||
if let Some(sub) = item.submenu {
|
||||
sub.walk(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `@IBOutlet`s of the Swift `AppDelegate` (plus `MenuSelectionForSearch`, the
|
||||
/// outlet name the xib actually connects, see the module docs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Outlet {
|
||||
MenuAbout,
|
||||
MenuServices,
|
||||
MenuCheckForUpdates,
|
||||
MenuOpenConfig,
|
||||
MenuReloadConfig,
|
||||
MenuSecureInput,
|
||||
MenuQuit,
|
||||
MenuNewWindow,
|
||||
MenuNewTab,
|
||||
MenuSplitRight,
|
||||
MenuSplitLeft,
|
||||
MenuSplitDown,
|
||||
MenuSplitUp,
|
||||
MenuClose,
|
||||
MenuCloseTab,
|
||||
MenuCloseWindow,
|
||||
MenuCloseAllWindows,
|
||||
MenuUndo,
|
||||
MenuRedo,
|
||||
MenuCopy,
|
||||
MenuPaste,
|
||||
MenuPasteSelection,
|
||||
MenuSelectAll,
|
||||
MenuFindParent,
|
||||
MenuFind,
|
||||
MenuSelectionForFind,
|
||||
MenuScrollToSelection,
|
||||
MenuFindNext,
|
||||
MenuFindPrevious,
|
||||
MenuHideFindBar,
|
||||
MenuToggleVisibility,
|
||||
MenuToggleFullScreen,
|
||||
MenuBringAllToFront,
|
||||
MenuZoomSplit,
|
||||
MenuPreviousSplit,
|
||||
MenuNextSplit,
|
||||
MenuSelectSplitAbove,
|
||||
MenuSelectSplitBelow,
|
||||
MenuSelectSplitLeft,
|
||||
MenuSelectSplitRight,
|
||||
MenuReturnToDefaultSize,
|
||||
MenuFloatOnTop,
|
||||
MenuUseAsDefault,
|
||||
MenuSetAsDefaultTerminal,
|
||||
MenuIncreaseFontSize,
|
||||
MenuDecreaseFontSize,
|
||||
MenuResetFontSize,
|
||||
MenuChangeTitle,
|
||||
MenuChangeTabTitle,
|
||||
MenuReadonly,
|
||||
MenuQuickTerminal,
|
||||
MenuTerminalInspector,
|
||||
MenuCommandPalette,
|
||||
MenuEqualizeSplits,
|
||||
MenuMoveSplitDividerUp,
|
||||
MenuMoveSplitDividerDown,
|
||||
MenuMoveSplitDividerLeft,
|
||||
MenuMoveSplitDividerRight,
|
||||
MenuSelectionForSearch,
|
||||
}
|
||||
|
||||
/// `syncMenuShortcuts`: (Ghostty keybind action, menu item), in the Swift order.
|
||||
pub const SYNCED_SHORTCUTS: &[(&str, Outlet)] = &[
|
||||
("check_for_updates", Outlet::MenuCheckForUpdates),
|
||||
("open_config", Outlet::MenuOpenConfig),
|
||||
("reload_config", Outlet::MenuReloadConfig),
|
||||
("quit", Outlet::MenuQuit),
|
||||
("new_window", Outlet::MenuNewWindow),
|
||||
("new_tab", Outlet::MenuNewTab),
|
||||
("close_surface", Outlet::MenuClose),
|
||||
("close_tab", Outlet::MenuCloseTab),
|
||||
("close_window", Outlet::MenuCloseWindow),
|
||||
("close_all_windows", Outlet::MenuCloseAllWindows),
|
||||
("new_split:right", Outlet::MenuSplitRight),
|
||||
("new_split:left", Outlet::MenuSplitLeft),
|
||||
("new_split:down", Outlet::MenuSplitDown),
|
||||
("new_split:up", Outlet::MenuSplitUp),
|
||||
("undo", Outlet::MenuUndo),
|
||||
("redo", Outlet::MenuRedo),
|
||||
("copy_to_clipboard", Outlet::MenuCopy),
|
||||
("paste_from_clipboard", Outlet::MenuPaste),
|
||||
("paste_from_selection", Outlet::MenuPasteSelection),
|
||||
("select_all", Outlet::MenuSelectAll),
|
||||
("start_search", Outlet::MenuFind),
|
||||
("search_selection", Outlet::MenuSelectionForFind),
|
||||
("scroll_to_selection", Outlet::MenuScrollToSelection),
|
||||
("search:next", Outlet::MenuFindNext),
|
||||
("search:previous", Outlet::MenuFindPrevious),
|
||||
("toggle_split_zoom", Outlet::MenuZoomSplit),
|
||||
("goto_split:previous", Outlet::MenuPreviousSplit),
|
||||
("goto_split:next", Outlet::MenuNextSplit),
|
||||
("goto_split:up", Outlet::MenuSelectSplitAbove),
|
||||
("goto_split:down", Outlet::MenuSelectSplitBelow),
|
||||
("goto_split:left", Outlet::MenuSelectSplitLeft),
|
||||
("goto_split:right", Outlet::MenuSelectSplitRight),
|
||||
("resize_split:up,10", Outlet::MenuMoveSplitDividerUp),
|
||||
("resize_split:down,10", Outlet::MenuMoveSplitDividerDown),
|
||||
("resize_split:right,10", Outlet::MenuMoveSplitDividerRight),
|
||||
("resize_split:left,10", Outlet::MenuMoveSplitDividerLeft),
|
||||
("equalize_splits", Outlet::MenuEqualizeSplits),
|
||||
("reset_window_size", Outlet::MenuReturnToDefaultSize),
|
||||
("increase_font_size:1", Outlet::MenuIncreaseFontSize),
|
||||
("decrease_font_size:1", Outlet::MenuDecreaseFontSize),
|
||||
("reset_font_size", Outlet::MenuResetFontSize),
|
||||
("prompt_surface_title", Outlet::MenuChangeTitle),
|
||||
("prompt_tab_title", Outlet::MenuChangeTabTitle),
|
||||
("toggle_quick_terminal", Outlet::MenuQuickTerminal),
|
||||
("toggle_visibility", Outlet::MenuToggleVisibility),
|
||||
("toggle_window_float_on_top", Outlet::MenuFloatOnTop),
|
||||
("inspector:toggle", Outlet::MenuTerminalInspector),
|
||||
("toggle_command_palette", Outlet::MenuCommandPalette),
|
||||
("toggle_secure_input", Outlet::MenuSecureInput),
|
||||
];
|
||||
|
||||
/// `setupMenuImages`: (menu item, SF Symbol name), in the Swift order.
|
||||
pub const MENU_IMAGES: &[(Outlet, &str)] = &[
|
||||
(Outlet::MenuAbout, "info.circle"),
|
||||
(Outlet::MenuCheckForUpdates, "square.and.arrow.down"),
|
||||
(Outlet::MenuOpenConfig, "gear"),
|
||||
(
|
||||
Outlet::MenuReloadConfig,
|
||||
"arrow.trianglehead.2.clockwise.rotate.90",
|
||||
),
|
||||
(Outlet::MenuSecureInput, "lock.display"),
|
||||
(Outlet::MenuNewWindow, "macwindow.badge.plus"),
|
||||
(Outlet::MenuNewTab, "macwindow"),
|
||||
(Outlet::MenuSplitRight, "rectangle.righthalf.inset.filled"),
|
||||
(Outlet::MenuSplitLeft, "rectangle.leadinghalf.inset.filled"),
|
||||
(Outlet::MenuSplitUp, "rectangle.tophalf.inset.filled"),
|
||||
(Outlet::MenuSplitDown, "rectangle.bottomhalf.inset.filled"),
|
||||
(Outlet::MenuClose, "xmark"),
|
||||
(Outlet::MenuPasteSelection, "doc.on.clipboard.fill"),
|
||||
(Outlet::MenuIncreaseFontSize, "textformat.size.larger"),
|
||||
(Outlet::MenuResetFontSize, "textformat.size"),
|
||||
(Outlet::MenuDecreaseFontSize, "textformat.size.smaller"),
|
||||
(Outlet::MenuCommandPalette, "filemenu.and.selection"),
|
||||
(Outlet::MenuQuickTerminal, "apple.terminal"),
|
||||
(Outlet::MenuChangeTabTitle, "pencil.line"),
|
||||
(Outlet::MenuTerminalInspector, "scope"),
|
||||
(Outlet::MenuReadonly, "eye.fill"),
|
||||
(Outlet::MenuSetAsDefaultTerminal, "star.fill"),
|
||||
(
|
||||
Outlet::MenuToggleFullScreen,
|
||||
"square.arrowtriangle.4.outward",
|
||||
),
|
||||
(Outlet::MenuToggleVisibility, "eye"),
|
||||
(Outlet::MenuZoomSplit, "arrow.up.left.and.arrow.down.right"),
|
||||
(Outlet::MenuPreviousSplit, "chevron.backward.2"),
|
||||
(Outlet::MenuNextSplit, "chevron.forward.2"),
|
||||
(
|
||||
Outlet::MenuEqualizeSplits,
|
||||
"inset.filled.topleft.topright.bottomleft.bottomright.rectangle",
|
||||
),
|
||||
(Outlet::MenuSelectSplitLeft, "arrow.left"),
|
||||
(Outlet::MenuSelectSplitRight, "arrow.right"),
|
||||
(Outlet::MenuSelectSplitAbove, "arrow.up"),
|
||||
(Outlet::MenuSelectSplitBelow, "arrow.down"),
|
||||
(Outlet::MenuMoveSplitDividerUp, "arrow.up.to.line"),
|
||||
(Outlet::MenuMoveSplitDividerDown, "arrow.down.to.line"),
|
||||
(Outlet::MenuMoveSplitDividerLeft, "arrow.left.to.line"),
|
||||
(Outlet::MenuMoveSplitDividerRight, "arrow.right.to.line"),
|
||||
(Outlet::MenuFloatOnTop, "square.filled.on.square"),
|
||||
(Outlet::MenuFindParent, "text.page.badge.magnifyingglass"),
|
||||
];
|
||||
|
||||
/// `AppDelegate.MenuShortcutKey`: a menu shortcut normalized for quick lookup
|
||||
/// (lower-cased key equivalent + only the shift/control/option/command bits).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct MenuShortcutKey {
|
||||
key_equivalent: String,
|
||||
modifiers_raw_value: usize,
|
||||
}
|
||||
|
||||
impl MenuShortcutKey {
|
||||
/// `init?(keyEquivalent:modifiers:)`; `modifiers_raw` is the
|
||||
/// `NSEvent.ModifierFlags.rawValue`.
|
||||
#[must_use]
|
||||
pub fn new(key_equivalent: &str, modifiers_raw: usize) -> Option<Self> {
|
||||
let normalized = key_equivalent.to_lowercase();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
key_equivalent: normalized,
|
||||
modifiers_raw_value: modifiers_raw & Mods::SHORTCUT.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The `dockMenu` entries of `reloadDockMenu`: (title, selector).
|
||||
pub const DOCK_MENU: &[(&str, &str)] = &[("New Window", "newWindow:"), ("New Tab", "newTab:")];
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn all_items() -> Vec<Item> {
|
||||
let mut v = Vec::new();
|
||||
MAIN_MENU.walk(&mut |i| v.push(*i));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xib_menu_structure() {
|
||||
let top: Vec<_> = MAIN_MENU
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
if let ItemSpec::Item(i) = i {
|
||||
Some(i.title)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(top, ["Ghostty", "File", "Edit", "View", "Window", "Help"]);
|
||||
assert_eq!(MAIN_MENU.system_menu, Some(SystemMenu::Main));
|
||||
let sys: Vec<_> = all_items()
|
||||
.iter()
|
||||
.filter_map(|i| i.submenu.and_then(|m| m.system_menu))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sys,
|
||||
[SystemMenu::Apple, SystemMenu::Window, SystemMenu::Help]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_key_equivalents_from_the_xib() {
|
||||
let find = |title: &str| *all_items().iter().find(|i| i.title == title).unwrap();
|
||||
let prefs = find("Preferences…");
|
||||
assert_eq!(
|
||||
(prefs.key_equivalent, prefs.modifiers.is_none()),
|
||||
(",", true)
|
||||
);
|
||||
let hide_others = find("Hide Others");
|
||||
assert_eq!(hide_others.key_equivalent, "h");
|
||||
assert_eq!(
|
||||
hide_others.modifiers,
|
||||
Some(Mods(Mods::OPTION.0 | Mods::COMMAND.0))
|
||||
);
|
||||
let fs = find("Toggle Full Screen");
|
||||
assert_eq!(fs.key_equivalent, "f");
|
||||
assert_eq!(fs.modifiers, Some(Mods(Mods::CONTROL.0 | Mods::COMMAND.0)));
|
||||
assert_eq!(find("Ghostty Help").key_equivalent, "?");
|
||||
assert_eq!(find("Minimize").key_equivalent, "m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlets_match_the_swift_app_delegate() {
|
||||
// Every Swift outlet except the two the xib never connects has an item.
|
||||
let connected: std::collections::HashSet<_> = CONNECTED_OUTLETS.iter().copied().collect();
|
||||
for o in [Outlet::MenuSelectionForFind, Outlet::MenuScrollToSelection] {
|
||||
assert!(!connected.contains(&o), "{o:?} is not connected by the xib");
|
||||
}
|
||||
assert!(connected.contains(&Outlet::MenuSelectionForSearch));
|
||||
// Each connected outlet is attached to exactly one item/menu.
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
MAIN_MENU.walk(&mut |i| {
|
||||
if let Some(o) = i.outlet {
|
||||
*seen.entry(o).or_insert(0) += 1;
|
||||
}
|
||||
if let Some(o) = i.submenu.and_then(|m| m.outlet) {
|
||||
*seen.entry(o).or_insert(0) += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(seen.len(), CONNECTED_OUTLETS.len());
|
||||
assert!(seen.values().all(|&n| n == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_actions_are_unique_and_ordered_like_swift() {
|
||||
assert_eq!(SYNCED_SHORTCUTS.len(), 49);
|
||||
assert_eq!(
|
||||
SYNCED_SHORTCUTS[0],
|
||||
("check_for_updates", Outlet::MenuCheckForUpdates)
|
||||
);
|
||||
assert_eq!(
|
||||
SYNCED_SHORTCUTS[48],
|
||||
("toggle_secure_input", Outlet::MenuSecureInput)
|
||||
);
|
||||
// toggle_fullscreen is deliberately not synced.
|
||||
assert!(
|
||||
!SYNCED_SHORTCUTS
|
||||
.iter()
|
||||
.any(|(a, _)| *a == "toggle_fullscreen")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_shortcut_key_normalizes() {
|
||||
let a = MenuShortcutKey::new("T", Mods::COMMAND.0 | 0x10000).unwrap();
|
||||
let b = MenuShortcutKey::new("t", Mods::COMMAND.0).unwrap();
|
||||
// caps lock (0x10000) is not a shortcut modifier.
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(
|
||||
b,
|
||||
MenuShortcutKey::new("t", Mods::COMMAND.0 | Mods::SHIFT.0).unwrap()
|
||||
);
|
||||
assert!(MenuShortcutKey::new("", Mods::COMMAND.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Retained<NSString>> = defaults
|
||||
.iter()
|
||||
.map(|(k, _)| NSString::from_str(k))
|
||||
.collect();
|
||||
let key_refs: Vec<&NSString> = keys.iter().map(|k| &**k).collect();
|
||||
let values: Vec<Retained<AnyObject>> = defaults
|
||||
.iter()
|
||||
.map(|(_, v)| {
|
||||
let n = NSNumber::new_bool(*v);
|
||||
// SAFETY: an NSNumber is an Objective-C object.
|
||||
unsafe { Retained::cast_unchecked::<AnyObject>(n) }
|
||||
})
|
||||
.collect();
|
||||
let dict: Retained<NSDictionary<NSString, AnyObject>> =
|
||||
NSDictionary::from_retained_objects(&key_refs, &values);
|
||||
// SAFETY: `registerDefaults:` takes an NSDictionary of property-list values.
|
||||
unsafe { user_defaults::ghostty().registerDefaults(&dict) };
|
||||
}
|
||||
|
||||
/// `UserDefaults.ghostty.removePersistentDomain(forName: suite)` (debug builds).
|
||||
pub fn remove_persistent_domain_for_ghostty_suite() {
|
||||
if let Some(suite) = user_defaults::ghostty_suite() {
|
||||
user_defaults::ghostty().removePersistentDomainForName(&NSString::from_str(&suite));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Bundle.main.infoDictionary?[key] as? Bool`.
|
||||
#[must_use]
|
||||
pub fn bundle_info_bool(key: &str) -> Option<bool> {
|
||||
let info = NSBundle::mainBundle().infoDictionary()?;
|
||||
let value = info.objectForKey(&NSString::from_str(key))?;
|
||||
let number = value.downcast_ref::<NSNumber>()?;
|
||||
Some(number.boolValue())
|
||||
}
|
||||
|
||||
/// `NSWorkspace.shared.setDefaultApplication(at: Bundle.main.bundleURL,
|
||||
/// toOpen: .unixExecutable) { error in ... }`.
|
||||
pub fn set_default_terminal(handler: &RcBlock<dyn Fn(*mut NSError)>) {
|
||||
let Some(ut_type) = AnyClass::get(c"UTType") else {
|
||||
log::warn!("UTType is unavailable");
|
||||
return;
|
||||
};
|
||||
let identifier = NSString::from_str("public.unix-executable");
|
||||
// SAFETY: `+[UTType typeWithIdentifier:]` takes an NSString and returns UTType?.
|
||||
let content_type: Option<Retained<AnyObject>> =
|
||||
unsafe { msg_send![ut_type, typeWithIdentifier: &*identifier] };
|
||||
let Some(content_type) = content_type else {
|
||||
return;
|
||||
};
|
||||
let url: Retained<NSURL> = NSBundle::mainBundle().bundleURL();
|
||||
let workspace = NSWorkspace::sharedWorkspace();
|
||||
// SAFETY: selector and argument types are those of
|
||||
// `-setDefaultApplicationAtURL:toOpenContentType:completionHandler:`.
|
||||
let _: () = unsafe {
|
||||
msg_send![
|
||||
&*workspace,
|
||||
setDefaultApplicationAtURL: &*url,
|
||||
toOpenContentType: &*content_type,
|
||||
completionHandler: &**handler
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -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<AboutController> = std::rc::Rc::default();
|
||||
}
|
||||
|
||||
impl AboutController {
|
||||
#[must_use]
|
||||
pub fn shared() -> std::rc::Rc<Self> {
|
||||
SHARED.with(std::rc::Rc::clone)
|
||||
}
|
||||
|
||||
/// `show()`. TODO(MA4)
|
||||
pub fn show(&self) {
|
||||
log::warn!("TODO(MA4): AboutController.show");
|
||||
}
|
||||
|
||||
/// `hide()`. TODO(MA4)
|
||||
pub fn hide(&self) {}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
|
||||
@@ -1 +0,0 @@
|
||||
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
|
||||
@@ -1 +0,0 @@
|
||||
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
|
||||
@@ -1 +0,0 @@
|
||||
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<GlobalEventTap> = Rc::default();
|
||||
}
|
||||
|
||||
impl GlobalEventTap {
|
||||
#[must_use]
|
||||
pub fn shared() -> Rc<Self> {
|
||||
SHARED.with(Rc::clone)
|
||||
}
|
||||
|
||||
/// `enable()`. TODO(MA4)
|
||||
pub fn enable(&self) {
|
||||
log::warn!("TODO(MA4): GlobalEventTap.enable");
|
||||
}
|
||||
|
||||
/// `disable()`. TODO(MA4)
|
||||
pub fn disable(&self) {}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//! `Sources/Features/<Area>`: one submodule per Swift feature folder.
|
||||
//!
|
||||
//! Each area is owned by one agent (see `docs/rewrite/notes/macos-app-design.md`);
|
||||
//! until it is ported the module holds only the surface that the foundation
|
||||
//! (`app`, `ghostty`) calls into, marked `TODO(<owner>)`.
|
||||
pub mod about;
|
||||
pub mod app_intents;
|
||||
pub mod applescript;
|
||||
pub mod clipboard_confirmation;
|
||||
pub mod command_palette;
|
||||
pub mod custom_app_icon;
|
||||
pub mod global_keybinds;
|
||||
pub mod quick_terminal;
|
||||
pub mod secure_input;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod services;
|
||||
pub mod settings;
|
||||
pub mod splits;
|
||||
pub mod terminal;
|
||||
pub mod update;
|
||||
@@ -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<SurfaceConfiguration> {
|
||||
let mut config = SurfaceConfiguration::default();
|
||||
config
|
||||
.environment_variables
|
||||
.insert("GHOSTTY_QUICK_TERMINAL".to_string(), "1".to_string());
|
||||
Some(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance variables of [`QuickTerminalController`].
|
||||
#[derive(Default)]
|
||||
pub struct Ivars {
|
||||
visible: Cell<bool>,
|
||||
position: Cell<QuickTerminalPosition>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
// SAFETY: NSWindowController has no subclassing requirements we violate and
|
||||
// the class does not implement Drop.
|
||||
#[unsafe(super(NSWindowController))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "QuickTerminalController"]
|
||||
#[ivars = Ivars]
|
||||
pub struct QuickTerminalController;
|
||||
|
||||
impl QuickTerminalController {}
|
||||
);
|
||||
|
||||
impl QuickTerminalController {
|
||||
/// `init(_ ghostty:position:baseConfig:restorationState:)`.
|
||||
pub fn new(
|
||||
_ghostty: &Rc<App>,
|
||||
position: QuickTerminalPosition,
|
||||
_base_config: Option<SurfaceConfiguration>,
|
||||
_restoration_state: Option<&QuickTerminalRestorableState>,
|
||||
) -> Retained<Self> {
|
||||
let mtm = crate::helpers::main_thread();
|
||||
let ivars = Ivars::default();
|
||||
ivars.position.set(position);
|
||||
let this = Self::alloc(mtm).set_ivars(ivars);
|
||||
// SAFETY: `init` is NSWindowController's designated initializer.
|
||||
unsafe { msg_send![super(this), init] }
|
||||
}
|
||||
|
||||
/// `visible`.
|
||||
#[must_use]
|
||||
pub fn visible(&self) -> bool {
|
||||
self.ivars().visible.get()
|
||||
}
|
||||
|
||||
/// `restorable`.
|
||||
#[must_use]
|
||||
pub fn restorable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `toggle()`. TODO(MA4)
|
||||
pub fn toggle(&self) {
|
||||
log::warn!("TODO(MA4): QuickTerminalController.toggle");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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<Self> {
|
||||
match s {
|
||||
"top" => Some(Self::Top),
|
||||
"bottom" => Some(Self::Bottom),
|
||||
"left" => Some(Self::Left),
|
||||
"right" => Some(Self::Right),
|
||||
"center" => Some(Self::Center),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn raw_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Top => "top",
|
||||
Self::Bottom => "bottom",
|
||||
Self::Left => "left",
|
||||
Self::Right => "right",
|
||||
Self::Center => "center",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
match s {
|
||||
"main" => Some(Self::Main),
|
||||
"mouse" => Some(Self::Mouse),
|
||||
"macos-menu-bar" => Some(Self::MenuBar),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
match c.tag {
|
||||
quick_terminal_size_tag::PERCENTAGE => {
|
||||
// SAFETY: `tag` says the `percentage` member is the active one.
|
||||
#[allow(unsafe_code)]
|
||||
Some(Self::Percentage(unsafe { c.value.percentage }))
|
||||
}
|
||||
quick_terminal_size_tag::PIXELS => {
|
||||
// SAFETY: `tag` says the `pixels` member is the active one.
|
||||
#[allow(unsafe_code)]
|
||||
Some(Self::Pixels(unsafe { c.value.pixels }))
|
||||
}
|
||||
// NONE (and anything unknown: Swift asserts and returns nil).
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn to_pixels(self, parent_dimension: f64) -> f64 {
|
||||
match self {
|
||||
Self::Percentage(v) => parent_dimension * f64::from(v) / 100.0,
|
||||
Self::Pixels(v) => f64::from(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `struct QuickTerminalSize`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
||||
pub struct QuickTerminalSize {
|
||||
pub primary: Option<Size>,
|
||||
pub secondary: Option<Size>,
|
||||
}
|
||||
|
||||
impl QuickTerminalSize {
|
||||
#[must_use]
|
||||
pub fn from_c(c: &ghostty_config_quick_terminal_size_s) -> Self {
|
||||
Self {
|
||||
primary: Size::from_c(&c.primary),
|
||||
secondary: Size::from_c(&c.secondary),
|
||||
}
|
||||
}
|
||||
|
||||
/// An almost direct port of the Zig function `QuickTerminalSize.calculate`.
|
||||
#[must_use]
|
||||
pub fn calculate(&self, position: QuickTerminalPosition, screen: Size2) -> Size2 {
|
||||
let dims = screen;
|
||||
match position {
|
||||
QuickTerminalPosition::Left | QuickTerminalPosition::Right => Size2 {
|
||||
width: self.primary.map_or(400.0, |s| s.to_pixels(dims.width)),
|
||||
height: self
|
||||
.secondary
|
||||
.map_or(dims.height, |s| s.to_pixels(dims.height)),
|
||||
},
|
||||
QuickTerminalPosition::Top | QuickTerminalPosition::Bottom => Size2 {
|
||||
width: self
|
||||
.secondary
|
||||
.map_or(dims.width, |s| s.to_pixels(dims.width)),
|
||||
height: self.primary.map_or(400.0, |s| s.to_pixels(dims.height)),
|
||||
},
|
||||
QuickTerminalPosition::Center => {
|
||||
if dims.width >= dims.height {
|
||||
// Landscape
|
||||
Size2 {
|
||||
width: self.primary.map_or(800.0, |s| s.to_pixels(dims.width)),
|
||||
height: self.secondary.map_or(400.0, |s| s.to_pixels(dims.height)),
|
||||
}
|
||||
} else {
|
||||
// Portrait
|
||||
Size2 {
|
||||
width: self.secondary.map_or(400.0, |s| s.to_pixels(dims.width)),
|
||||
height: self.primary.map_or(800.0, |s| s.to_pixels(dims.height)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SCREEN: Size2 = Size2 {
|
||||
width: 1000.0,
|
||||
height: 600.0,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn defaults_per_position() {
|
||||
let s = QuickTerminalSize::default();
|
||||
assert_eq!(
|
||||
s.calculate(QuickTerminalPosition::Top, SCREEN),
|
||||
Size2 {
|
||||
width: 1000.0,
|
||||
height: 400.0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
s.calculate(QuickTerminalPosition::Left, SCREEN),
|
||||
Size2 {
|
||||
width: 400.0,
|
||||
height: 600.0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
s.calculate(QuickTerminalPosition::Center, SCREEN),
|
||||
Size2 {
|
||||
width: 800.0,
|
||||
height: 400.0
|
||||
}
|
||||
);
|
||||
let portrait = Size2 {
|
||||
width: 600.0,
|
||||
height: 1000.0,
|
||||
};
|
||||
assert_eq!(
|
||||
s.calculate(QuickTerminalPosition::Center, portrait),
|
||||
Size2 {
|
||||
width: 400.0,
|
||||
height: 800.0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_sizes() {
|
||||
let s = QuickTerminalSize {
|
||||
primary: Some(Size::Percentage(50.0)),
|
||||
secondary: Some(Size::Pixels(300)),
|
||||
};
|
||||
assert_eq!(
|
||||
s.calculate(QuickTerminalPosition::Bottom, SCREEN),
|
||||
Size2 {
|
||||
width: 300.0,
|
||||
height: 300.0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
s.calculate(QuickTerminalPosition::Right, SCREEN),
|
||||
Size2 {
|
||||
width: 500.0,
|
||||
height: 300.0
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
match s {
|
||||
"move" => Some(Self::Move),
|
||||
"remain" => Some(Self::Remain),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_vendor = "apple")]
|
||||
impl QuickTerminalSpaceBehavior {
|
||||
/// `collectionBehavior`.
|
||||
#[must_use]
|
||||
pub fn collection_behavior(self) -> objc2_app_kit::NSWindowCollectionBehavior {
|
||||
use objc2_app_kit::NSWindowCollectionBehavior as B;
|
||||
let common = B::IgnoresCycle | B::FullScreenAuxiliary;
|
||||
match self {
|
||||
// We want this to move the window to the active space.
|
||||
Self::Move => B::CanJoinAllSpaces | common,
|
||||
// We want this to remain the window in the current space.
|
||||
Self::Remain => B::MoveToActiveSpace | common,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<bool>,
|
||||
enabled: Cell<bool>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SHARED: Rc<SecureInput> = Rc::default();
|
||||
}
|
||||
|
||||
impl SecureInput {
|
||||
/// `shared`.
|
||||
#[must_use]
|
||||
pub fn shared() -> Rc<Self> {
|
||||
SHARED.with(Rc::clone)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn global(&self) -> bool {
|
||||
self.global.get()
|
||||
}
|
||||
|
||||
/// `global` setter (`didSet { apply() }`). TODO(MA4): apply.
|
||||
pub fn set_global(&self, v: bool) {
|
||||
self.global.set(v);
|
||||
}
|
||||
|
||||
/// `enabled`.
|
||||
#[must_use]
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.enabled.get()
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
let this = Self::alloc(crate::helpers::main_thread());
|
||||
// SAFETY: NSObject's `init`.
|
||||
unsafe { msg_send![this, init] }
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<String>>,
|
||||
window_visible: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SHARED: std::rc::Rc<ConfigurationErrorsController> = std::rc::Rc::default();
|
||||
}
|
||||
|
||||
impl ConfigurationErrorsController {
|
||||
/// `sharedInstance`.
|
||||
#[must_use]
|
||||
pub fn shared_instance() -> std::rc::Rc<Self> {
|
||||
SHARED.with(std::rc::Rc::clone)
|
||||
}
|
||||
|
||||
/// `errors` getter.
|
||||
#[must_use]
|
||||
pub fn errors(&self) -> Vec<String> {
|
||||
self.errors.borrow().clone()
|
||||
}
|
||||
|
||||
/// `errors` setter: with no errors the window closes (`didSet`).
|
||||
pub fn set_errors(&self, errors: Vec<String>) {
|
||||
let empty = errors.is_empty();
|
||||
*self.errors.borrow_mut() = errors;
|
||||
if empty {
|
||||
self.window_visible.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// `window == nil || !window!.isVisible`.
|
||||
#[must_use]
|
||||
pub fn is_window_visible(&self) -> bool {
|
||||
self.window_visible.get()
|
||||
}
|
||||
|
||||
/// `showWindow(_:)`. TODO(MA4)
|
||||
pub fn show_window(&self) {
|
||||
log::warn!(
|
||||
"TODO(MA4): ConfigurationErrorsController.showWindow ({} errors)",
|
||||
self.errors.borrow().len()
|
||||
);
|
||||
self.window_visible.set(true);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
//! Feature area placeholder (see docs/rewrite/notes/macos-app-design.md for the owner).
|
||||
@@ -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<bool>,
|
||||
/// `titleOverride`.
|
||||
pub title_override: RefCell<Option<String>>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
/// `BaseTerminalController`: the base class of all terminal window controllers.
|
||||
// SAFETY: NSWindowController has no subclassing requirements we violate;
|
||||
// no Drop impl.
|
||||
#[unsafe(super(NSWindowController, NSResponder, NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "GhosttyBaseTerminalController"]
|
||||
#[ivars = BaseTerminalControllerIvars]
|
||||
pub struct BaseTerminalController;
|
||||
);
|
||||
|
||||
impl BaseTerminalController {
|
||||
/// `surfaceTree.isSplit`.
|
||||
#[must_use]
|
||||
pub fn surface_tree_is_split(&self) -> bool {
|
||||
warn!("TODO(MA3): BaseTerminalController::surface_tree_is_split");
|
||||
false
|
||||
}
|
||||
|
||||
/// `surfaceTree.focusTarget(for:from:) != nil` for the node holding `view`
|
||||
/// (App.gotoSplit's performability check).
|
||||
#[must_use]
|
||||
pub fn focus_target_exists(
|
||||
&self,
|
||||
_direction: SplitFocusDirection,
|
||||
_view: &SurfaceView,
|
||||
) -> bool {
|
||||
warn!("TODO(MA3): BaseTerminalController::focus_target_exists");
|
||||
false
|
||||
}
|
||||
|
||||
/// `surfaceTree` as a flat list of its surface views (iterating the tree).
|
||||
#[must_use]
|
||||
pub fn surface_tree(&self) -> Vec<Retained<SurfaceView>> {
|
||||
warn!("TODO(MA3): BaseTerminalController::surface_tree");
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// `focusedSurface`.
|
||||
#[must_use]
|
||||
pub fn focused_surface(&self) -> Option<Retained<SurfaceView>> {
|
||||
warn!("TODO(MA3): BaseTerminalController::focused_surface");
|
||||
None
|
||||
}
|
||||
|
||||
/// `toggleBackgroundOpacity()`.
|
||||
pub fn toggle_background_opacity(&self) {
|
||||
warn!("TODO(MA3): BaseTerminalController::toggle_background_opacity");
|
||||
}
|
||||
|
||||
/// `promptTabTitle()`.
|
||||
pub fn prompt_tab_title(&self) {
|
||||
warn!("TODO(MA3): BaseTerminalController::prompt_tab_title");
|
||||
}
|
||||
|
||||
/// `titleOverride = ...`.
|
||||
pub fn set_title_override(&self, title: Option<String>) {
|
||||
*self.ivars().title_override.borrow_mut() = title;
|
||||
warn!("TODO(MA3): BaseTerminalController::set_title_override (title not applied)");
|
||||
}
|
||||
|
||||
/// `bell`.
|
||||
#[must_use]
|
||||
pub fn bell(&self) -> bool {
|
||||
self.ivars().bell.get()
|
||||
}
|
||||
|
||||
/// `relabelTabs()`.
|
||||
pub fn relabel_tabs(&self) {
|
||||
warn!("TODO(MA3): BaseTerminalController::relabel_tabs");
|
||||
}
|
||||
}
|
||||
|
||||
define_class!(
|
||||
/// `TerminalController`: the controller of a regular terminal window.
|
||||
// SAFETY: as above.
|
||||
#[unsafe(super(BaseTerminalController, NSWindowController, NSResponder, NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "GhosttyTerminalController"]
|
||||
#[ivars = ()]
|
||||
pub struct TerminalController;
|
||||
);
|
||||
|
||||
impl TerminalController {
|
||||
/// `TerminalController.all`: every terminal controller, in creation order.
|
||||
#[must_use]
|
||||
pub fn all() -> Vec<Retained<TerminalController>> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// `TerminalController.newWindow(_:withBaseConfig:)`.
|
||||
#[must_use]
|
||||
pub fn new_window(
|
||||
_ghostty: &Rc<App>,
|
||||
_base_config: Option<&SurfaceConfiguration>,
|
||||
) -> Option<Retained<TerminalController>> {
|
||||
warn!("TODO(MA3): TerminalController::new_window");
|
||||
None
|
||||
}
|
||||
|
||||
/// `TerminalController.newTab(_:from:withBaseConfig:)`.
|
||||
#[must_use]
|
||||
pub fn new_tab(
|
||||
_ghostty: &Rc<App>,
|
||||
_from: Option<&NSWindow>,
|
||||
_base_config: Option<&SurfaceConfiguration>,
|
||||
) -> Option<Retained<TerminalController>> {
|
||||
warn!("TODO(MA3): TerminalController::new_tab");
|
||||
None
|
||||
}
|
||||
|
||||
/// `TerminalController.preferredParent`.
|
||||
#[must_use]
|
||||
pub fn preferred_parent() -> Option<Retained<TerminalController>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `TerminalController.closeAllWindows()`.
|
||||
pub fn close_all_windows() {
|
||||
warn!("TODO(MA3): TerminalController::close_all_windows");
|
||||
}
|
||||
}
|
||||
|
||||
/// `TerminalWindow.defaultLevelKey`.
|
||||
pub const DEFAULT_LEVEL_KEY: &str = "TerminalDefaultLevel";
|
||||
|
||||
define_class!(
|
||||
/// `TerminalWindow`: the NSWindow subclass of terminal windows.
|
||||
// SAFETY: as above.
|
||||
#[unsafe(super(NSWindow, NSResponder, NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "GhosttyTerminalWindow"]
|
||||
#[ivars = ()]
|
||||
pub struct TerminalWindow;
|
||||
);
|
||||
|
||||
impl TerminalWindow {
|
||||
/// `TerminalWindow.defaultLevelKey`.
|
||||
pub const DEFAULT_LEVEL_KEY: &'static str = DEFAULT_LEVEL_KEY;
|
||||
}
|
||||
@@ -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<bool>,
|
||||
pub automatically_downloads_updates: Cell<bool>,
|
||||
installing: Cell<bool>,
|
||||
}
|
||||
|
||||
impl UpdateController {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `startUpdater()`. TODO(MA5)
|
||||
pub fn start_updater(&self) {
|
||||
log::debug!("TODO(MA5): UpdateController.startUpdater");
|
||||
}
|
||||
|
||||
/// `checkForUpdates()`. TODO(MA5)
|
||||
pub fn check_for_updates(&self) {
|
||||
log::warn!("TODO(MA5): UpdateController.checkForUpdates");
|
||||
}
|
||||
|
||||
/// `isInstalling`.
|
||||
#[must_use]
|
||||
pub fn is_installing(&self) -> bool {
|
||||
self.installing.get()
|
||||
}
|
||||
}
|
||||
@@ -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::<u8>(), c.len) };
|
||||
String::from_utf8(data.to_vec()).unwrap_or_default()
|
||||
};
|
||||
Self {
|
||||
kind: OpenUrlKind::from_c(c.kind),
|
||||
url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ghostty.Action.ProgressReport`
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ProgressReport {
|
||||
pub state: ProgressReportState,
|
||||
pub progress: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProgressReportState {
|
||||
Remove,
|
||||
Set,
|
||||
Error,
|
||||
Indeterminate,
|
||||
Pause,
|
||||
}
|
||||
|
||||
impl ProgressReportState {
|
||||
#[must_use]
|
||||
pub fn from_c(c: c_int) -> Self {
|
||||
match c {
|
||||
GHOSTTY_PROGRESS_STATE_SET => Self::Set,
|
||||
GHOSTTY_PROGRESS_STATE_ERROR => Self::Error,
|
||||
GHOSTTY_PROGRESS_STATE_INDETERMINATE => Self::Indeterminate,
|
||||
GHOSTTY_PROGRESS_STATE_PAUSE => Self::Pause,
|
||||
// GHOSTTY_PROGRESS_STATE_REMOVE and anything unknown.
|
||||
_ => Self::Remove,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressReport {
|
||||
#[must_use]
|
||||
pub fn from_c(c: &ghostty_action_progress_report_s) -> Self {
|
||||
Self {
|
||||
state: ProgressReportState::from_c(c.state),
|
||||
progress: u8::try_from(c.progress).ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ghostty.Action.Scrollbar`
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Scrollbar {
|
||||
pub total: u64,
|
||||
pub offset: u64,
|
||||
pub len: u64,
|
||||
}
|
||||
|
||||
impl Scrollbar {
|
||||
#[must_use]
|
||||
pub fn from_c(c: &ghostty_action_scrollbar_s) -> Self {
|
||||
Self {
|
||||
total: c.total,
|
||||
offset: c.offset,
|
||||
len: c.len,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ghostty.Action.StartSearch`
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StartSearch {
|
||||
pub needle: Option<String>,
|
||||
}
|
||||
|
||||
impl StartSearch {
|
||||
/// `init(c:)`.
|
||||
///
|
||||
/// # Safety
|
||||
/// A non-null `c.needle` must be NUL terminated.
|
||||
#[must_use]
|
||||
pub unsafe fn from_c(c: &ghostty_action_start_search_s) -> Self {
|
||||
let needle = if c.needle.is_null() {
|
||||
None
|
||||
} else {
|
||||
// SAFETY: per the contract.
|
||||
Some(
|
||||
unsafe { CStr::from_ptr(c.needle) }
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
)
|
||||
};
|
||||
Self { needle }
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ghostty.Action.PromptTitle`
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PromptTitle {
|
||||
Surface,
|
||||
Tab,
|
||||
}
|
||||
|
||||
impl PromptTitle {
|
||||
#[must_use]
|
||||
pub fn from_c(c: c_int) -> Self {
|
||||
if c == GHOSTTY_PROMPT_TITLE_TAB {
|
||||
Self::Tab
|
||||
} else {
|
||||
Self::Surface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ghostty.Action.KeyTable`
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum KeyTable {
|
||||
Activate { name: String },
|
||||
Deactivate,
|
||||
DeactivateAll,
|
||||
}
|
||||
|
||||
impl KeyTable {
|
||||
/// `init?(c:)`.
|
||||
///
|
||||
/// # Safety
|
||||
/// For the activate tag, `c.value.activate.name` must point to `len` bytes.
|
||||
#[must_use]
|
||||
pub unsafe fn from_c(c: &ghostty_action_key_table_s) -> Option<Self> {
|
||||
match c.tag {
|
||||
GHOSTTY_KEY_TABLE_ACTIVATE => {
|
||||
// SAFETY: the tag selects the `activate` member; pointer per the contract.
|
||||
let (ptr, len) = unsafe { (c.value.activate.name, c.value.activate.len) };
|
||||
let name = if ptr.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
// SAFETY: per the contract.
|
||||
let data = unsafe { std::slice::from_raw_parts(ptr.cast::<u8>(), len) };
|
||||
String::from_utf8(data.to_vec()).unwrap_or_default()
|
||||
};
|
||||
Some(Self::Activate { name })
|
||||
}
|
||||
GHOSTTY_KEY_TABLE_DEACTIVATE => Some(Self::Deactivate),
|
||||
GHOSTTY_KEY_TABLE_DEACTIVATE_ALL => Some(Self::DeactivateAll),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ghostty::capi::{ghostty_action_key_table_activate_s, ghostty_action_key_table_u};
|
||||
|
||||
#[test]
|
||||
fn color_change_kinds() {
|
||||
let c = |kind| ghostty_action_color_change_s {
|
||||
kind,
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 51,
|
||||
};
|
||||
assert_eq!(
|
||||
ColorChange::from_c(&c(GHOSTTY_ACTION_COLOR_KIND_FOREGROUND)).kind,
|
||||
ColorChangeKind::Foreground
|
||||
);
|
||||
assert_eq!(
|
||||
ColorChange::from_c(&c(GHOSTTY_ACTION_COLOR_KIND_CURSOR)).kind,
|
||||
ColorChangeKind::Cursor
|
||||
);
|
||||
let p = ColorChange::from_c(&c(7));
|
||||
assert_eq!(p.kind, ColorChangeKind::Palette { index: 7 });
|
||||
assert_eq!(p.color, Color::new(1.0, 0.0, 0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_report() {
|
||||
let r = ProgressReport::from_c(&ghostty_action_progress_report_s {
|
||||
state: GHOSTTY_PROGRESS_STATE_SET,
|
||||
progress: 40,
|
||||
});
|
||||
assert_eq!(
|
||||
r,
|
||||
ProgressReport {
|
||||
state: ProgressReportState::Set,
|
||||
progress: Some(40)
|
||||
}
|
||||
);
|
||||
let r = ProgressReport::from_c(&ghostty_action_progress_report_s {
|
||||
state: GHOSTTY_PROGRESS_STATE_INDETERMINATE,
|
||||
progress: -1,
|
||||
});
|
||||
assert_eq!(r.progress, None);
|
||||
assert_eq!(ProgressReportState::from_c(99), ProgressReportState::Remove);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_url_and_search() {
|
||||
let url = "file:///tmp/a";
|
||||
let c = ghostty_action_open_url_s {
|
||||
kind: GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
|
||||
url: url.as_ptr().cast(),
|
||||
len: url.len(),
|
||||
};
|
||||
// SAFETY: `url` outlives the call.
|
||||
let o = unsafe { OpenUrl::from_c(&c) };
|
||||
assert_eq!(
|
||||
o,
|
||||
OpenUrl {
|
||||
kind: OpenUrlKind::Text,
|
||||
url: url.into()
|
||||
}
|
||||
);
|
||||
let s = ghostty_action_start_search_s {
|
||||
needle: c"abc".as_ptr(),
|
||||
};
|
||||
// SAFETY: static C string.
|
||||
assert_eq!(
|
||||
unsafe { StartSearch::from_c(&s) }.needle.as_deref(),
|
||||
Some("abc")
|
||||
);
|
||||
let s = ghostty_action_start_search_s {
|
||||
needle: std::ptr::null(),
|
||||
};
|
||||
// SAFETY: null handled.
|
||||
assert_eq!(unsafe { StartSearch::from_c(&s) }.needle, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_table() {
|
||||
let name = "vim";
|
||||
let c = ghostty_action_key_table_s {
|
||||
tag: GHOSTTY_KEY_TABLE_ACTIVATE,
|
||||
value: ghostty_action_key_table_u {
|
||||
activate: ghostty_action_key_table_activate_s {
|
||||
name: name.as_ptr().cast(),
|
||||
len: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
// SAFETY: `name` outlives the call.
|
||||
assert_eq!(
|
||||
unsafe { KeyTable::from_c(&c) },
|
||||
Some(KeyTable::Activate { name: "vim".into() })
|
||||
);
|
||||
let c = ghostty_action_key_table_s {
|
||||
tag: GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
|
||||
..c
|
||||
};
|
||||
// SAFETY: no pointer is read.
|
||||
assert_eq!(
|
||||
unsafe { KeyTable::from_c(&c) },
|
||||
Some(KeyTable::DeactivateAll)
|
||||
);
|
||||
assert_eq!(
|
||||
PromptTitle::from_c(GHOSTTY_PROMPT_TITLE_TAB),
|
||||
PromptTitle::Tab
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<HashMap<usize, RcWeak<App>>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn registered(addr: usize) -> Option<Rc<App>> {
|
||||
REGISTRY.with(|r| r.borrow().get(&addr).and_then(RcWeak::upgrade))
|
||||
}
|
||||
|
||||
// MARK: App
|
||||
|
||||
/// `Ghostty.App.Readiness`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Readiness {
|
||||
Loading,
|
||||
Error,
|
||||
Ready,
|
||||
}
|
||||
|
||||
/// `Ghostty.App.FontSizeModification`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FontSizeModification {
|
||||
Increase(i32),
|
||||
Decrease(i32),
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// `Ghostty.App`.
|
||||
pub struct App {
|
||||
/// Optional delegate (`weak var delegate`).
|
||||
delegate: RefCell<Option<Weak<AppDelegate>>>,
|
||||
/// The readiness value of the state.
|
||||
readiness: Published<Readiness>,
|
||||
/// The global app configuration. This defines the app level configuration
|
||||
/// plus any behavior for new windows, tabs, etc. Note that when creating a
|
||||
/// new window, it may inherit some configuration (i.e. font size) from the
|
||||
/// previously focused window. This would override this.
|
||||
config: Published<Rc<Config>>,
|
||||
/// Preferred config file than the default ones.
|
||||
config_path: Option<String>,
|
||||
/// The ghostty app instance. We only have one of these for the entire app.
|
||||
handle: Cell<ghostty_app_t>,
|
||||
/// The AppKit observers (removed on drop).
|
||||
observers: RefCell<Vec<NsObserver>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// `init(configPath:)`.
|
||||
#[must_use]
|
||||
pub fn new(config_path: Option<&str>) -> Rc<App> {
|
||||
// Initialize the global configuration.
|
||||
let config = Config::new(config_path, true);
|
||||
let loaded = config.loaded();
|
||||
let this = Rc::new(App {
|
||||
delegate: RefCell::new(None),
|
||||
readiness: Published::new(Readiness::Loading),
|
||||
config: Published::new(Rc::new(config)),
|
||||
config_path: config_path.map(str::to_owned),
|
||||
handle: Cell::new(std::ptr::null_mut()),
|
||||
observers: RefCell::new(Vec::new()),
|
||||
});
|
||||
if !loaded {
|
||||
this.readiness.set(Readiness::Error);
|
||||
return this;
|
||||
}
|
||||
|
||||
// The userdata handed to libghostty is the address of this allocation.
|
||||
let userdata = Rc::as_ptr(&this) as *mut c_void;
|
||||
REGISTRY.with(|r| {
|
||||
r.borrow_mut()
|
||||
.insert(userdata as usize, Rc::downgrade(&this))
|
||||
});
|
||||
|
||||
// Create our "runtime" config. The "runtime" is the configuration that
|
||||
// ghostty uses to interface with the application runtime environment.
|
||||
let runtime_cfg = ghostty_runtime_config_s {
|
||||
userdata,
|
||||
supports_selection_clipboard: true,
|
||||
wakeup_cb: Some(wakeup_cb),
|
||||
action_cb: Some(action_cb),
|
||||
read_clipboard_cb: Some(read_clipboard_cb),
|
||||
confirm_read_clipboard_cb: Some(confirm_read_clipboard_cb),
|
||||
write_clipboard_cb: Some(write_clipboard_cb),
|
||||
close_surface_cb: Some(close_surface_cb),
|
||||
};
|
||||
|
||||
// Create the ghostty app.
|
||||
// SAFETY: valid runtime config and config handle; the callbacks above
|
||||
// are sound for the userdata we pass.
|
||||
let app = unsafe { ghostty_app_new(&raw const runtime_cfg, this.config().as_ptr()) };
|
||||
if app.is_null() {
|
||||
error!("ghostty_app_new failed");
|
||||
this.readiness.set(Readiness::Error);
|
||||
return this;
|
||||
}
|
||||
this.handle.set(app);
|
||||
|
||||
// Set our initial focus state.
|
||||
let mtm = crate::helpers::main_thread();
|
||||
// SAFETY: valid app handle.
|
||||
unsafe { ghostty_app_set_focus(app, NSApplication::sharedApplication(mtm).isActive()) };
|
||||
|
||||
let mut observers = Vec::new();
|
||||
let weak = Rc::downgrade(&this);
|
||||
// Called when the selected keyboard changes. We have to notify Ghostty
|
||||
// so that it can reload the keyboard mapping for input.
|
||||
// SAFETY: statics provided by AppKit.
|
||||
let kb_name = unsafe { NSTextInputContextKeyboardSelectionDidChangeNotification };
|
||||
observers.push(observe(kb_name, None, {
|
||||
let weak = weak.clone();
|
||||
move |_| {
|
||||
if let Some(this) = weak.upgrade()
|
||||
&& let Some(app) = this.app_ptr()
|
||||
{
|
||||
// SAFETY: valid app handle.
|
||||
unsafe { ghostty_app_keyboard_changed(app) };
|
||||
}
|
||||
}
|
||||
}));
|
||||
// Called when the app becomes active.
|
||||
// SAFETY: statics provided by AppKit.
|
||||
let active = unsafe { NSApplicationDidBecomeActiveNotification };
|
||||
observers.push(observe(active, None, {
|
||||
let weak = weak.clone();
|
||||
move |_| {
|
||||
if let Some(this) = weak.upgrade()
|
||||
&& let Some(app) = this.app_ptr()
|
||||
{
|
||||
// SAFETY: valid app handle.
|
||||
unsafe { ghostty_app_set_focus(app, true) };
|
||||
}
|
||||
}
|
||||
}));
|
||||
// Called when the app becomes inactive.
|
||||
// SAFETY: statics provided by AppKit.
|
||||
let resign = unsafe { NSApplicationDidResignActiveNotification };
|
||||
observers.push(observe(resign, None, {
|
||||
move |_| {
|
||||
if let Some(this) = weak.upgrade()
|
||||
&& let Some(app) = this.app_ptr()
|
||||
{
|
||||
// SAFETY: valid app handle.
|
||||
unsafe { ghostty_app_set_focus(app, false) };
|
||||
}
|
||||
}
|
||||
}));
|
||||
*this.observers.borrow_mut() = observers;
|
||||
|
||||
this.readiness.set(Readiness::Ready);
|
||||
this
|
||||
}
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// `delegate = ...`.
|
||||
pub fn set_delegate(&self, delegate: Weak<AppDelegate>) {
|
||||
*self.delegate.borrow_mut() = Some(delegate);
|
||||
}
|
||||
|
||||
fn delegate(&self) -> Option<Retained<AppDelegate>> {
|
||||
self.delegate.borrow().as_ref().and_then(Weak::load)
|
||||
}
|
||||
|
||||
/// `readiness`.
|
||||
#[must_use]
|
||||
pub fn readiness(&self) -> Readiness {
|
||||
self.readiness.get()
|
||||
}
|
||||
|
||||
/// `$readiness`.
|
||||
#[must_use]
|
||||
pub fn readiness_published(&self) -> Published<Readiness> {
|
||||
self.readiness.clone()
|
||||
}
|
||||
|
||||
/// `config`.
|
||||
#[must_use]
|
||||
pub fn config(&self) -> Rc<Config> {
|
||||
self.config.get()
|
||||
}
|
||||
|
||||
/// `$config`.
|
||||
#[must_use]
|
||||
pub fn config_published(&self) -> Published<Rc<Config>> {
|
||||
self.config.clone()
|
||||
}
|
||||
|
||||
/// `app` (the `ghostty_app_t`), if created.
|
||||
#[must_use]
|
||||
pub fn app_ptr(&self) -> Option<ghostty_app_t> {
|
||||
let p = self.handle.get();
|
||||
if p.is_null() { None } else { Some(p) }
|
||||
}
|
||||
|
||||
/// True if we need to confirm before quitting.
|
||||
#[must_use]
|
||||
pub fn needs_confirm_quit(&self) -> bool {
|
||||
match self.app_ptr() {
|
||||
// SAFETY: valid app handle.
|
||||
Some(app) => unsafe { ghostty_app_needs_confirm_quit(app) },
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: App Operations
|
||||
|
||||
/// `appTick()`.
|
||||
pub fn app_tick(&self) {
|
||||
let Some(app) = self.app_ptr() else { return };
|
||||
// SAFETY: valid app handle.
|
||||
unsafe { ghostty_app_tick(app) };
|
||||
}
|
||||
|
||||
/// `static func openConfig()`.
|
||||
pub fn open_config() {
|
||||
// SAFETY: returns an owned string freed by `AllocatedString`.
|
||||
let s = AllocatedString::new(ghostty_config_open_path()).string();
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Deviation: Swift passes `fileURL.count` (characters) as the byte
|
||||
// length, truncating URLs with non-ASCII paths; we use the full URL.
|
||||
let file_url = NSURL::fileURLWithPath(&NSString::from_str(&s));
|
||||
let url = file_url
|
||||
.absoluteString()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let _ = open_url(&OpenUrl {
|
||||
kind: OpenUrlKind::Text,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
/// `reloadConfig(soft:)`: reload the configuration.
|
||||
pub fn reload_config(&self, soft: bool) {
|
||||
let Some(app) = self.app_ptr() else { return };
|
||||
|
||||
// Soft updates just call with our existing config.
|
||||
if soft {
|
||||
// SAFETY: valid handles.
|
||||
unsafe { ghostty_app_update_config(app, self.config().as_ptr()) };
|
||||
return;
|
||||
}
|
||||
|
||||
// Hard or full updates have to reload the full configuration.
|
||||
let new_config = Config::new(self.config_path.as_deref(), true);
|
||||
if !new_config.loaded() {
|
||||
warn!("failed to reload configuration");
|
||||
return;
|
||||
}
|
||||
|
||||
// SAFETY: valid handles.
|
||||
unsafe { ghostty_app_update_config(app, new_config.as_ptr()) };
|
||||
// applied config will be updated in `config_change`.
|
||||
}
|
||||
|
||||
/// `reloadConfig(surface:soft:)`.
|
||||
pub fn reload_config_surface(&self, surface: ghostty_surface_t, soft: bool) {
|
||||
// Soft updates just call with our existing config.
|
||||
if soft {
|
||||
// SAFETY: valid handles.
|
||||
unsafe { ghostty_surface_update_config(surface, self.config().as_ptr()) };
|
||||
return;
|
||||
}
|
||||
|
||||
// Hard or full updates have to reload the full configuration.
|
||||
// NOTE: We never set this on self.config because this is a
|
||||
// surface-only config. We free it after the call.
|
||||
let new_config = Config::new(self.config_path.as_deref(), true);
|
||||
if !new_config.loaded() {
|
||||
warn!("failed to reload configuration");
|
||||
return;
|
||||
}
|
||||
|
||||
// SAFETY: valid handles.
|
||||
unsafe { ghostty_surface_update_config(surface, new_config.as_ptr()) };
|
||||
}
|
||||
|
||||
/// Request that the given surface is closed. This will trigger the full
|
||||
/// normal surface close event cycle which will call our close surface
|
||||
/// callback.
|
||||
pub fn request_close(&self, surface: ghostty_surface_t) {
|
||||
// SAFETY: valid surface handle (caller).
|
||||
unsafe { ghostty_surface_request_close(surface) };
|
||||
}
|
||||
|
||||
fn binding_action(surface: ghostty_surface_t, action: &str) {
|
||||
// SAFETY: valid surface handle; pointer/length describe `action`.
|
||||
if !unsafe { ghostty_surface_binding_action(surface, action.as_ptr().cast(), action.len()) }
|
||||
{
|
||||
warn!("action failed action={action}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `newTab(surface:)`.
|
||||
pub fn new_tab(&self, surface: ghostty_surface_t) {
|
||||
Self::binding_action(surface, "new_tab");
|
||||
}
|
||||
|
||||
/// `newWindow(surface:)`.
|
||||
pub fn new_window(&self, surface: ghostty_surface_t) {
|
||||
Self::binding_action(surface, "new_window");
|
||||
}
|
||||
|
||||
/// `split(surface:direction:)` (`ghostty_action_split_direction_e`).
|
||||
pub fn split(&self, surface: ghostty_surface_t, direction: c_int) {
|
||||
// SAFETY: valid surface handle.
|
||||
unsafe { ghostty_surface_split(surface, direction) };
|
||||
}
|
||||
|
||||
/// `splitMoveFocus(surface:direction:)`.
|
||||
pub fn split_move_focus(&self, surface: ghostty_surface_t, direction: SplitFocusDirection) {
|
||||
// SAFETY: valid surface handle.
|
||||
unsafe { ghostty_surface_split_focus(surface, direction.to_native()) };
|
||||
}
|
||||
|
||||
/// `splitResize(surface:direction:amount:)`.
|
||||
pub fn split_resize(
|
||||
&self,
|
||||
surface: ghostty_surface_t,
|
||||
direction: SplitResizeDirection,
|
||||
amount: u16,
|
||||
) {
|
||||
// SAFETY: valid surface handle.
|
||||
unsafe { ghostty_surface_split_resize(surface, direction.to_native(), amount) };
|
||||
}
|
||||
|
||||
/// `splitEqualize(surface:)`.
|
||||
pub fn split_equalize(&self, surface: ghostty_surface_t) {
|
||||
// SAFETY: valid surface handle.
|
||||
unsafe { ghostty_surface_split_equalize(surface) };
|
||||
}
|
||||
|
||||
/// `splitToggleZoom(surface:)`.
|
||||
pub fn split_toggle_zoom(&self, surface: ghostty_surface_t) {
|
||||
Self::binding_action(surface, "toggle_split_zoom");
|
||||
}
|
||||
|
||||
/// `toggleFullscreen(surface:)`.
|
||||
pub fn toggle_fullscreen(&self, surface: ghostty_surface_t) {
|
||||
Self::binding_action(surface, "toggle_fullscreen");
|
||||
}
|
||||
|
||||
/// `changeFontSize(surface:_:)`.
|
||||
pub fn change_font_size(&self, surface: ghostty_surface_t, change: FontSizeModification) {
|
||||
let action = match change {
|
||||
FontSizeModification::Increase(amount) => format!("increase_font_size:{amount}"),
|
||||
FontSizeModification::Decrease(amount) => format!("decrease_font_size:{amount}"),
|
||||
FontSizeModification::Reset => "reset_font_size".to_owned(),
|
||||
};
|
||||
Self::binding_action(surface, &action);
|
||||
}
|
||||
|
||||
/// `toggleTerminalInspector(surface:)`.
|
||||
pub fn toggle_terminal_inspector(&self, surface: ghostty_surface_t) {
|
||||
Self::binding_action(surface, "inspector:toggle");
|
||||
}
|
||||
|
||||
/// `resetTerminal(surface:)`.
|
||||
pub fn reset_terminal(&self, surface: ghostty_surface_t) {
|
||||
Self::binding_action(surface, "reset");
|
||||
}
|
||||
|
||||
// MARK: User Notifications
|
||||
|
||||
/// Determine if a given notification should be presented to the user when
|
||||
/// Ghostty is running in the foreground.
|
||||
#[must_use]
|
||||
pub fn should_present_notification(&self, notification: &UNNotification) -> bool {
|
||||
// SAFETY: plain property getters.
|
||||
let user_info = notification.request().content().userInfo();
|
||||
|
||||
// We always require the notification to be attached to a surface.
|
||||
let Some(surface) =
|
||||
user_info_surface(&user_info).and_then(|uuid| self.delegate()?.find_surface(&uuid))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(window) = surface.window() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// If we don't require focus then we're good!
|
||||
let require_focus = user_info
|
||||
.objectForKey(&NSString::from_str("requireFocus"))
|
||||
.and_then(|v| v.downcast::<objc2_foundation::NSNumber>().ok())
|
||||
.is_none_or(|n| n.boolValue());
|
||||
if !require_focus {
|
||||
return true;
|
||||
}
|
||||
|
||||
!window.isKeyWindow() || !surface.focused()
|
||||
}
|
||||
|
||||
/// Handle a received user notification. This is called when a user
|
||||
/// notification is clicked or dismissed by the user.
|
||||
pub fn handle_user_notification(&self, response: &UNNotificationResponse) {
|
||||
let notification = response.notification();
|
||||
let user_info = notification.request().content().userInfo();
|
||||
let action_identifier = response.actionIdentifier().to_string();
|
||||
let Some(surface) =
|
||||
user_info_surface(&user_info).and_then(|uuid| self.delegate()?.find_surface(&uuid))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// SAFETY: statics provided by UserNotifications.
|
||||
let (default_id, dismiss_id) = unsafe {
|
||||
(
|
||||
objc2_user_notifications::UNNotificationDefaultActionIdentifier.to_string(),
|
||||
objc2_user_notifications::UNNotificationDismissActionIdentifier.to_string(),
|
||||
)
|
||||
};
|
||||
if action_identifier == default_id || action_identifier == USER_NOTIFICATION_ACTION_SHOW {
|
||||
// The user clicked on a notification
|
||||
surface.handle_user_notification(¬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<Retained<NSUUID>> {
|
||||
let s = user_info
|
||||
.objectForKey(&NSString::from_str("surface"))?
|
||||
.downcast::<NSString>()
|
||||
.ok()?;
|
||||
NSUUID::initWithUUIDString(NSUUID::alloc(), &s)
|
||||
}
|
||||
|
||||
// MARK: Pointer helpers
|
||||
|
||||
/// `Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()`
|
||||
/// (retained for the duration of the use).
|
||||
fn surface_userdata(userdata: *mut c_void) -> Option<Retained<SurfaceView>> {
|
||||
// SAFETY: the userdata of every surface (and of the surface callbacks) is
|
||||
// the address of a live `SurfaceView`, set when the surface was created.
|
||||
unsafe { Retained::retain(userdata.cast::<SurfaceView>()) }
|
||||
}
|
||||
|
||||
/// `surfaceView(from: surface)`.
|
||||
fn surface_view(surface: ghostty_surface_t) -> Option<Retained<SurfaceView>> {
|
||||
// SAFETY: valid surface handle (from libghostty's target).
|
||||
let ud = unsafe { ghostty_surface_userdata(surface) };
|
||||
if ud.is_null() {
|
||||
None
|
||||
} else {
|
||||
surface_userdata(ud)
|
||||
}
|
||||
}
|
||||
|
||||
/// `appState(fromView:)`.
|
||||
fn app_state(view: &SurfaceView) -> Option<Rc<App>> {
|
||||
let surface = view.surface()?;
|
||||
// SAFETY: valid surface handle.
|
||||
let app = unsafe { ghostty_surface_app(surface) };
|
||||
if app.is_null() {
|
||||
return None;
|
||||
}
|
||||
app_from_handle(app)
|
||||
}
|
||||
|
||||
/// `Unmanaged<App>.fromOpaque(ghostty_app_userdata(app)).takeUnretainedValue()`.
|
||||
fn app_from_handle(app: ghostty_app_t) -> Option<Rc<App>> {
|
||||
// SAFETY: valid app handle.
|
||||
let ud = unsafe { ghostty_app_userdata(app) };
|
||||
if ud.is_null() {
|
||||
None
|
||||
} else {
|
||||
registered(ud as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// The sender of notifications posted for `view` (`object: surfaceView`).
|
||||
fn sender(view: &Retained<SurfaceView>) -> Sender {
|
||||
Sender::new(Retained::as_ptr(view) as usize, view.clone())
|
||||
}
|
||||
|
||||
fn post(name: Name, view: Option<&Retained<SurfaceView>>, user_info: UserInfo) {
|
||||
NotificationCenter::default_center().post(name, view.map(sender), user_info);
|
||||
}
|
||||
|
||||
fn app_delegate() -> Option<Retained<AppDelegate>> {
|
||||
AppDelegate::shared(crate::helpers::main_thread())
|
||||
}
|
||||
|
||||
fn base_controller(view: &SurfaceView) -> Option<Retained<BaseTerminalController>> {
|
||||
view.window()?
|
||||
.windowController()?
|
||||
.downcast::<BaseTerminalController>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn tab_count(view: &SurfaceView) -> usize {
|
||||
view.window()
|
||||
.and_then(|w| w.tabGroup())
|
||||
.map_or(0, |g| g.windows().count())
|
||||
}
|
||||
|
||||
/// Resolve an action target to a surface view for handlers that "do nothing
|
||||
/// with an app target". Logs like the Swift `Ghostty.logger.warning`.
|
||||
fn surface_target(what: &str, target: &ghostty_target_s) -> Option<Retained<SurfaceView>> {
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP => {
|
||||
warn!("{what} does nothing with an app target");
|
||||
None
|
||||
}
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
// SAFETY: the tag selects the `surface` member.
|
||||
let surface = unsafe { target.target.surface };
|
||||
if surface.is_null() {
|
||||
None
|
||||
} else {
|
||||
surface_view(surface)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
error!("unknown action target={}", target.tag);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn c_str(p: *const c_char) -> Option<String> {
|
||||
if p.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: libghostty passes NUL terminated strings.
|
||||
unsafe { CStr::from_ptr(p) }
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
// MARK: Callbacks
|
||||
|
||||
unsafe extern "C" fn wakeup_cb(userdata: *mut c_void) {
|
||||
// Wakeup can be called from any thread so we schedule the app tick from
|
||||
// the main thread. There is probably some improvements we can make to
|
||||
// coalesce multiple ticks but I don't think it matters from a performance
|
||||
// standpoint since we don't do this much.
|
||||
let addr = userdata as usize;
|
||||
DispatchQueue::main().exec_async(move || {
|
||||
if let Some(app) = registered(addr) {
|
||||
app.app_tick();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unsafe extern "C" fn close_surface_cb(userdata: *mut c_void, process_alive: bool) {
|
||||
let Some(surface) = surface_userdata(userdata) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
GHOSTTY_CLOSE_SURFACE,
|
||||
Some(&surface),
|
||||
UserInfo::new().with("process_alive", process_alive),
|
||||
);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn read_clipboard_cb(
|
||||
userdata: *mut c_void,
|
||||
location: c_int,
|
||||
state: *mut c_void,
|
||||
) -> bool {
|
||||
let Some(view) = surface_userdata(userdata) else {
|
||||
return false;
|
||||
};
|
||||
let Some(surface) = view.surface() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get our pasteboard
|
||||
let Some(pasteboard) = ns_pasteboard::ghostty(location) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Return false if there is no text-like clipboard content so performable
|
||||
// paste bindings can pass through to the terminal.
|
||||
let Some(str) = pasteboard.get_opinionated_string_contents() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
complete_clipboard_request(surface, &str, state, false);
|
||||
true
|
||||
}
|
||||
|
||||
unsafe extern "C" fn confirm_read_clipboard_cb(
|
||||
userdata: *mut c_void,
|
||||
string: *const c_char,
|
||||
state: *mut c_void,
|
||||
request: c_int,
|
||||
) {
|
||||
let Some(surface) = surface_userdata(userdata) else {
|
||||
return;
|
||||
};
|
||||
let Some(value) = c_str(string) else { return };
|
||||
let Some(request) = ClipboardRequest::from_request(request) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
CONFIRM_CLIPBOARD,
|
||||
Some(&surface),
|
||||
UserInfo::new()
|
||||
.with(CONFIRM_CLIPBOARD_STR_KEY, value)
|
||||
// The opaque request state pointer, as an address.
|
||||
.with(CONFIRM_CLIPBOARD_STATE_KEY, state as usize)
|
||||
.with(CONFIRM_CLIPBOARD_REQUEST_KEY, request),
|
||||
);
|
||||
}
|
||||
|
||||
/// `static func completeClipboardRequest(_:data:state:confirmed:)`.
|
||||
pub fn complete_clipboard_request(
|
||||
surface: ghostty_surface_t,
|
||||
data: &str,
|
||||
state: *mut c_void,
|
||||
confirmed: bool,
|
||||
) {
|
||||
let end = data.find('\0').unwrap_or(data.len());
|
||||
let c = CString::new(&data.as_bytes()[..end]).unwrap_or_default();
|
||||
// SAFETY: valid surface; the string lives for the call; `state` is the
|
||||
// request state libghostty gave us.
|
||||
unsafe { ghostty_surface_complete_clipboard_request(surface, c.as_ptr(), state, confirmed) };
|
||||
}
|
||||
|
||||
unsafe extern "C" fn write_clipboard_cb(
|
||||
userdata: *mut c_void,
|
||||
location: c_int,
|
||||
content: *const ghostty_clipboard_content_s,
|
||||
len: usize,
|
||||
confirm: bool,
|
||||
) {
|
||||
let Some(surface) = surface_userdata(userdata) else {
|
||||
return;
|
||||
};
|
||||
let Some(pasteboard) = ns_pasteboard::ghostty(location) else {
|
||||
return;
|
||||
};
|
||||
if content.is_null() || len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the C array to a Rust vector.
|
||||
// SAFETY: libghostty passes `len` valid entries.
|
||||
let slice = unsafe { std::slice::from_raw_parts(content, len) };
|
||||
// SAFETY: entries hold NUL terminated strings or null.
|
||||
let contents: Vec<ClipboardContent> = slice
|
||||
.iter()
|
||||
.filter_map(|c| unsafe { ClipboardContent::from_c(c) })
|
||||
.collect();
|
||||
if contents.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Assert there is only one text/plain entry. For security reasons we need
|
||||
// to guarantee this for now since our confirmation dialog only shows one.
|
||||
debug_assert!(
|
||||
contents.iter().filter(|c| c.mime == "text/plain").count() <= 1,
|
||||
"clipboard contents should have at most one text/plain entry"
|
||||
);
|
||||
|
||||
if !confirm {
|
||||
// Declare all types
|
||||
let types: Vec<Retained<NSString>> = contents
|
||||
.iter()
|
||||
.map(|c| pasteboard_type_from_mime(&c.mime))
|
||||
.collect();
|
||||
let types_array = NSArray::from_retained_slice(&types);
|
||||
// SAFETY: plain pasteboard API calls with valid arguments.
|
||||
unsafe { pasteboard.declareTypes_owner(&types_array, None) };
|
||||
|
||||
// Set data for each type
|
||||
for (item, ty) in contents.iter().zip(&types) {
|
||||
let _ = pasteboard.setString_forType(&NSString::from_str(&item.data), ty);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For confirmation, use the text/plain content if it exists
|
||||
let Some(text_plain) = contents.iter().find(|c| c.mime == "text/plain") else {
|
||||
return;
|
||||
};
|
||||
|
||||
post(
|
||||
CONFIRM_CLIPBOARD,
|
||||
Some(&surface),
|
||||
UserInfo::new()
|
||||
.with(CONFIRM_CLIPBOARD_STR_KEY, text_plain.data.clone())
|
||||
.with(
|
||||
CONFIRM_CLIPBOARD_REQUEST_KEY,
|
||||
ClipboardRequest::Osc52Write(Some(Retained::as_ptr(&pasteboard) as usize)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
unsafe extern "C" fn action_cb(
|
||||
app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
action: ghostty_action_s,
|
||||
) -> bool {
|
||||
action_dispatch(app, &target, &action)
|
||||
}
|
||||
|
||||
fn action_dispatch(
|
||||
app: ghostty_app_t,
|
||||
target: &ghostty_target_s,
|
||||
action: &ghostty_action_s,
|
||||
) -> bool {
|
||||
// Make sure it a target we understand so all our action handlers can assert
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP | GHOSTTY_TARGET_SURFACE => {}
|
||||
other => {
|
||||
warn!("unknown action target={other}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY (all union reads below): `action.tag` selects the active member.
|
||||
let v = &action.action;
|
||||
// Action dispatch
|
||||
unsafe {
|
||||
match action.tag {
|
||||
GHOSTTY_ACTION_QUIT => quit(),
|
||||
GHOSTTY_ACTION_NEW_WINDOW => new_window(target),
|
||||
GHOSTTY_ACTION_NEW_TAB => new_tab(target),
|
||||
GHOSTTY_ACTION_NEW_SPLIT => new_split(target, v.new_split),
|
||||
GHOSTTY_ACTION_CLOSE_TAB => close_tab(target, v.close_tab_mode),
|
||||
GHOSTTY_ACTION_CLOSE_WINDOW => close_window(target),
|
||||
GHOSTTY_ACTION_TOGGLE_FULLSCREEN => toggle_fullscreen(target, v.toggle_fullscreen),
|
||||
GHOSTTY_ACTION_MOVE_TAB => return move_tab(target, &v.move_tab),
|
||||
GHOSTTY_ACTION_GOTO_TAB => return goto_tab(target, v.goto_tab),
|
||||
GHOSTTY_ACTION_GOTO_SPLIT => return goto_split(target, v.goto_split),
|
||||
GHOSTTY_ACTION_GOTO_WINDOW => return goto_window(v.goto_window),
|
||||
GHOSTTY_ACTION_RESIZE_SPLIT => return resize_split(target, &v.resize_split),
|
||||
GHOSTTY_ACTION_EQUALIZE_SPLITS => equalize_splits(target),
|
||||
GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM => return toggle_split_zoom(target),
|
||||
GHOSTTY_ACTION_INSPECTOR => control_inspector(target, v.inspector),
|
||||
GHOSTTY_ACTION_RENDER_INSPECTOR => render_inspector(target),
|
||||
GHOSTTY_ACTION_DESKTOP_NOTIFICATION => {
|
||||
show_desktop_notification_action(target, &v.desktop_notification);
|
||||
}
|
||||
GHOSTTY_ACTION_SET_TITLE => set_title(target, &v.set_title),
|
||||
GHOSTTY_ACTION_SET_TAB_TITLE => return set_tab_title(target, &v.set_tab_title),
|
||||
GHOSTTY_ACTION_PROMPT_TITLE => return prompt_title(target, v.prompt_title),
|
||||
GHOSTTY_ACTION_PWD => pwd_changed(target, &v.pwd),
|
||||
GHOSTTY_ACTION_OPEN_CONFIG => App::open_config(),
|
||||
GHOSTTY_ACTION_FLOAT_WINDOW => toggle_float_window(target, v.float_window),
|
||||
GHOSTTY_ACTION_SECURE_INPUT => toggle_secure_input(target, v.secure_input),
|
||||
GHOSTTY_ACTION_MOUSE_SHAPE => set_mouse_shape(target, v.mouse_shape),
|
||||
GHOSTTY_ACTION_MOUSE_VISIBILITY => set_mouse_visibility(target, v.mouse_visibility),
|
||||
GHOSTTY_ACTION_MOUSE_OVER_LINK => set_mouse_over_link(target, &v.mouse_over_link),
|
||||
GHOSTTY_ACTION_INITIAL_SIZE => set_initial_size(target, &v.initial_size),
|
||||
GHOSTTY_ACTION_RESET_WINDOW_SIZE => reset_window_size(target),
|
||||
GHOSTTY_ACTION_CELL_SIZE => set_cell_size(target, &v.cell_size),
|
||||
GHOSTTY_ACTION_RENDERER_HEALTH => renderer_health(target, v.renderer_health),
|
||||
GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE => toggle_command_palette(target),
|
||||
GHOSTTY_ACTION_TOGGLE_MAXIMIZE => toggle_maximize(target),
|
||||
GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL => toggle_quick_terminal(),
|
||||
GHOSTTY_ACTION_TOGGLE_VISIBILITY => toggle_visibility(),
|
||||
GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY => toggle_background_opacity(target),
|
||||
GHOSTTY_ACTION_KEY_SEQUENCE => key_sequence(target, &v.key_sequence),
|
||||
GHOSTTY_ACTION_KEY_TABLE => key_table(target, &v.key_table),
|
||||
GHOSTTY_ACTION_PROGRESS_REPORT => progress_report(target, &v.progress_report),
|
||||
GHOSTTY_ACTION_CONFIG_CHANGE => config_change(app, target, &v.config_change),
|
||||
GHOSTTY_ACTION_RELOAD_CONFIG => config_reload(app, target, &v.reload_config),
|
||||
GHOSTTY_ACTION_COLOR_CHANGE => color_change(target, &v.color_change),
|
||||
GHOSTTY_ACTION_RING_BELL => ring_bell(target),
|
||||
GHOSTTY_ACTION_READONLY => set_readonly(target, v.readonly),
|
||||
GHOSTTY_ACTION_CHECK_FOR_UPDATES => check_for_updates(),
|
||||
GHOSTTY_ACTION_OPEN_URL => return open_url(&OpenUrl::from_c(&v.open_url)),
|
||||
GHOSTTY_ACTION_UNDO => return undo(target),
|
||||
GHOSTTY_ACTION_REDO => return redo(target),
|
||||
GHOSTTY_ACTION_SCROLLBAR => scrollbar(target, &v.scrollbar),
|
||||
GHOSTTY_ACTION_CLOSE_ALL_WINDOWS => close_all_windows(),
|
||||
GHOSTTY_ACTION_START_SEARCH => start_search(target, &v.start_search),
|
||||
GHOSTTY_ACTION_END_SEARCH => end_search(target),
|
||||
GHOSTTY_ACTION_SEARCH_TOTAL => search_total(target, v.search_total.total),
|
||||
GHOSTTY_ACTION_SEARCH_SELECTED => search_selected(target, v.search_selected.selected),
|
||||
GHOSTTY_ACTION_COMMAND_FINISHED => command_finished(target, &v.command_finished),
|
||||
GHOSTTY_ACTION_PRESENT_TERMINAL => return present_terminal(target),
|
||||
|
||||
GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW
|
||||
| GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS
|
||||
| GHOSTTY_ACTION_SIZE_LIMIT
|
||||
| GHOSTTY_ACTION_QUIT_TIMER
|
||||
| GHOSTTY_ACTION_SHOW_CHILD_EXITED => {
|
||||
info!("known but unimplemented action action={}", action.tag);
|
||||
return false;
|
||||
}
|
||||
GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD => return copy_title_to_clipboard(target),
|
||||
other => {
|
||||
warn!("unknown action action={other}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reached here then we assume performed since all unknown actions
|
||||
// are captured in the switch and return false.
|
||||
true
|
||||
}
|
||||
|
||||
fn quit() {
|
||||
// We want to quit, start that process.
|
||||
NSApplication::sharedApplication(crate::helpers::main_thread()).terminate(None);
|
||||
}
|
||||
|
||||
fn check_for_updates() {
|
||||
if let Some(delegate) = app_delegate() {
|
||||
delegate.check_for_updates();
|
||||
}
|
||||
}
|
||||
|
||||
/// `openURL(_:)`.
|
||||
pub fn open_url(action: &OpenUrl) -> bool {
|
||||
// If the URL doesn't have a valid scheme we assume its a file path. The URL
|
||||
// initializer will gladly take invalid URLs (e.g. plain file paths) and turn
|
||||
// them into schema-less URLs, but these won't open properly in text editors.
|
||||
// See: https://github.com/ghostty-org/ghostty/issues/8763
|
||||
let url = match NSURL::URLWithString(&NSString::from_str(&action.url)) {
|
||||
Some(candidate) if candidate.scheme().is_some() => candidate,
|
||||
_ => {
|
||||
// Expand ~ to the user's home directory so that file paths like
|
||||
// ~/Documents/file.txt resolve correctly.
|
||||
let expanded = NSString::from_str(&action.url).stringByStandardizingPath();
|
||||
NSURL::fileURLWithPath(&expanded)
|
||||
}
|
||||
};
|
||||
|
||||
match action.kind {
|
||||
OpenUrlKind::Text => {
|
||||
// Open with the default editor for `*.ghostty` file or just system text editor
|
||||
let ext = url
|
||||
.pathExtension()
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_default();
|
||||
let workspace = NSWorkspace::sharedWorkspace();
|
||||
let editor = workspace
|
||||
.default_application_url_for_extension(&ext)
|
||||
.or_else(|| workspace.default_text_editor());
|
||||
if let Some(text_editor) = editor {
|
||||
let urls = NSArray::from_slice(&[&*url]);
|
||||
workspace.openURLs_withApplicationAtURL_configuration_completionHandler(
|
||||
&urls,
|
||||
&text_editor,
|
||||
&NSWorkspaceOpenConfiguration::configuration(),
|
||||
None,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// The extension will be HTML and we do the right thing automatically.
|
||||
OpenUrlKind::Html | OpenUrlKind::Unknown => {}
|
||||
}
|
||||
|
||||
// Open with the default application for the URL
|
||||
NSWorkspace::sharedWorkspace().openURL(&url);
|
||||
true
|
||||
}
|
||||
|
||||
fn undo_manager_for(
|
||||
target: &ghostty_target_s,
|
||||
) -> Option<Retained<objc2_foundation::NSUndoManager>> {
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP => app_delegate().map(|d| Retained::into_super(d.undo_manager())),
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
// SAFETY: the tag selects the `surface` member.
|
||||
let surface = unsafe { target.target.surface };
|
||||
if surface.is_null() {
|
||||
return None;
|
||||
}
|
||||
surface_view(surface)?.undoManager()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn undo(target: &ghostty_target_s) -> bool {
|
||||
let Some(um) = undo_manager_for(target) else {
|
||||
return false;
|
||||
};
|
||||
if !um.canUndo() {
|
||||
return false;
|
||||
}
|
||||
um.undo();
|
||||
true
|
||||
}
|
||||
|
||||
fn redo(target: &ghostty_target_s) -> bool {
|
||||
let Some(um) = undo_manager_for(target) else {
|
||||
return false;
|
||||
};
|
||||
if !um.canRedo() {
|
||||
return false;
|
||||
}
|
||||
um.redo();
|
||||
true
|
||||
}
|
||||
|
||||
/// Shared by new_window/new_tab/new_split.
|
||||
fn inherited_config(surface: ghostty_surface_t, context: c_int) -> SurfaceConfiguration {
|
||||
// SAFETY: valid surface; the returned strings live as long as the surface.
|
||||
unsafe { SurfaceConfiguration::from_c(&ghostty_surface_inherited_config(surface, context)) }
|
||||
}
|
||||
|
||||
fn surface_of(target: &ghostty_target_s) -> Option<ghostty_surface_t> {
|
||||
// SAFETY: only called for targets tagged as surface.
|
||||
let s = unsafe { target.target.surface };
|
||||
if s.is_null() { None } else { Some(s) }
|
||||
}
|
||||
|
||||
fn new_window(target: &ghostty_target_s) {
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP => post(GHOSTTY_NEW_WINDOW, None, UserInfo::new()),
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = surface_view(surface) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
GHOSTTY_NEW_WINDOW,
|
||||
Some(&view),
|
||||
UserInfo::new().with(
|
||||
NEW_SURFACE_CONFIG_KEY,
|
||||
inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_WINDOW),
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => debug_assert!(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_tab(target: &ghostty_target_s) {
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP => post(GHOSTTY_NEW_TAB, None, UserInfo::new()),
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = surface_view(surface) else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = app_state(&view) else {
|
||||
return;
|
||||
};
|
||||
if !state.config().window_decorations() {
|
||||
let alert = NSAlert::new(crate::helpers::main_thread());
|
||||
alert.setMessageText(&NSString::from_str("Tabs are disabled"));
|
||||
alert.setInformativeText(&NSString::from_str(
|
||||
"Enable window decorations to use tabs",
|
||||
));
|
||||
alert.addButtonWithTitle(&NSString::from_str("OK"));
|
||||
alert.setAlertStyle(NSAlertStyle::Warning);
|
||||
let _ = alert.runModal();
|
||||
return;
|
||||
}
|
||||
|
||||
post(
|
||||
GHOSTTY_NEW_TAB,
|
||||
Some(&view),
|
||||
UserInfo::new().with(
|
||||
NEW_SURFACE_CONFIG_KEY,
|
||||
inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_TAB),
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => debug_assert!(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_split(target: &ghostty_target_s, direction: c_int) {
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP => {
|
||||
// New split does nothing with an app target
|
||||
warn!("new split does nothing with an app target");
|
||||
}
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = surface_view(surface) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
GHOSTTY_NEW_SPLIT,
|
||||
Some(&view),
|
||||
UserInfo::new().with("direction", direction).with(
|
||||
NEW_SURFACE_CONFIG_KEY,
|
||||
inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT),
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => debug_assert!(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn present_terminal(target: &ghostty_target_s) -> bool {
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP => false,
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return false;
|
||||
};
|
||||
let Some(view) = surface_view(surface) else {
|
||||
return false;
|
||||
};
|
||||
post(GHOSTTY_PRESENT_TERMINAL, Some(&view), UserInfo::new());
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn close_tab(target: &ghostty_target_s, mode: c_int) {
|
||||
let Some(view) = surface_target("close tabs", target) else {
|
||||
return;
|
||||
};
|
||||
match mode {
|
||||
GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS => post(GHOSTTY_CLOSE_TAB, Some(&view), UserInfo::new()),
|
||||
GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER => {
|
||||
post(GHOSTTY_CLOSE_OTHER_TABS, Some(&view), UserInfo::new());
|
||||
}
|
||||
GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT => {
|
||||
post(
|
||||
GHOSTTY_CLOSE_TABS_ON_THE_RIGHT,
|
||||
Some(&view),
|
||||
UserInfo::new(),
|
||||
);
|
||||
}
|
||||
_ => debug_assert!(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn close_window(target: &ghostty_target_s) {
|
||||
let Some(view) = surface_target("close window", target) else {
|
||||
return;
|
||||
};
|
||||
post(GHOSTTY_CLOSE_WINDOW, Some(&view), UserInfo::new());
|
||||
}
|
||||
|
||||
fn close_all_windows() {
|
||||
let Some(delegate) = app_delegate() else {
|
||||
return;
|
||||
};
|
||||
delegate.close_all_windows();
|
||||
}
|
||||
|
||||
fn toggle_fullscreen(target: &ghostty_target_s, raw: c_int) {
|
||||
let Some(view) = surface_target("toggle fullscreen", target) else {
|
||||
return;
|
||||
};
|
||||
let Some(mode) = fullscreen_mode::fullscreen_mode_from_ghostty(raw) else {
|
||||
warn!("unknown fullscreen mode raw={raw}");
|
||||
return;
|
||||
};
|
||||
post(
|
||||
GHOSTTY_TOGGLE_FULLSCREEN,
|
||||
Some(&view),
|
||||
UserInfo::new().with(FULLSCREEN_MODE_KEY, mode),
|
||||
);
|
||||
}
|
||||
|
||||
fn toggle_command_palette(target: &ghostty_target_s) {
|
||||
let Some(view) = surface_target("toggle command palette", target) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
GHOSTTY_COMMAND_PALETTE_DID_TOGGLE,
|
||||
Some(&view),
|
||||
UserInfo::new(),
|
||||
);
|
||||
}
|
||||
|
||||
fn toggle_maximize(target: &ghostty_target_s) {
|
||||
let Some(view) = surface_target("toggle maximize", target) else {
|
||||
return;
|
||||
};
|
||||
post(GHOSTTY_MAXIMIZE_DID_TOGGLE, Some(&view), UserInfo::new());
|
||||
}
|
||||
|
||||
fn toggle_visibility() {
|
||||
let Some(delegate) = app_delegate() else {
|
||||
return;
|
||||
};
|
||||
delegate.toggle_visibility();
|
||||
}
|
||||
|
||||
fn ring_bell(target: &ghostty_target_s) {
|
||||
// Technically we could still request app attention here but there are no
|
||||
// known cases where the bell is rang with an app target so I think its
|
||||
// better to warn.
|
||||
let Some(view) = surface_target("ring bell", target) else {
|
||||
return;
|
||||
};
|
||||
post(GHOSTTY_BELL_DID_RING, Some(&view), UserInfo::new());
|
||||
}
|
||||
|
||||
fn set_readonly(target: &ghostty_target_s, v: c_int) {
|
||||
let Some(view) = surface_target("set readonly", target) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
GHOSTTY_DID_CHANGE_READONLY,
|
||||
Some(&view),
|
||||
UserInfo::new().with(READONLY_KEY, v == GHOSTTY_READONLY_ON),
|
||||
);
|
||||
}
|
||||
|
||||
fn move_tab(
|
||||
target: &ghostty_target_s,
|
||||
mv: &crate::ghostty::capi::ghostty_action_move_tab_s,
|
||||
) -> bool {
|
||||
let Some(view) = surface_target("move tab", target) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// See goto_tab for notes on this check.
|
||||
if tab_count(&view) <= 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
post(
|
||||
GHOSTTY_MOVE_TAB,
|
||||
Some(&view),
|
||||
UserInfo::new().with(GHOSTTY_MOVE_TAB_KEY, MoveTab::from_c(mv)),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn goto_tab(target: &ghostty_target_s, tab: c_int) -> bool {
|
||||
let Some(view) = surface_target("goto tab", target) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Similar to goto_split (see comment there) about our performability, we
|
||||
// should make this more accurate later.
|
||||
if tab_count(&view) <= 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
post(
|
||||
GHOSTTY_GOTO_TAB,
|
||||
Some(&view),
|
||||
UserInfo::new().with(GOTO_TAB_KEY, tab),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn goto_split(target: &ghostty_target_s, direction: c_int) -> bool {
|
||||
let Some(view) = surface_target("goto split", target) else {
|
||||
return false;
|
||||
};
|
||||
let Some(controller) = base_controller(&view) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// If the window has no splits, the action is not performable
|
||||
if !controller.surface_tree_is_split() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert the C API direction to our type
|
||||
let Some(split_direction) = SplitFocusDirection::from_direction(direction) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if a split actually exists in the target direction before
|
||||
// returning true. This ensures performable keybinds only consume the key
|
||||
// event when we actually perform navigation.
|
||||
if !controller.focus_target_exists(split_direction, &view) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We have a valid target, post the notification to perform the navigation
|
||||
post(
|
||||
GHOSTTY_FOCUS_SPLIT,
|
||||
Some(&view),
|
||||
UserInfo::new().with(SPLIT_DIRECTION_KEY, split_direction),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn goto_window(direction: c_int) -> bool {
|
||||
let mtm = crate::helpers::main_thread();
|
||||
// Collect candidate windows: visible terminal windows that are either
|
||||
// standalone or the currently selected tab in their tab group. This treats
|
||||
// each native tab group as a single "window" for navigation purposes,
|
||||
// since goto_tab handles per-tab navigation.
|
||||
let candidates: Vec<Retained<NSWindow>> = NSApplication::sharedApplication(mtm)
|
||||
.windows()
|
||||
.iter()
|
||||
.filter(|window| {
|
||||
let is_terminal = window
|
||||
.windowController()
|
||||
.is_some_and(|c| c.downcast::<BaseTerminalController>().is_ok());
|
||||
if !is_terminal || !window.isVisible() || window.isMiniaturized() {
|
||||
return false;
|
||||
}
|
||||
// For native tabs, only include the selected tab in each group
|
||||
if let Some(group) = window.tabGroup()
|
||||
&& group
|
||||
.selectedWindow()
|
||||
.is_some_and(|s| Retained::as_ptr(&s) != Retained::as_ptr(window))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Need at least two windows to navigate between
|
||||
if candidates.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find starting index from the current key/main window
|
||||
let start_index = candidates
|
||||
.iter()
|
||||
.position(|w| w.isKeyWindow())
|
||||
.or_else(|| candidates.iter().position(|w| w.isMainWindow()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let step: isize = match direction {
|
||||
GHOSTTY_GOTO_WINDOW_NEXT => 1,
|
||||
GHOSTTY_GOTO_WINDOW_PREVIOUS => -1,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Iterate with wrap-around until we find a valid window or return to start
|
||||
let count = candidates.len() as isize;
|
||||
let mut index = (start_index as isize + step + count) % count;
|
||||
|
||||
while index != start_index as isize {
|
||||
let candidate = &candidates[index as usize];
|
||||
if candidate.isVisible() && !candidate.isMiniaturized() {
|
||||
candidate.makeKeyAndOrderFront(None);
|
||||
// Also focus the terminal surface within the window
|
||||
if let Some(controller) = candidate
|
||||
.windowController()
|
||||
.and_then(|c| c.downcast::<BaseTerminalController>().ok())
|
||||
{
|
||||
if let Some(surface) = controller.focused_surface() {
|
||||
move_focus(&surface, None, None);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
index = (index + step + count) % count;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn resize_split(
|
||||
target: &ghostty_target_s,
|
||||
resize: &crate::ghostty::capi::ghostty_action_resize_split_s,
|
||||
) -> bool {
|
||||
let Some(view) = surface_target("resize split", target) else {
|
||||
return false;
|
||||
};
|
||||
let Some(controller) = base_controller(&view) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// If the window has no splits, the action is not performable
|
||||
if !controller.surface_tree_is_split() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(direction) = SplitResizeDirection::from_direction(resize.direction) else {
|
||||
return false;
|
||||
};
|
||||
post(
|
||||
DID_RESIZE_SPLIT,
|
||||
Some(&view),
|
||||
UserInfo::new()
|
||||
.with(RESIZE_SPLIT_DIRECTION_KEY, direction)
|
||||
.with(RESIZE_SPLIT_AMOUNT_KEY, resize.amount),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn equalize_splits(target: &ghostty_target_s) {
|
||||
let Some(view) = surface_target("equalize splits", target) else {
|
||||
return;
|
||||
};
|
||||
post(DID_EQUALIZE_SPLITS, Some(&view), UserInfo::new());
|
||||
}
|
||||
|
||||
fn toggle_split_zoom(target: &ghostty_target_s) -> bool {
|
||||
let Some(view) = surface_target("toggle split zoom", target) else {
|
||||
return false;
|
||||
};
|
||||
let Some(controller) = base_controller(&view) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// If the window has no splits, the action is not performable
|
||||
if !controller.surface_tree_is_split() {
|
||||
return false;
|
||||
}
|
||||
|
||||
post(DID_TOGGLE_SPLIT_ZOOM, Some(&view), UserInfo::new());
|
||||
true
|
||||
}
|
||||
|
||||
fn control_inspector(target: &ghostty_target_s, mode: c_int) {
|
||||
let Some(view) = surface_target("toggle inspector", target) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
DID_CONTROL_INSPECTOR,
|
||||
Some(&view),
|
||||
UserInfo::new().with("mode", mode),
|
||||
);
|
||||
}
|
||||
|
||||
fn show_desktop_notification_action(
|
||||
target: &ghostty_target_s,
|
||||
n: &crate::ghostty::capi::ghostty_action_desktop_notification_s,
|
||||
) {
|
||||
let Some(view) = surface_target("desktop notification", target) else {
|
||||
return;
|
||||
};
|
||||
let Some(title) = c_str(n.title) else { return };
|
||||
let Some(body) = c_str(n.body) else { return };
|
||||
show_desktop_notification(&view, &title, &body, true);
|
||||
}
|
||||
|
||||
fn show_desktop_notification(
|
||||
view: &Retained<SurfaceView>,
|
||||
title: &str,
|
||||
body: &str,
|
||||
require_focus: bool,
|
||||
) {
|
||||
let center = UNUserNotificationCenter::currentNotificationCenter();
|
||||
let request_done = RcBlock::new(
|
||||
|_granted: objc2::runtime::Bool, error: *mut objc2_foundation::NSError| {
|
||||
// SAFETY: null or a valid NSError for the call.
|
||||
if let Some(error) = unsafe { error.as_ref() } {
|
||||
error!(
|
||||
"Error while requesting notification authorization: {}",
|
||||
error.localizedDescription()
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
center.requestAuthorizationWithOptions_completionHandler(
|
||||
UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound,
|
||||
&request_done,
|
||||
);
|
||||
|
||||
// The settings callback runs on a background thread; hop to the main
|
||||
// queue before touching the view (Swift calls the view directly).
|
||||
let mtm = crate::helpers::main_thread();
|
||||
let bound = std::sync::Arc::new(MainThreadBound::new(
|
||||
(Weak::from_retained(view), title.to_owned(), body.to_owned()),
|
||||
mtm,
|
||||
));
|
||||
let handler = RcBlock::new(move |settings: NonNull<UNNotificationSettings>| {
|
||||
// SAFETY: valid settings object for the call.
|
||||
if unsafe { settings.as_ref().authorizationStatus() } != UNAuthorizationStatus::Authorized {
|
||||
return;
|
||||
}
|
||||
let bound = bound.clone();
|
||||
DispatchQueue::main().exec_async(move || {
|
||||
let mtm = MainThreadMarker::new().expect("main queue");
|
||||
let (weak, title, body) = bound.get(mtm);
|
||||
if let Some(view) = weak.load() {
|
||||
view.show_user_notification(title, body, require_focus);
|
||||
}
|
||||
});
|
||||
});
|
||||
center.getNotificationSettingsWithCompletionHandler(&handler);
|
||||
}
|
||||
|
||||
fn command_finished(
|
||||
target: &ghostty_target_s,
|
||||
v: &crate::ghostty::capi::ghostty_action_command_finished_s,
|
||||
) {
|
||||
use crate::ghostty::config_types::{NotifyOnCommandFinish, NotifyOnCommandFinishAction};
|
||||
|
||||
let Some(view) = surface_target("command finished", target) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Determine if we even care about command finish notifications
|
||||
let Some(config) = app_delegate().map(|d| d.ghostty().config()) else {
|
||||
return;
|
||||
};
|
||||
match config.notify_on_command_finish() {
|
||||
NotifyOnCommandFinish::Never => return,
|
||||
NotifyOnCommandFinish::Unfocused => {
|
||||
if view.focused() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
NotifyOnCommandFinish::Always => {}
|
||||
}
|
||||
|
||||
// Determine if the command was slow enough
|
||||
let duration = Duration::from_nanos(v.duration);
|
||||
if duration < config.notify_on_command_finish_after() {
|
||||
return;
|
||||
}
|
||||
|
||||
let actions = config.notify_on_command_finish_action();
|
||||
|
||||
if actions.contains(NotifyOnCommandFinishAction::BELL) {
|
||||
post(GHOSTTY_BELL_DID_RING, Some(&view), UserInfo::new());
|
||||
}
|
||||
|
||||
if actions.contains(NotifyOnCommandFinishAction::NOTIFY) {
|
||||
let title = match v.exit_code.cmp(&0) {
|
||||
std::cmp::Ordering::Less => "Command Finished",
|
||||
std::cmp::Ordering::Equal => "Command Succeeded",
|
||||
std::cmp::Ordering::Greater => "Command Failed",
|
||||
};
|
||||
|
||||
let formatted = duration_format::format_duration_abbreviated(duration);
|
||||
let body = if v.exit_code < 0 {
|
||||
format!("Command took {formatted}.")
|
||||
} else {
|
||||
format!(
|
||||
"Command took {formatted} and exited with code {}.",
|
||||
v.exit_code
|
||||
)
|
||||
};
|
||||
|
||||
show_desktop_notification(&view, title, &body, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_float_window(target: &ghostty_target_s, mode_raw: c_int) {
|
||||
let Some(mode) = SetFloatWindow::from_c(mode_raw) else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = surface_target("toggle float window", target) else {
|
||||
return;
|
||||
};
|
||||
let Some(window) = view.window() else { return };
|
||||
let Ok(window) = window.downcast::<TerminalWindow>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// SAFETY: plain constants.
|
||||
let (floating, normal) = (NSFloatingWindowLevel, NSNormalWindowLevel);
|
||||
match mode {
|
||||
SetFloatWindow::On => window.setLevel(floating),
|
||||
SetFloatWindow::Off => window.setLevel(normal),
|
||||
SetFloatWindow::Toggle => window.setLevel(if window.level() == floating {
|
||||
normal
|
||||
} else {
|
||||
floating
|
||||
}),
|
||||
}
|
||||
|
||||
if let Some(delegate) = app_delegate() {
|
||||
delegate.sync_float_on_top_menu(Some(&window));
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_background_opacity(target: &ghostty_target_s) {
|
||||
let Some(view) = surface_target("toggle background opacity", target) else {
|
||||
return;
|
||||
};
|
||||
let Some(controller) = base_controller(&view) else {
|
||||
return;
|
||||
};
|
||||
controller.toggle_background_opacity();
|
||||
}
|
||||
|
||||
fn toggle_secure_input(target: &ghostty_target_s, mode_raw: c_int) {
|
||||
let Some(mode) = SetSecureInput::from_c(mode_raw) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP => {
|
||||
let Some(delegate) = app_delegate() else {
|
||||
return;
|
||||
};
|
||||
delegate.set_secure_input(mode);
|
||||
}
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = surface_view(surface) else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = app_state(&view) else {
|
||||
return;
|
||||
};
|
||||
if !state.config().auto_secure_input() {
|
||||
return;
|
||||
}
|
||||
|
||||
match mode {
|
||||
SetSecureInput::On => view.set_password_input(true),
|
||||
SetSecureInput::Off => view.set_password_input(false),
|
||||
SetSecureInput::Toggle => view.set_password_input(!view.password_input()),
|
||||
}
|
||||
}
|
||||
_ => debug_assert!(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_quick_terminal() {
|
||||
let Some(delegate) = app_delegate() else {
|
||||
return;
|
||||
};
|
||||
delegate.toggle_quick_terminal();
|
||||
}
|
||||
|
||||
fn set_title(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_set_title_s) {
|
||||
let Some(view) = surface_target("set title", target) else {
|
||||
return;
|
||||
};
|
||||
let Some(title) = c_str(v.title) else { return };
|
||||
view.set_title(&title);
|
||||
}
|
||||
|
||||
fn set_tab_title(
|
||||
target: &ghostty_target_s,
|
||||
v: &crate::ghostty::capi::ghostty_action_set_title_s,
|
||||
) -> bool {
|
||||
let Some(view) = surface_target("set tab title", target) else {
|
||||
return false;
|
||||
};
|
||||
let Some(title) = c_str(v.title) else {
|
||||
return false;
|
||||
};
|
||||
let title_override = if title.is_empty() { None } else { Some(title) };
|
||||
let Some(controller) = base_controller(&view) else {
|
||||
return false;
|
||||
};
|
||||
controller.set_title_override(title_override);
|
||||
true
|
||||
}
|
||||
|
||||
fn copy_title_to_clipboard(target: &ghostty_target_s) -> bool {
|
||||
if target.tag != GHOSTTY_TARGET_SURFACE {
|
||||
return false;
|
||||
}
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return false;
|
||||
};
|
||||
let Some(view) = surface_view(surface) else {
|
||||
return false;
|
||||
};
|
||||
let title = view.title();
|
||||
if title.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let pasteboard = NSPasteboard::generalPasteboard();
|
||||
pasteboard.clearContents();
|
||||
// SAFETY: valid constant.
|
||||
let _ = pasteboard.setString_forType(&NSString::from_str(&title), unsafe {
|
||||
NSPasteboardTypeString
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn prompt_title(target: &ghostty_target_s, v: c_int) -> bool {
|
||||
match PromptTitle::from_c(v) {
|
||||
PromptTitle::Surface => match target.tag {
|
||||
GHOSTTY_TARGET_APP => {
|
||||
warn!("set title prompt does nothing with an app target");
|
||||
false
|
||||
}
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return false;
|
||||
};
|
||||
let Some(view) = surface_view(surface) else {
|
||||
return false;
|
||||
};
|
||||
view.prompt_title();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
PromptTitle::Tab => match target.tag {
|
||||
GHOSTTY_TARGET_APP => {
|
||||
let app = NSApplication::sharedApplication(crate::helpers::main_thread());
|
||||
let Some(window) = app.mainWindow().or_else(|| app.keyWindow()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(controller) = window
|
||||
.windowController()
|
||||
.and_then(|c| c.downcast::<BaseTerminalController>().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
controller.prompt_tab_title();
|
||||
true
|
||||
}
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return false;
|
||||
};
|
||||
let Some(view) = surface_view(surface) else {
|
||||
return false;
|
||||
};
|
||||
let Some(controller) = base_controller(&view) else {
|
||||
return false;
|
||||
};
|
||||
controller.prompt_tab_title();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn pwd_changed(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_pwd_s) {
|
||||
let Some(view) = surface_target("pwd change", target) else {
|
||||
return;
|
||||
};
|
||||
let Some(pwd) = c_str(v.pwd) else { return };
|
||||
view.set_pwd(Some(pwd));
|
||||
}
|
||||
|
||||
fn set_mouse_shape(target: &ghostty_target_s, shape: c_int) {
|
||||
let Some(view) = surface_target("set mouse shape", target) else {
|
||||
return;
|
||||
};
|
||||
view.set_cursor_shape(shape);
|
||||
}
|
||||
|
||||
fn set_mouse_visibility(target: &ghostty_target_s, v: c_int) {
|
||||
let Some(view) = surface_target("set mouse visibility", target) else {
|
||||
return;
|
||||
};
|
||||
match v {
|
||||
GHOSTTY_MOUSE_VISIBLE => view.set_cursor_visibility(true),
|
||||
GHOSTTY_MOUSE_HIDDEN => view.set_cursor_visibility(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mouse_over_link(
|
||||
target: &ghostty_target_s,
|
||||
v: &crate::ghostty::capi::ghostty_action_mouse_over_link_s,
|
||||
) {
|
||||
let Some(view) = surface_target("mouse over link", target) else {
|
||||
return;
|
||||
};
|
||||
if v.len == 0 || v.url.is_null() {
|
||||
view.set_hover_url(None);
|
||||
return;
|
||||
}
|
||||
|
||||
// SAFETY: libghostty passes `len` valid bytes.
|
||||
let buffer = unsafe { std::slice::from_raw_parts(v.url.cast::<u8>(), v.len) };
|
||||
view.set_hover_url(String::from_utf8(buffer.to_vec()).ok());
|
||||
}
|
||||
|
||||
fn set_initial_size(
|
||||
target: &ghostty_target_s,
|
||||
v: &crate::ghostty::capi::ghostty_action_initial_size_s,
|
||||
) {
|
||||
let Some(view) = surface_target("initial size", target) else {
|
||||
return;
|
||||
};
|
||||
view.set_initial_size(Some(NSSize::new(f64::from(v.width), f64::from(v.height))));
|
||||
}
|
||||
|
||||
fn reset_window_size(target: &ghostty_target_s) {
|
||||
let Some(view) = surface_target("reset window size", target) else {
|
||||
return;
|
||||
};
|
||||
post(GHOSTTY_RESET_WINDOW_SIZE, Some(&view), UserInfo::new());
|
||||
}
|
||||
|
||||
fn set_cell_size(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_cell_size_s) {
|
||||
let Some(view) = surface_target("cell size", target) else {
|
||||
return;
|
||||
};
|
||||
let backing_size = NSSize::new(f64::from(v.width), f64::from(v.height));
|
||||
let weak = Weak::from_retained(&view);
|
||||
dispatch_main_async(move || {
|
||||
let Some(view) = weak.load() else { return };
|
||||
let size = view.convert_size_from_backing(backing_size);
|
||||
view.set_cell_size(size);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_inspector(target: &ghostty_target_s) {
|
||||
let Some(view) = surface_target("render inspector", target) else {
|
||||
return;
|
||||
};
|
||||
post(INSPECTOR_NEEDS_DISPLAY, Some(&view), UserInfo::new());
|
||||
}
|
||||
|
||||
fn renderer_health(target: &ghostty_target_s, v: c_int) {
|
||||
let Some(view) = surface_target("renderer health", target) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
DID_UPDATE_RENDERER_HEALTH,
|
||||
Some(&view),
|
||||
UserInfo::new().with("health", v),
|
||||
);
|
||||
}
|
||||
|
||||
fn key_sequence(
|
||||
target: &ghostty_target_s,
|
||||
v: &crate::ghostty::capi::ghostty_action_key_sequence_s,
|
||||
) {
|
||||
let Some(view) = surface_target("key sequence", target) else {
|
||||
return;
|
||||
};
|
||||
if v.active {
|
||||
post(
|
||||
DID_CONTINUE_KEY_SEQUENCE,
|
||||
Some(&view),
|
||||
UserInfo::new().with(KEY_SEQUENCE_KEY, input::keyboard_shortcut(&v.trigger)),
|
||||
);
|
||||
} else {
|
||||
post(DID_END_KEY_SEQUENCE, Some(&view), UserInfo::new());
|
||||
}
|
||||
}
|
||||
|
||||
fn key_table(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_key_table_s) {
|
||||
let Some(view) = surface_target("key table", target) else {
|
||||
return;
|
||||
};
|
||||
// SAFETY: the C struct comes straight from libghostty.
|
||||
let Some(action) = (unsafe { KeyTable::from_c(v) }) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
DID_CHANGE_KEY_TABLE,
|
||||
Some(&view),
|
||||
UserInfo::new().with(KEY_TABLE_KEY, action),
|
||||
);
|
||||
}
|
||||
|
||||
fn progress_report(
|
||||
target: &ghostty_target_s,
|
||||
v: &crate::ghostty::capi::ghostty_action_progress_report_s,
|
||||
) {
|
||||
let Some(view) = surface_target("progress report", target) else {
|
||||
return;
|
||||
};
|
||||
let Some(config) = app_delegate().map(|d| d.ghostty().config()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !config.progress_style() {
|
||||
debug!("progress_report action blocked by config");
|
||||
let weak = Weak::from_retained(&view);
|
||||
dispatch_main_async(move || {
|
||||
if let Some(view) = weak.load() {
|
||||
view.set_progress_report(None);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let report = ProgressReport::from_c(v);
|
||||
let weak = Weak::from_retained(&view);
|
||||
dispatch_main_async(move || {
|
||||
let Some(view) = weak.load() else { return };
|
||||
if report.state == ProgressReportState::Remove {
|
||||
view.set_progress_report(None);
|
||||
} else {
|
||||
view.set_progress_report(Some(report));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn scrollbar(target: &ghostty_target_s, v: &crate::ghostty::capi::ghostty_action_scrollbar_s) {
|
||||
let Some(view) = surface_target("scrollbar", target) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
GHOSTTY_DID_UPDATE_SCROLLBAR,
|
||||
Some(&view),
|
||||
UserInfo::new().with(SCROLLBAR_KEY, Scrollbar::from_c(v)),
|
||||
);
|
||||
}
|
||||
|
||||
fn start_search(
|
||||
target: &ghostty_target_s,
|
||||
v: &crate::ghostty::capi::ghostty_action_start_search_s,
|
||||
) {
|
||||
let Some(view) = surface_target("start_search", target) else {
|
||||
return;
|
||||
};
|
||||
// SAFETY: the needle is null or a valid C string.
|
||||
let start = unsafe { StartSearch::from_c(v) };
|
||||
let weak = Weak::from_retained(&view);
|
||||
dispatch_main_async(move || {
|
||||
let Some(view) = weak.load() else { return };
|
||||
if let Some(search_state) = view.search_state() {
|
||||
if let Some(needle) = start.needle.as_ref().filter(|n| !n.is_empty()) {
|
||||
search_state.needle.set(needle.clone());
|
||||
}
|
||||
} else {
|
||||
view.set_search_state(Some(Rc::new(SearchState::new(&start))));
|
||||
}
|
||||
|
||||
post(GHOSTTY_SEARCH_FOCUS, Some(&view), UserInfo::new());
|
||||
});
|
||||
}
|
||||
|
||||
fn end_search(target: &ghostty_target_s) {
|
||||
let Some(view) = surface_target("end_search", target) else {
|
||||
return;
|
||||
};
|
||||
let weak = Weak::from_retained(&view);
|
||||
dispatch_main_async(move || {
|
||||
if let Some(view) = weak.load() {
|
||||
view.set_search_state(None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn search_total(target: &ghostty_target_s, total: isize) {
|
||||
let Some(view) = surface_target("search_total", target) else {
|
||||
return;
|
||||
};
|
||||
let total = usize::try_from(total).ok();
|
||||
let weak = Weak::from_retained(&view);
|
||||
dispatch_main_async(move || {
|
||||
if let Some(state) = weak.load().and_then(|v| v.search_state()) {
|
||||
state.total.set(total);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn search_selected(target: &ghostty_target_s, selected: isize) {
|
||||
let Some(view) = surface_target("search_selected", target) else {
|
||||
return;
|
||||
};
|
||||
let selected = usize::try_from(selected).ok();
|
||||
let weak = Weak::from_retained(&view);
|
||||
dispatch_main_async(move || {
|
||||
if let Some(state) = weak.load().and_then(|v| v.search_state()) {
|
||||
state.selected.set(selected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn config_reload(
|
||||
app: ghostty_app_t,
|
||||
target: &ghostty_target_s,
|
||||
v: &crate::ghostty::capi::ghostty_action_reload_config_s,
|
||||
) {
|
||||
info!("config reload notification");
|
||||
|
||||
let Some(ghostty) = app_from_handle(app) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP => ghostty.reload_config(v.soft),
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return;
|
||||
};
|
||||
ghostty.reload_config_surface(surface, v.soft);
|
||||
}
|
||||
_ => debug_assert!(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn config_change(
|
||||
app: ghostty_app_t,
|
||||
target: &ghostty_target_s,
|
||||
v: &crate::ghostty::capi::ghostty_action_config_change_s,
|
||||
) {
|
||||
info!("config change notification");
|
||||
|
||||
// Clone the config so we own the memory. It'd be nicer to not have to do
|
||||
// this but since we async send the config out below we have to own the
|
||||
// lifetime. A future improvement might be to add reference counting to
|
||||
// config or something so apprt's do not have to do this.
|
||||
// SAFETY: `v.config` is valid for the duration of the callback.
|
||||
let config = Rc::new(unsafe { Config::clone_from_ptr(v.config) });
|
||||
|
||||
match target.tag {
|
||||
GHOSTTY_TARGET_APP => {
|
||||
// Notify the world that the app config changed
|
||||
post(
|
||||
GHOSTTY_CONFIG_DID_CHANGE,
|
||||
None,
|
||||
UserInfo::new().with(GHOSTTY_CONFIG_CHANGE_KEY, config.clone()),
|
||||
);
|
||||
|
||||
// We also REPLACE our app-level config when this happens. This lets
|
||||
// all the various things that depend on this but are still theme
|
||||
// specific such as split border color work.
|
||||
let Some(ghostty) = app_from_handle(app) else {
|
||||
return;
|
||||
};
|
||||
ghostty.config.set(config);
|
||||
}
|
||||
GHOSTTY_TARGET_SURFACE => {
|
||||
let Some(surface) = surface_of(target) else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = surface_view(surface) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
GHOSTTY_CONFIG_DID_CHANGE,
|
||||
Some(&view),
|
||||
UserInfo::new().with(GHOSTTY_CONFIG_CHANGE_KEY, config),
|
||||
);
|
||||
}
|
||||
_ => debug_assert!(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn color_change(
|
||||
target: &ghostty_target_s,
|
||||
change: &crate::ghostty::capi::ghostty_action_color_change_s,
|
||||
) {
|
||||
let Some(view) = surface_target("color change", target) else {
|
||||
return;
|
||||
};
|
||||
post(
|
||||
GHOSTTY_COLOR_DID_CHANGE,
|
||||
Some(&view),
|
||||
UserInfo::new().with(GHOSTTY_COLOR_CHANGE_KEY, ColorChange::from_c(change)),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<Config>`.
|
||||
//!
|
||||
//! Deviations (recorded in `docs/rewrite/notes/macos-app-MA1.md`):
|
||||
//! * `background_color` falls back to a fixed light gray where Swift uses the
|
||||
//! dynamic `NSColor.windowBackgroundColor`; use [`Config::background_color_opt`]
|
||||
//! to detect the fallback.
|
||||
//! * `window_fullscreen` / `window_fullscreen_mode` return the raw config
|
||||
//! strings (`FullscreenMode` belongs to `helpers::fullscreen`).
|
||||
//! TODO(MA3): convert to `FullscreenMode`.
|
||||
#![allow(unsafe_code)]
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::assert_is_empty))]
|
||||
|
||||
use std::{
|
||||
cell::Cell,
|
||||
ffi::{CStr, CString, c_char, c_int, c_uint, c_void},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use super::{
|
||||
capi::{
|
||||
self,
|
||||
ghostty_config_color_list_s,
|
||||
ghostty_config_color_s,
|
||||
ghostty_config_command_list_s,
|
||||
ghostty_config_path_s,
|
||||
ghostty_config_quick_terminal_size_s,
|
||||
ghostty_config_t,
|
||||
},
|
||||
command::Command,
|
||||
config_types::{
|
||||
AutoUpdate,
|
||||
AutoUpdateChannel,
|
||||
BackgroundBlur,
|
||||
BellFeatures,
|
||||
ConfigPath,
|
||||
MacDockDropBehavior,
|
||||
MacHidden,
|
||||
MacOSIcon,
|
||||
MacOSIconFrame,
|
||||
MacOSTitlebarProxyIcon,
|
||||
MacOSTitlebarStyle,
|
||||
MacOSWindowButtons,
|
||||
MacShortcuts,
|
||||
NotifyOnCommandFinish,
|
||||
NotifyOnCommandFinishAction,
|
||||
ResizeOverlay,
|
||||
ResizeOverlayPosition,
|
||||
Scrollbar,
|
||||
SplitPreserveZoom,
|
||||
WindowDecoration,
|
||||
},
|
||||
input,
|
||||
};
|
||||
use crate::{
|
||||
features::quick_terminal::{
|
||||
QuickTerminalPosition,
|
||||
QuickTerminalScreen,
|
||||
QuickTerminalSize,
|
||||
QuickTerminalSpaceBehavior,
|
||||
},
|
||||
helpers::{app_info::is_running_in_xcode, color::Color, keyboard_shortcut::KeyboardShortcut},
|
||||
};
|
||||
|
||||
/// Maps to a `ghostty_config_t` and the various operations on that.
|
||||
pub struct Config {
|
||||
/// The underlying C pointer to the Ghostty config structure (null = nil).
|
||||
/// This should never be accessed directly. Any operations on this should
|
||||
/// be called from the functions on this or another class.
|
||||
config: Cell<ghostty_config_t>,
|
||||
}
|
||||
|
||||
impl Drop for Config {
|
||||
fn drop(&mut self) {
|
||||
let old = self.config.replace(std::ptr::null_mut());
|
||||
if !old.is_null() {
|
||||
// SAFETY: we own the handle and nothing uses it afterwards.
|
||||
unsafe { capi::ghostty_config_free(old) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// `init(config:)`: takes ownership of `config` (may be null).
|
||||
#[must_use]
|
||||
pub fn from_ptr(config: ghostty_config_t) -> Self {
|
||||
Self {
|
||||
config: Cell::new(config),
|
||||
}
|
||||
}
|
||||
|
||||
/// `init(at:finalize:)`.
|
||||
///
|
||||
/// * `path`: An optional preferred config file path. Pass `None` to load the default configuration files.
|
||||
/// * `finalize`: Whether to finalize the configuration to populate default values.
|
||||
#[must_use]
|
||||
pub fn new(path: Option<&str>, finalize: bool) -> Self {
|
||||
Self::from_ptr(Self::load_config(path, finalize))
|
||||
}
|
||||
|
||||
/// `init(clone:)`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `config` must be a valid config handle.
|
||||
#[must_use]
|
||||
pub unsafe fn clone_from_ptr(config: ghostty_config_t) -> Self {
|
||||
// SAFETY: per the contract.
|
||||
Self::from_ptr(unsafe { capi::ghostty_config_clone(config) })
|
||||
}
|
||||
|
||||
/// The raw handle (for `ghostty_app_update_config` and friends). Owned by
|
||||
/// `self`; null when not loaded.
|
||||
#[must_use]
|
||||
pub fn as_ptr(&self) -> ghostty_config_t {
|
||||
self.config.get()
|
||||
}
|
||||
|
||||
/// True if the configuration is loaded
|
||||
#[must_use]
|
||||
pub fn loaded(&self) -> bool {
|
||||
!self.config.get().is_null()
|
||||
}
|
||||
|
||||
/// Return the errors found while loading the configuration.
|
||||
#[must_use]
|
||||
pub fn errors(&self) -> Vec<String> {
|
||||
let cfg = self.config.get();
|
||||
if cfg.is_null() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut diags = Vec::new();
|
||||
// SAFETY: `cfg` is a valid handle owned by `self`.
|
||||
let count = unsafe { capi::ghostty_config_diagnostics_count(cfg) };
|
||||
for i in 0..count {
|
||||
// SAFETY: as above; the message lives as long as the config.
|
||||
let diag = unsafe { capi::ghostty_config_get_diagnostic(cfg, i) };
|
||||
diags.push(cstr_to_string(diag.message));
|
||||
}
|
||||
|
||||
diags
|
||||
}
|
||||
|
||||
/// Initializes a new configuration and loads all the values.
|
||||
///
|
||||
/// * `path`: An optional preferred config file path. Pass `None` to load the default configuration files.
|
||||
/// * `finalize`: Whether to finalize the configuration to populate default values.
|
||||
#[must_use]
|
||||
pub fn load_config(path: Option<&str>, finalize: bool) -> ghostty_config_t {
|
||||
// Initialize the global configuration.
|
||||
let cfg = capi::ghostty_config_new();
|
||||
if cfg.is_null() {
|
||||
log::error!("ghostty_config_new failed");
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
|
||||
// Load our configuration from files, CLI args, and then any referenced files.
|
||||
// We only do this on macOS because other Apple platforms do not have the
|
||||
// same filesystem concept. (This crate only targets macOS.)
|
||||
// SAFETY: `cfg` is the valid handle created above; strings are NUL terminated.
|
||||
unsafe {
|
||||
if let Some(path) = path {
|
||||
match CString::new(path) {
|
||||
Ok(path) => capi::ghostty_config_load_file(cfg, path.as_ptr()),
|
||||
Err(_) => log::warn!("config path contains a NUL byte path={path:?}"),
|
||||
}
|
||||
} else {
|
||||
capi::ghostty_config_load_default_files(cfg);
|
||||
}
|
||||
|
||||
// We only load CLI args when not running in Xcode because in Xcode we
|
||||
// pass some special parameters to control the debugger.
|
||||
if !is_running_in_xcode() {
|
||||
capi::ghostty_config_load_cli_args(cfg);
|
||||
}
|
||||
|
||||
capi::ghostty_config_load_recursive_files(cfg);
|
||||
}
|
||||
|
||||
// TODO: we'd probably do some config loading here... for now we'd
|
||||
// have to do this synchronously. When we support config updating we can do
|
||||
// this async and update later.
|
||||
|
||||
if finalize {
|
||||
// Finalize will make our defaults available.
|
||||
// SAFETY: valid handle.
|
||||
unsafe { capi::ghostty_config_finalize(cfg) };
|
||||
}
|
||||
// Log any configuration errors. These will be automatically shown in a
|
||||
// pop-up window too.
|
||||
// SAFETY: valid handle.
|
||||
let diags_count = unsafe { capi::ghostty_config_diagnostics_count(cfg) };
|
||||
if diags_count > 0 {
|
||||
log::warn!("config error: {diags_count} configuration errors on reload");
|
||||
for i in 0..diags_count {
|
||||
// SAFETY: valid handle; the message lives as long as the config.
|
||||
let diag = unsafe { capi::ghostty_config_get_diagnostic(cfg, i) };
|
||||
log::warn!("config error: {}", cstr_to_string(diag.message));
|
||||
}
|
||||
}
|
||||
|
||||
cfg
|
||||
}
|
||||
|
||||
// MARK: - Keybindings
|
||||
|
||||
/// Return the key equivalent for the given action. The action is the name of the action
|
||||
/// in the Ghostty configuration. For example `keybind = cmd+q=quit` in Ghostty
|
||||
/// configuration would be "quit" action.
|
||||
///
|
||||
/// Returns nil if there is no key equivalent for the given action.
|
||||
#[must_use]
|
||||
pub fn keyboard_shortcut(&self, action: &str) -> Option<KeyboardShortcut> {
|
||||
let cfg = self.config.get();
|
||||
if cfg.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// SAFETY: valid handle; `action` is `len` readable bytes.
|
||||
let trigger =
|
||||
unsafe { capi::ghostty_config_trigger(cfg, action.as_ptr().cast(), action.len()) };
|
||||
input::keyboard_shortcut(&trigger)
|
||||
}
|
||||
|
||||
// MARK: - Raw access
|
||||
|
||||
/// `ghostty_config_get(config, &v, key, key.count)`.
|
||||
fn get_raw<T>(&self, key: &str, out: &mut T) -> bool {
|
||||
let cfg = self.config.get();
|
||||
// SAFETY: valid (or null, which the C API tolerates) handle; `out` is
|
||||
// storage of the key's C type, chosen by the caller per key.
|
||||
unsafe {
|
||||
capi::ghostty_config_get(
|
||||
cfg,
|
||||
std::ptr::from_mut(out).cast::<c_void>(),
|
||||
key.as_ptr().cast(),
|
||||
key.len(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain value of C type `T` (`bool`, integer, float): `None` if the key is unsupported/unset.
|
||||
fn get_value<T>(&self, key: &str, init: T) -> Option<T> {
|
||||
let mut v = init;
|
||||
self.get_raw(key, &mut v).then_some(v)
|
||||
}
|
||||
|
||||
/// Bool getter: the value if the lookup works, `init` otherwise.
|
||||
fn get_bool(&self, key: &str, init: bool) -> bool {
|
||||
let mut v = init;
|
||||
let _ = self.get_raw(key, &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// A `?[:0]const u8` / enum-tag string (`UnsafePointer<Int8>?`).
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
let mut v: *const c_char = std::ptr::null();
|
||||
if !self.get_raw(key, &mut v) || v.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(cstr_to_string(v))
|
||||
}
|
||||
|
||||
/// A color (`ghostty_config_color_s`).
|
||||
fn get_color(&self, key: &str) -> Option<ghostty_config_color_s> {
|
||||
let mut v = ghostty_config_color_s { r: 0, g: 0, b: 0 };
|
||||
self.get_raw(key, &mut v).then_some(v)
|
||||
}
|
||||
|
||||
// MARK: - Configuration Values
|
||||
|
||||
// For all of the configuration values below, see the associated Ghostty documentation for
|
||||
// details on what each means. We only add documentation if there is a strange conversion
|
||||
// due to the embedded library and Rust.
|
||||
|
||||
#[must_use]
|
||||
pub fn bell_features(&self) -> BellFeatures {
|
||||
if !self.loaded() {
|
||||
return BellFeatures::empty();
|
||||
}
|
||||
self.get_value::<c_uint>("bell-features", 0)
|
||||
.map_or_else(BellFeatures::empty, BellFeatures::from_bits_retain)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bell_audio_path(&self) -> Option<ConfigPath> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
let mut v = ghostty_config_path_s {
|
||||
path: c"".as_ptr(),
|
||||
optional: false,
|
||||
};
|
||||
let key = "bell-audio-path";
|
||||
if !self.get_raw(key, &mut v) {
|
||||
return None;
|
||||
}
|
||||
let path = cstr_to_string(v.path);
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ConfigPath {
|
||||
path,
|
||||
optional: v.optional,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bell_audio_volume(&self) -> f32 {
|
||||
if !self.loaded() {
|
||||
return 0.5;
|
||||
}
|
||||
let mut v: f64 = 0.5;
|
||||
let _ = self.get_raw("bell-audio-volume", &mut v);
|
||||
v as f32
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn notify_on_command_finish(&self) -> NotifyOnCommandFinish {
|
||||
if !self.loaded() {
|
||||
return NotifyOnCommandFinish::Never;
|
||||
}
|
||||
self.get_string("notify-on-command-finish")
|
||||
.and_then(|s| NotifyOnCommandFinish::from_raw_value(&s))
|
||||
.unwrap_or(NotifyOnCommandFinish::Never)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn notify_on_command_finish_action(&self) -> NotifyOnCommandFinishAction {
|
||||
let default_value = NotifyOnCommandFinishAction::BELL;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_value::<c_uint>("notify-on-command-finish-action", 0)
|
||||
.map_or(default_value, NotifyOnCommandFinishAction::from_bits_retain)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn notify_on_command_finish_after(&self) -> Duration {
|
||||
if !self.loaded() {
|
||||
return Duration::from_secs(5);
|
||||
}
|
||||
let mut v: usize = 0;
|
||||
let _ = self.get_raw("notify-on-command-finish-after", &mut v);
|
||||
Duration::from_millis(v as u64)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn split_preserve_zoom(&self) -> SplitPreserveZoom {
|
||||
if !self.loaded() {
|
||||
return SplitPreserveZoom::empty();
|
||||
}
|
||||
self.get_value::<c_uint>("split-preserve-zoom", 0)
|
||||
.map_or_else(
|
||||
SplitPreserveZoom::empty,
|
||||
SplitPreserveZoom::from_bits_retain,
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn initial_window(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return true;
|
||||
}
|
||||
self.get_bool("initial-window", true)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn should_quit_after_last_window_closed(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return true;
|
||||
}
|
||||
self.get_bool("quit-after-last-window-closed", false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn title(&self) -> Option<String> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
self.get_string("title")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn window_save_state(&self) -> String {
|
||||
if !self.loaded() {
|
||||
return String::new();
|
||||
}
|
||||
self.get_string("window-save-state").unwrap_or_default()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn window_position_x(&self) -> Option<i16> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
self.get_value::<i16>("window-position-x", 0)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn window_position_y(&self) -> Option<i16> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
self.get_value::<i16>("window-position-y", 0)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn window_new_tab_position(&self) -> String {
|
||||
if !self.loaded() {
|
||||
return String::new();
|
||||
}
|
||||
self.get_string("window-new-tab-position")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn window_decorations(&self) -> bool {
|
||||
let default_value = true;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("window-decoration")
|
||||
.and_then(|s| WindowDecoration::from_raw_value(&s))
|
||||
.map_or(default_value, WindowDecoration::enabled)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn window_theme(&self) -> Option<String> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
self.get_string("window-theme")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn window_step_resize(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return true;
|
||||
}
|
||||
self.get_bool("window-step-resize", false)
|
||||
}
|
||||
|
||||
/// The raw `fullscreen` enum config string (`"false"`, `"true"`,
|
||||
/// `"non-native"`, `"non-native-visible-menu"`, `"non-native-padded-notch"`).
|
||||
/// Swift returns the fullscreen mode if fullscreen is enabled, or nil if
|
||||
/// disabled; use [`fullscreen_mode_from_config`] for that mapping.
|
||||
/// TODO(MA3): return `Option<FullscreenMode>` once `helpers::fullscreen` exists.
|
||||
#[must_use]
|
||||
pub fn window_fullscreen(&self) -> Option<String> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
self.get_string("fullscreen")
|
||||
}
|
||||
|
||||
/// The raw `macos-non-native-fullscreen` config string (`"false"`, `"true"`,
|
||||
/// `"visible-menu"`, `"padded-notch"`); Swift's default is native
|
||||
/// (`"false"`). Controls the fullscreen mode for toggle actions (keybindings).
|
||||
/// TODO(MA3): return `FullscreenMode`.
|
||||
#[must_use]
|
||||
pub fn window_fullscreen_mode(&self) -> String {
|
||||
let default_value = "false".to_string();
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("macos-non-native-fullscreen")
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn window_title_font_family(&self) -> Option<String> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
self.get_string("window-title-font-family")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_window_buttons(&self) -> MacOSWindowButtons {
|
||||
let default_value = MacOSWindowButtons::Visible;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("macos-window-buttons")
|
||||
.and_then(|s| MacOSWindowButtons::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_titlebar_style(&self) -> MacOSTitlebarStyle {
|
||||
let default_value = MacOSTitlebarStyle::Transparent;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("macos-titlebar-style")
|
||||
.and_then(|s| MacOSTitlebarStyle::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_titlebar_proxy_icon(&self) -> MacOSTitlebarProxyIcon {
|
||||
let default_value = MacOSTitlebarProxyIcon::Visible;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("macos-titlebar-proxy-icon")
|
||||
.and_then(|s| MacOSTitlebarProxyIcon::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_dock_drop_behavior(&self) -> MacDockDropBehavior {
|
||||
let default_value = MacDockDropBehavior::NewTab;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("macos-dock-drop-behavior")
|
||||
.and_then(|s| MacDockDropBehavior::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_window_shadow(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return false;
|
||||
}
|
||||
self.get_bool("macos-window-shadow", false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_icon(&self) -> MacOSIcon {
|
||||
let default_value = MacOSIcon::Official;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("macos-icon")
|
||||
.and_then(|s| MacOSIcon::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_custom_icon(&self) -> String {
|
||||
let default_value = expanding_tilde_in_path("~/.config/ghostty/Ghostty.icns");
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
match self.get_string("macos-custom-icon") {
|
||||
Some(path) => expanding_tilde_in_path(&path),
|
||||
None => default_value,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_icon_frame(&self) -> MacOSIconFrame {
|
||||
let default_value = MacOSIconFrame::Aluminum;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("macos-icon-frame")
|
||||
.and_then(|s| MacOSIconFrame::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
/// `macosIconGhostColor` (an `OSColor?` in Swift; AppKit conversion in `helpers::extensions`).
|
||||
#[must_use]
|
||||
pub fn macos_icon_ghost_color(&self) -> Option<Color> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
self.get_color("macos-icon-ghost-color")
|
||||
.map(|c| Color::from_rgb8(c.r, c.g, c.b))
|
||||
}
|
||||
|
||||
/// `macosIconScreenColor` (an `[OSColor]?` in Swift).
|
||||
#[must_use]
|
||||
pub fn macos_icon_screen_color(&self) -> Option<Vec<Color>> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
let mut v = ghostty_config_color_list_s {
|
||||
colors: std::ptr::null(),
|
||||
len: 0,
|
||||
};
|
||||
if !self.get_raw("macos-icon-screen-color", &mut v) || v.len == 0 || v.colors.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the C API guarantees `len` colors at `colors`, valid while the config lives.
|
||||
let buffer = unsafe { std::slice::from_raw_parts(v.colors, v.len) };
|
||||
Some(
|
||||
buffer
|
||||
.iter()
|
||||
.map(|c| Color::from_rgb8(c.r, c.g, c.b))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_hidden(&self) -> MacHidden {
|
||||
if !self.loaded() {
|
||||
return MacHidden::Never;
|
||||
}
|
||||
self.get_string("macos-hidden")
|
||||
.and_then(|s| MacHidden::from_raw_value(&s))
|
||||
.unwrap_or(MacHidden::Never)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn focus_follows_mouse(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return false;
|
||||
}
|
||||
self.get_bool("focus-follows-mouse", false)
|
||||
}
|
||||
|
||||
/// `background` when the lookup works.
|
||||
#[must_use]
|
||||
pub fn background_color_opt(&self) -> Option<Color> {
|
||||
self.get_color("background")
|
||||
.map(|c| Color::from_rgb8(c.r, c.g, c.b))
|
||||
}
|
||||
|
||||
/// `backgroundColor`. Swift falls back to `NSColor.windowBackgroundColor`
|
||||
/// (dynamic); here a fixed light gray stands in.
|
||||
#[must_use]
|
||||
pub fn background_color(&self) -> Color {
|
||||
self.background_color_opt()
|
||||
.unwrap_or(Color::new(0.925, 0.925, 0.925))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn background_opacity(&self) -> f64 {
|
||||
if !self.loaded() {
|
||||
return 1.0;
|
||||
}
|
||||
let mut v: f64 = 1.0;
|
||||
let _ = self.get_raw("background-opacity", &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn background_blur(&self) -> BackgroundBlur {
|
||||
if !self.loaded() {
|
||||
return BackgroundBlur::Disabled;
|
||||
}
|
||||
let mut v: i16 = 0;
|
||||
let _ = self.get_raw("background-blur", &mut v);
|
||||
BackgroundBlur::from_c_value(v)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn unfocused_split_opacity(&self) -> f64 {
|
||||
if !self.loaded() {
|
||||
return 1.0;
|
||||
}
|
||||
let mut opacity: f64 = 0.85;
|
||||
let _ = self.get_raw("unfocused-split-opacity", &mut opacity);
|
||||
1.0 - opacity
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn unfocused_split_fill(&self) -> Color {
|
||||
if !self.loaded() {
|
||||
return Color::WHITE;
|
||||
}
|
||||
|
||||
let mut color = ghostty_config_color_s { r: 0, g: 0, b: 0 };
|
||||
if !self.get_raw("unfocused-split-fill", &mut color) {
|
||||
let _ = self.get_raw("background", &mut color);
|
||||
}
|
||||
|
||||
Color::from_rgb8(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn split_divider_color(&self) -> Color {
|
||||
let background_color = self.background_color();
|
||||
let is_light_background = luminance(background_color) > 0.5;
|
||||
let new_color = if is_light_background {
|
||||
darken(background_color, 0.08)
|
||||
} else {
|
||||
darken(background_color, 0.4)
|
||||
};
|
||||
|
||||
if !self.loaded() {
|
||||
return new_color;
|
||||
}
|
||||
|
||||
match self.get_color("split-divider-color") {
|
||||
Some(c) => Color::from_rgb8(c.r, c.g, c.b),
|
||||
None => new_color,
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Quick terminal
|
||||
|
||||
#[must_use]
|
||||
pub fn quick_terminal_position(&self) -> QuickTerminalPosition {
|
||||
if !self.loaded() {
|
||||
return QuickTerminalPosition::Top;
|
||||
}
|
||||
self.get_string("quick-terminal-position")
|
||||
.and_then(|s| QuickTerminalPosition::from_raw_value(&s))
|
||||
.unwrap_or(QuickTerminalPosition::Top)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn quick_terminal_screen(&self) -> QuickTerminalScreen {
|
||||
if !self.loaded() {
|
||||
return QuickTerminalScreen::Main;
|
||||
}
|
||||
self.get_string("quick-terminal-screen")
|
||||
.and_then(|s| QuickTerminalScreen::from_ghostty_config(&s))
|
||||
.unwrap_or(QuickTerminalScreen::Main)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn quick_terminal_animation_duration(&self) -> f64 {
|
||||
if !self.loaded() {
|
||||
return 0.2;
|
||||
}
|
||||
let mut v: f64 = 0.2;
|
||||
let _ = self.get_raw("quick-terminal-animation-duration", &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn quick_terminal_auto_hide(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return true;
|
||||
}
|
||||
self.get_bool("quick-terminal-autohide", true)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn quick_terminal_space_behavior(&self) -> QuickTerminalSpaceBehavior {
|
||||
if !self.loaded() {
|
||||
return QuickTerminalSpaceBehavior::Move;
|
||||
}
|
||||
self.get_string("quick-terminal-space-behavior")
|
||||
.and_then(|s| QuickTerminalSpaceBehavior::from_ghostty_config(&s))
|
||||
.unwrap_or(QuickTerminalSpaceBehavior::Move)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn quick_terminal_size(&self) -> QuickTerminalSize {
|
||||
if !self.loaded() {
|
||||
return QuickTerminalSize::default();
|
||||
}
|
||||
// SAFETY: an all-zero value is a valid `none`/`none` size.
|
||||
let mut v: ghostty_config_quick_terminal_size_s = unsafe { std::mem::zeroed() };
|
||||
if !self.get_raw("quick-terminal-size", &mut v) {
|
||||
return QuickTerminalSize::default();
|
||||
}
|
||||
QuickTerminalSize::from_c(&v)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resize_overlay(&self) -> ResizeOverlay {
|
||||
if !self.loaded() {
|
||||
return ResizeOverlay::AfterFirst;
|
||||
}
|
||||
self.get_string("resize-overlay")
|
||||
.and_then(|s| ResizeOverlay::from_raw_value(&s))
|
||||
.unwrap_or(ResizeOverlay::AfterFirst)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resize_overlay_position(&self) -> ResizeOverlayPosition {
|
||||
let default_value = ResizeOverlayPosition::Center;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("resize-overlay-position")
|
||||
.and_then(|s| ResizeOverlayPosition::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resize_overlay_duration(&self) -> usize {
|
||||
if !self.loaded() {
|
||||
return 1000;
|
||||
}
|
||||
let mut v: usize = 0;
|
||||
let _ = self.get_raw("resize-overlay-duration", &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn undo_timeout(&self) -> Duration {
|
||||
if !self.loaded() {
|
||||
return Duration::from_secs(5);
|
||||
}
|
||||
let mut v: usize = 0;
|
||||
let _ = self.get_raw("undo-timeout", &mut v);
|
||||
Duration::from_millis(v as u64)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auto_update(&self) -> Option<AutoUpdate> {
|
||||
if !self.loaded() {
|
||||
return None;
|
||||
}
|
||||
self.get_string("auto-update")
|
||||
.and_then(|s| AutoUpdate::from_raw_value(&s))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auto_update_channel(&self) -> AutoUpdateChannel {
|
||||
let default_value = AutoUpdateChannel::Stable;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("auto-update-channel")
|
||||
.and_then(|s| AutoUpdateChannel::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auto_secure_input(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return true;
|
||||
}
|
||||
self.get_bool("macos-auto-secure-input", false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn secure_input_indication(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return true;
|
||||
}
|
||||
self.get_bool("macos-secure-input-indication", false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_applescript(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return true;
|
||||
}
|
||||
self.get_bool("macos-applescript", false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn maximize(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return true;
|
||||
}
|
||||
self.get_bool("maximize", false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn macos_shortcuts(&self) -> MacShortcuts {
|
||||
let default_value = MacShortcuts::Ask;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("macos-shortcuts")
|
||||
.and_then(|s| MacShortcuts::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn scrollbar(&self) -> Scrollbar {
|
||||
let default_value = Scrollbar::System;
|
||||
if !self.loaded() {
|
||||
return default_value;
|
||||
}
|
||||
self.get_string("scrollbar")
|
||||
.and_then(|s| Scrollbar::from_raw_value(&s))
|
||||
.unwrap_or(default_value)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn command_palette_entries(&self) -> Vec<Command> {
|
||||
if !self.loaded() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut v = ghostty_config_command_list_s {
|
||||
commands: std::ptr::null(),
|
||||
len: 0,
|
||||
};
|
||||
if !self.get_raw("command-palette-entry", &mut v) || v.len == 0 || v.commands.is_null() {
|
||||
return Vec::new();
|
||||
}
|
||||
// SAFETY: the C API guarantees `len` commands at `commands`, valid while the config lives.
|
||||
let buffer = unsafe { std::slice::from_raw_parts(v.commands, v.len) };
|
||||
// SAFETY: the command strings are NUL terminated.
|
||||
buffer
|
||||
.iter()
|
||||
.map(|c| unsafe { Command::from_c(c) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn progress_style(&self) -> bool {
|
||||
if !self.loaded() {
|
||||
return true;
|
||||
}
|
||||
self.get_bool("progress-style", true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The mapping `Ghostty.Config.windowFullscreen` applies to the `fullscreen`
|
||||
/// config string: `None` when disabled (or unknown), otherwise the
|
||||
/// `FullscreenMode` case name (`native`, `nonNative`, `nonNativeVisibleMenu`,
|
||||
/// `nonNativePaddedNotch`).
|
||||
/// TODO(MA3): superseded by `FullscreenMode` once `helpers::fullscreen` exists.
|
||||
#[must_use]
|
||||
pub fn fullscreen_mode_from_config(raw: &str) -> Option<&'static str> {
|
||||
match raw {
|
||||
"true" => Some("native"),
|
||||
"non-native" => Some("nonNative"),
|
||||
"non-native-visible-menu" => Some("nonNativeVisibleMenu"),
|
||||
"non-native-padded-notch" => Some("nonNativePaddedNotch"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `String(cString:)`.
|
||||
fn cstr_to_string(p: *const c_char) -> String {
|
||||
if p.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
// SAFETY: C strings handed out by the library are NUL terminated and live
|
||||
// at least as long as the call.
|
||||
unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `(path as NSString).expandingTildeInPath` for `~` and `~/...`.
|
||||
fn expanding_tilde_in_path(path: &str) -> String {
|
||||
let home = || std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
|
||||
if path == "~" {
|
||||
home()
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
let home = home();
|
||||
format!("{}/{rest}", home.trim_end_matches('/'))
|
||||
} else {
|
||||
path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// `OSColor.luminance`.
|
||||
fn luminance(c: Color) -> f64 {
|
||||
0.299 * c.red + 0.587 * c.green + 0.114 * c.blue
|
||||
}
|
||||
|
||||
/// `OSColor.darken(by:)`: scales HSB brightness by `1 - amount` (hue and
|
||||
/// saturation kept), which is the same as scaling the RGB components.
|
||||
fn darken(c: Color, amount: f64) -> Color {
|
||||
let k = 1.0 - amount;
|
||||
let s = |v: f64| (v * k).min(1.0);
|
||||
Color::new(s(c.red), s(c.green), s(c.blue))
|
||||
}
|
||||
|
||||
// Silence "unused" for the c_int import on some cfgs.
|
||||
const _: Option<c_int> = None;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `TemporaryConfig`: a config loaded from a temporary file.
|
||||
struct TemporaryConfig {
|
||||
config: Config,
|
||||
_file: tempfile::NamedTempFile,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TemporaryConfig {
|
||||
type Target = Config;
|
||||
fn deref(&self) -> &Config {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
impl TemporaryConfig {
|
||||
fn new(text: &str) -> Self {
|
||||
Self::with_finalize(text, true)
|
||||
}
|
||||
|
||||
fn with_finalize(text: &str, finalize: bool) -> Self {
|
||||
let mut file = tempfile::Builder::new()
|
||||
.suffix(".ghostty")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
file.write_all(text.as_bytes()).unwrap();
|
||||
file.flush().unwrap();
|
||||
let cfg = Self::load(file.path().to_str().unwrap(), finalize);
|
||||
Self {
|
||||
config: Config::from_ptr(cfg),
|
||||
_file: file,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Config.loadConfig(at:finalize:)` without the default files / CLI
|
||||
/// args that the test process would otherwise pick up from the host.
|
||||
fn load(path: &str, finalize: bool) -> ghostty_config_t {
|
||||
let path = CString::new(path).unwrap();
|
||||
let cfg = capi::ghostty_config_new();
|
||||
// SAFETY: valid handle and NUL terminated path.
|
||||
unsafe {
|
||||
capi::ghostty_config_load_file(cfg, path.as_ptr());
|
||||
capi::ghostty_config_load_recursive_files(cfg);
|
||||
if finalize {
|
||||
capi::ghostty_config_finalize(cfg);
|
||||
}
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn optional_auto_update_channel(&self) -> Option<AutoUpdateChannel> {
|
||||
self.get_string("auto-update-channel")
|
||||
.and_then(|s| AutoUpdateChannel::from_raw_value(&s))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Boolean Properties
|
||||
|
||||
#[test]
|
||||
fn initial_window_defaults_to_true() {
|
||||
assert!(TemporaryConfig::new("").initial_window());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_window_set_to_false() {
|
||||
assert!(!TemporaryConfig::new("initial-window = false").initial_window());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_after_last_window_closed_defaults_to_false() {
|
||||
// The default is platform dependent in the config (true on Linux,
|
||||
// false on macOS); the Swift test runs on macOS.
|
||||
assert_eq!(
|
||||
TemporaryConfig::new("").should_quit_after_last_window_closed(),
|
||||
!cfg!(target_vendor = "apple")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_after_last_window_closed_set_to_true() {
|
||||
assert!(
|
||||
TemporaryConfig::new("quit-after-last-window-closed = true")
|
||||
.should_quit_after_last_window_closed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_step_resize_defaults_to_false() {
|
||||
assert!(!TemporaryConfig::new("").window_step_resize());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_follows_mouse_defaults_to_false() {
|
||||
assert!(!TemporaryConfig::new("").focus_follows_mouse());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_follows_mouse_set_to_true() {
|
||||
assert!(TemporaryConfig::new("focus-follows-mouse = true").focus_follows_mouse());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_decorations_defaults_to_true() {
|
||||
assert!(TemporaryConfig::new("").window_decorations());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_decorations_none() {
|
||||
assert!(!TemporaryConfig::new("window-decoration = none").window_decorations());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_window_shadow_defaults_to_true() {
|
||||
assert!(TemporaryConfig::new("").macos_window_shadow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximize_defaults_to_false() {
|
||||
assert!(!TemporaryConfig::new("").maximize());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximize_set_to_true() {
|
||||
assert!(TemporaryConfig::new("maximize = true").maximize());
|
||||
}
|
||||
|
||||
// MARK: - String / Optional String Properties
|
||||
|
||||
#[test]
|
||||
fn title_defaults_to_nil() {
|
||||
assert_eq!(TemporaryConfig::new("").title(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_set_to_custom_value() {
|
||||
assert_eq!(
|
||||
TemporaryConfig::new("title = My Terminal")
|
||||
.title()
|
||||
.as_deref(),
|
||||
Some("My Terminal")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_title_font_family_defaults_to_nil() {
|
||||
assert_eq!(TemporaryConfig::new("").window_title_font_family(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_title_font_family_set_to_value() {
|
||||
assert_eq!(
|
||||
TemporaryConfig::new("window-title-font-family = Menlo")
|
||||
.window_title_font_family()
|
||||
.as_deref(),
|
||||
Some("Menlo")
|
||||
);
|
||||
}
|
||||
|
||||
// MARK: - Enum Properties
|
||||
|
||||
#[test]
|
||||
fn macos_titlebar_style_defaults_to_transparent() {
|
||||
assert_eq!(
|
||||
TemporaryConfig::new("").macos_titlebar_style(),
|
||||
MacOSTitlebarStyle::Transparent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_titlebar_style_values() {
|
||||
for (raw, expected) in [
|
||||
("native", MacOSTitlebarStyle::Native),
|
||||
("transparent", MacOSTitlebarStyle::Transparent),
|
||||
("tabs", MacOSTitlebarStyle::Tabs),
|
||||
("hidden", MacOSTitlebarStyle::Hidden),
|
||||
] {
|
||||
let config = TemporaryConfig::new(&format!("macos-titlebar-style = {raw}"));
|
||||
assert_eq!(config.macos_titlebar_style(), expected, "{raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_overlay_defaults_to_after_first() {
|
||||
assert_eq!(
|
||||
TemporaryConfig::new("").resize_overlay(),
|
||||
ResizeOverlay::AfterFirst
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_overlay_values() {
|
||||
for (raw, expected) in [
|
||||
("always", ResizeOverlay::Always),
|
||||
("never", ResizeOverlay::Never),
|
||||
("after-first", ResizeOverlay::AfterFirst),
|
||||
] {
|
||||
let config = TemporaryConfig::new(&format!("resize-overlay = {raw}"));
|
||||
assert_eq!(config.resize_overlay(), expected, "{raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_overlay_position_defaults_to_center() {
|
||||
assert_eq!(
|
||||
TemporaryConfig::new("").resize_overlay_position(),
|
||||
ResizeOverlayPosition::Center
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_icon_defaults_to_official() {
|
||||
assert_eq!(TemporaryConfig::new("").macos_icon(), MacOSIcon::Official);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_icon_frame_defaults_to_aluminum() {
|
||||
assert_eq!(
|
||||
TemporaryConfig::new("").macos_icon_frame(),
|
||||
MacOSIconFrame::Aluminum
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_window_buttons_defaults_to_visible() {
|
||||
assert_eq!(
|
||||
TemporaryConfig::new("").macos_window_buttons(),
|
||||
MacOSWindowButtons::Visible
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_defaults_to_system() {
|
||||
assert_eq!(TemporaryConfig::new("").scrollbar(), Scrollbar::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_set_to_never() {
|
||||
assert_eq!(
|
||||
TemporaryConfig::new("scrollbar = never").scrollbar(),
|
||||
Scrollbar::Never
|
||||
);
|
||||
}
|
||||
|
||||
// MARK: - Numeric Properties
|
||||
|
||||
#[test]
|
||||
fn background_opacity_defaults_to_one() {
|
||||
assert!((TemporaryConfig::new("").background_opacity() - 1.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_opacity_set_to_custom() {
|
||||
let v = TemporaryConfig::new("background-opacity = 0.5").background_opacity();
|
||||
assert!((v - 0.5).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_position_defaults_to_nil() {
|
||||
let config = TemporaryConfig::new("");
|
||||
assert_eq!(config.window_position_x(), None);
|
||||
assert_eq!(config.window_position_y(), None);
|
||||
}
|
||||
|
||||
// MARK: - Config Loading
|
||||
|
||||
#[test]
|
||||
fn loaded_is_true_for_valid_config() {
|
||||
assert!(TemporaryConfig::new("").loaded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfinalized_config_is_loaded() {
|
||||
assert!(TemporaryConfig::with_finalize("", false).loaded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_is_loaded() {
|
||||
let config = TemporaryConfig::new("");
|
||||
assert!(config.optional_auto_update_channel().is_some()); // release or tip
|
||||
let config1 = TemporaryConfig::with_finalize("", false);
|
||||
assert!(config1.optional_auto_update_channel().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_empty_for_valid_config() {
|
||||
assert!(TemporaryConfig::new("").errors().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_reported_for_invalid_config() {
|
||||
assert!(
|
||||
!TemporaryConfig::new("not-a-real-key = value")
|
||||
.errors()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
// MARK: - Multiple Config Lines
|
||||
|
||||
#[test]
|
||||
fn multiple_config_values() {
|
||||
let config = TemporaryConfig::new(
|
||||
"initial-window = false\nquit-after-last-window-closed = true\nmaximize = true\nfocus-follows-mouse = true\n",
|
||||
);
|
||||
assert!(!config.initial_window());
|
||||
assert!(config.should_quit_after_last_window_closed());
|
||||
assert!(config.maximize());
|
||||
assert!(config.focus_follows_mouse());
|
||||
}
|
||||
|
||||
// MARK: - Extra
|
||||
|
||||
#[test]
|
||||
fn config_new_with_path_loads_the_file() {
|
||||
let mut file = tempfile::NamedTempFile::new().unwrap();
|
||||
file.write_all(b"title = From Path\n").unwrap();
|
||||
file.flush().unwrap();
|
||||
let config = Config::new(file.path().to_str(), true);
|
||||
assert!(config.loaded());
|
||||
assert_eq!(config.title().as_deref(), Some("From Path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_config_returns_swift_defaults() {
|
||||
let c = Config::from_ptr(std::ptr::null_mut());
|
||||
assert!(!c.loaded());
|
||||
assert!(c.errors().is_empty());
|
||||
assert!(c.initial_window());
|
||||
assert!(c.should_quit_after_last_window_closed());
|
||||
assert!(c.window_step_resize());
|
||||
assert_eq!(c.window_save_state(), "");
|
||||
assert_eq!(c.macos_dock_drop_behavior(), MacDockDropBehavior::NewTab);
|
||||
assert_eq!(c.notify_on_command_finish_after(), Duration::from_secs(5));
|
||||
assert_eq!(c.undo_timeout(), Duration::from_secs(5));
|
||||
assert!((c.unfocused_split_opacity() - 1.0).abs() < f64::EPSILON);
|
||||
assert_eq!(c.unfocused_split_fill(), Color::WHITE);
|
||||
assert!(c.keyboard_shortcut("quit").is_none());
|
||||
assert_eq!(c.resize_overlay_duration(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_shortcut_for_bound_action() {
|
||||
let config = TemporaryConfig::new("keybind = clear\nkeybind = cmd+shift+y=new_window\n");
|
||||
let s = config.keyboard_shortcut("new_window").expect("bound");
|
||||
assert_eq!(s.key.character, 'y');
|
||||
assert_eq!(
|
||||
s.modifiers,
|
||||
crate::helpers::keyboard_shortcut::EventModifiers::COMMAND
|
||||
| crate::helpers::keyboard_shortcut::EventModifiers::SHIFT
|
||||
);
|
||||
assert!(config.keyboard_shortcut("quit").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bell_features_and_command_finish_flags() {
|
||||
let config = TemporaryConfig::new(
|
||||
"bell-features = system,attention\nnotify-on-command-finish-action = bell,notify\nnotify-on-command-finish-after = 2s\n",
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.bell_features()
|
||||
.contains(BellFeatures::SYSTEM | BellFeatures::ATTENTION)
|
||||
);
|
||||
assert_eq!(
|
||||
config.notify_on_command_finish_action(),
|
||||
NotifyOnCommandFinishAction::BELL | NotifyOnCommandFinishAction::NOTIFY
|
||||
);
|
||||
assert_eq!(
|
||||
config.notify_on_command_finish_after(),
|
||||
Duration::from_secs(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_and_divider() {
|
||||
let config = TemporaryConfig::new("background = #ffffff\nsplit-divider-color = #102030\n");
|
||||
assert_eq!(config.background_color(), Color::WHITE);
|
||||
assert_eq!(
|
||||
config.split_divider_color(),
|
||||
Color::from_rgb8(0x10, 0x20, 0x30)
|
||||
);
|
||||
let derived = TemporaryConfig::new("background = #000000\n");
|
||||
assert_eq!(derived.split_divider_color(), Color::new(0.0, 0.0, 0.0));
|
||||
let light = TemporaryConfig::new("background = #ffffff\n");
|
||||
let d = light.split_divider_color();
|
||||
assert!((d.red - 0.92).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_raw_mapping() {
|
||||
assert_eq!(fullscreen_mode_from_config("true"), Some("native"));
|
||||
assert_eq!(fullscreen_mode_from_config("false"), None);
|
||||
let c = TemporaryConfig::new("");
|
||||
assert_eq!(c.window_fullscreen().as_deref(), Some("false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_expansion() {
|
||||
assert_eq!(expanding_tilde_in_path("/abs"), "/abs");
|
||||
assert!(!expanding_tilde_in_path("~/x").starts_with('~'));
|
||||
}
|
||||
}
|
||||
@@ -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<Self>` and `raw_value()`.
|
||||
|
||||
use bitflags::bitflags;
|
||||
|
||||
/// Declares a Swift `enum Name: String` with explicit raw values.
|
||||
macro_rules! string_enum {
|
||||
($(#[$meta:meta])* $name:ident { $($(#[$vmeta:meta])* $variant:ident = $raw:literal),+ $(,)? }) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum $name {
|
||||
$($(#[$vmeta])* $variant),+
|
||||
}
|
||||
|
||||
impl $name {
|
||||
/// `init?(rawValue:)`.
|
||||
#[must_use]
|
||||
pub fn from_raw_value(raw: &str) -> Option<Self> {
|
||||
match raw {
|
||||
$($raw => Some(Self::$variant),)+
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `rawValue`.
|
||||
#[must_use]
|
||||
pub const fn raw_value(self) -> &'static str {
|
||||
match self {
|
||||
$(Self::$variant => $raw),+
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// A configuration path value that may be optional or required.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConfigPath {
|
||||
pub path: String,
|
||||
pub optional: bool,
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
/// macos-icon
|
||||
MacOSIcon {
|
||||
Official = "official",
|
||||
Blueprint = "blueprint",
|
||||
Chalkboard = "chalkboard",
|
||||
Glass = "glass",
|
||||
Holographic = "holographic",
|
||||
Microchip = "microchip",
|
||||
Paper = "paper",
|
||||
Retro = "retro",
|
||||
Xray = "xray",
|
||||
Custom = "custom",
|
||||
CustomStyle = "custom-style",
|
||||
}
|
||||
}
|
||||
|
||||
impl MacOSIcon {
|
||||
/// Bundled asset name for built-in icons
|
||||
#[must_use]
|
||||
pub const fn asset_name(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Official | Self::Custom | Self::CustomStyle => None,
|
||||
Self::Blueprint => Some("BlueprintImage"),
|
||||
Self::Chalkboard => Some("ChalkboardImage"),
|
||||
Self::Microchip => Some("MicrochipImage"),
|
||||
Self::Glass => Some("GlassImage"),
|
||||
Self::Holographic => Some("HolographicImage"),
|
||||
Self::Paper => Some("PaperImage"),
|
||||
Self::Retro => Some("RetroImage"),
|
||||
Self::Xray => Some("XrayImage"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
/// macos-icon-frame
|
||||
MacOSIconFrame {
|
||||
Aluminum = "aluminum",
|
||||
Beige = "beige",
|
||||
Plastic = "plastic",
|
||||
Chrome = "chrome",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
AutoUpdate {
|
||||
Off = "off",
|
||||
Check = "check",
|
||||
Download = "download",
|
||||
}
|
||||
}
|
||||
|
||||
/// Background blur configuration that maps from the C API values.
|
||||
/// Positive values represent blur radius, special negative values
|
||||
/// represent macOS-specific glass effects.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BackgroundBlur {
|
||||
Disabled,
|
||||
Radius(i64),
|
||||
MacosGlassRegular,
|
||||
MacosGlassClear,
|
||||
}
|
||||
|
||||
impl BackgroundBlur {
|
||||
/// `init(fromCValue:)`. The Swift code gates the glass cases on
|
||||
/// `#available(macOS 26.0, *)` and falls back to `.disabled`;
|
||||
/// `glass_available` is that check (use [`BackgroundBlur::from_c_value`]
|
||||
/// for the runtime one).
|
||||
#[must_use]
|
||||
pub const fn from_c_value_with(value: i16, glass_available: bool) -> Self {
|
||||
match value {
|
||||
0 => Self::Disabled,
|
||||
-1 => {
|
||||
if glass_available {
|
||||
Self::MacosGlassRegular
|
||||
} else {
|
||||
Self::Disabled
|
||||
}
|
||||
}
|
||||
-2 => {
|
||||
if glass_available {
|
||||
Self::MacosGlassClear
|
||||
} else {
|
||||
Self::Disabled
|
||||
}
|
||||
}
|
||||
_ => Self::Radius(value as i64),
|
||||
}
|
||||
}
|
||||
|
||||
/// `init(fromCValue:)` using the running OS version for the glass check.
|
||||
#[must_use]
|
||||
pub fn from_c_value(value: i16) -> Self {
|
||||
Self::from_c_value_with(value, macos_26_available())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn is_enabled(self) -> bool {
|
||||
!matches!(self, Self::Disabled)
|
||||
}
|
||||
|
||||
/// Returns true if this is a macOS glass style (regular or clear).
|
||||
#[must_use]
|
||||
pub const fn is_glass_style(self) -> bool {
|
||||
matches!(self, Self::MacosGlassRegular | Self::MacosGlassClear)
|
||||
}
|
||||
|
||||
/// Returns the blur radius if applicable, nil for glass effects.
|
||||
#[must_use]
|
||||
pub const fn radius(self) -> Option<i64> {
|
||||
match self {
|
||||
Self::Radius(r) => Some(r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#available(macOS 26.0, *)`.
|
||||
#[cfg(target_vendor = "apple")]
|
||||
fn macos_26_available() -> bool {
|
||||
use objc2_foundation::{NSOperatingSystemVersion, NSProcessInfo};
|
||||
NSProcessInfo::processInfo().isOperatingSystemAtLeastVersion(NSOperatingSystemVersion {
|
||||
majorVersion: 26,
|
||||
minorVersion: 0,
|
||||
patchVersion: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
fn macos_26_available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub struct BellFeatures: u32 {
|
||||
const SYSTEM = 1 << 0;
|
||||
const AUDIO = 1 << 1;
|
||||
const ATTENTION = 1 << 2;
|
||||
const TITLE = 1 << 3;
|
||||
const BORDER = 1 << 4;
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub struct SplitPreserveZoom: u32 {
|
||||
const NAVIGATION = 1 << 0;
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
MacDockDropBehavior {
|
||||
NewTab = "new-tab",
|
||||
NewWindow = "new-window",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
MacHidden {
|
||||
Never = "never",
|
||||
Always = "always",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
MacShortcuts {
|
||||
Allow = "allow",
|
||||
Deny = "deny",
|
||||
Ask = "ask",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
Scrollbar {
|
||||
System = "system",
|
||||
Never = "never",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
ResizeOverlay {
|
||||
Always = "always",
|
||||
Never = "never",
|
||||
AfterFirst = "after-first",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
ResizeOverlayPosition {
|
||||
Center = "center",
|
||||
TopLeft = "top-left",
|
||||
TopCenter = "top-center",
|
||||
TopRight = "top-right",
|
||||
BottomLeft = "bottom-left",
|
||||
BottomCenter = "bottom-center",
|
||||
BottomRight = "bottom-right",
|
||||
}
|
||||
}
|
||||
|
||||
impl ResizeOverlayPosition {
|
||||
#[must_use]
|
||||
pub const fn top(self) -> bool {
|
||||
matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn bottom(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::BottomLeft | Self::BottomCenter | Self::BottomRight
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn left(self) -> bool {
|
||||
matches!(self, Self::TopLeft | Self::BottomLeft)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn right(self) -> bool {
|
||||
matches!(self, Self::TopRight | Self::BottomRight)
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
WindowDecoration {
|
||||
None = "none",
|
||||
Client = "client",
|
||||
Server = "server",
|
||||
Auto = "auto",
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowDecoration {
|
||||
#[must_use]
|
||||
pub const fn enabled(self) -> bool {
|
||||
!matches!(self, Self::None)
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
NotifyOnCommandFinish {
|
||||
Never = "never",
|
||||
Unfocused = "unfocused",
|
||||
Always = "always",
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub struct NotifyOnCommandFinishAction: u32 {
|
||||
const BELL = 1 << 0;
|
||||
const NOTIFY = 1 << 1;
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
MacOSTitlebarStyle {
|
||||
Native = "native",
|
||||
Transparent = "transparent",
|
||||
Tabs = "tabs",
|
||||
Hidden = "hidden",
|
||||
}
|
||||
}
|
||||
|
||||
impl MacOSTitlebarStyle {
|
||||
/// `MacOSTitlebarStyle.default`.
|
||||
pub const DEFAULT: Self = Self::Transparent;
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
/// Enum for the macos-window-buttons config option
|
||||
MacOSWindowButtons {
|
||||
Visible = "visible",
|
||||
Hidden = "hidden",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
/// Enum for the macos-titlebar-proxy-icon config option
|
||||
MacOSTitlebarProxyIcon {
|
||||
Visible = "visible",
|
||||
Hidden = "hidden",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
/// Enum for auto-update-channel config option
|
||||
AutoUpdateChannel {
|
||||
Tip = "tip",
|
||||
Stable = "stable",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn raw_values_round_trip() {
|
||||
assert_eq!(
|
||||
ResizeOverlay::from_raw_value("after-first"),
|
||||
Some(ResizeOverlay::AfterFirst)
|
||||
);
|
||||
assert_eq!(ResizeOverlay::AfterFirst.raw_value(), "after-first");
|
||||
assert_eq!(
|
||||
MacOSIcon::from_raw_value("custom-style"),
|
||||
Some(MacOSIcon::CustomStyle)
|
||||
);
|
||||
assert_eq!(MacOSIcon::from_raw_value("nope"), None);
|
||||
assert_eq!(MacOSIcon::Xray.asset_name(), Some("XrayImage"));
|
||||
assert_eq!(MacOSIcon::Official.asset_name(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_blur() {
|
||||
assert_eq!(
|
||||
BackgroundBlur::from_c_value_with(0, true),
|
||||
BackgroundBlur::Disabled
|
||||
);
|
||||
assert_eq!(
|
||||
BackgroundBlur::from_c_value_with(-1, true),
|
||||
BackgroundBlur::MacosGlassRegular
|
||||
);
|
||||
assert_eq!(
|
||||
BackgroundBlur::from_c_value_with(-2, true),
|
||||
BackgroundBlur::MacosGlassClear
|
||||
);
|
||||
assert_eq!(
|
||||
BackgroundBlur::from_c_value_with(-2, false),
|
||||
BackgroundBlur::Disabled
|
||||
);
|
||||
let b = BackgroundBlur::from_c_value_with(20, false);
|
||||
assert_eq!(b.radius(), Some(20));
|
||||
assert!(b.is_enabled() && !b.is_glass_style());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_position_edges() {
|
||||
assert!(ResizeOverlayPosition::TopRight.top() && ResizeOverlayPosition::TopRight.right());
|
||||
assert!(!ResizeOverlayPosition::Center.top());
|
||||
assert!(WindowDecoration::Auto.enabled() && !WindowDecoration::None.enabled());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FullscreenMode> {
|
||||
match raw {
|
||||
GHOSTTY_FULLSCREEN_NATIVE => Some(FullscreenMode::Native),
|
||||
GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE => Some(FullscreenMode::NonNative),
|
||||
GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU => {
|
||||
Some(FullscreenMode::NonNativeVisibleMenu)
|
||||
}
|
||||
GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH => {
|
||||
Some(FullscreenMode::NonNativePaddedNotch)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_all_modes() {
|
||||
assert_eq!(
|
||||
fullscreen_mode_from_ghostty(GHOSTTY_FULLSCREEN_NATIVE),
|
||||
Some(FullscreenMode::Native)
|
||||
);
|
||||
assert_eq!(
|
||||
fullscreen_mode_from_ghostty(GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH),
|
||||
Some(FullscreenMode::NonNativePaddedNotch)
|
||||
);
|
||||
assert_eq!(fullscreen_mode_from_ghostty(-1), None);
|
||||
}
|
||||
}
|
||||
@@ -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<KeyboardShortcut> {
|
||||
let key = match trigger.tag {
|
||||
GHOSTTY_TRIGGER_PHYSICAL => {
|
||||
// Only functional keys can be converted to a KeyboardShortcut. Other physical
|
||||
// mappings cannot because KeyboardShortcut in Swift is inherently layout-dependent.
|
||||
// SAFETY: the tag selects the `physical` member (all members are 32-bit).
|
||||
key_to_equivalent(unsafe { trigger.key.physical })?
|
||||
}
|
||||
|
||||
GHOSTTY_TRIGGER_UNICODE => {
|
||||
// SAFETY: the tag selects the `unicode` member.
|
||||
let scalar = char::from_u32(unsafe { trigger.key.unicode })?;
|
||||
KeyEquivalent::new(scalar)
|
||||
}
|
||||
|
||||
// catch_all matches any key, so it can't be represented as a KeyboardShortcut
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(KeyboardShortcut::new(
|
||||
key,
|
||||
event_modifiers_from_ns_flags(event_modifier_flags(trigger.mods)),
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Mods
|
||||
|
||||
/// Returns the event modifier flags (raw `NSEvent.ModifierFlags`) set for the Ghostty mods enum.
|
||||
#[must_use]
|
||||
pub fn event_modifier_flags(mods: ghostty_input_mods_e) -> u64 {
|
||||
let mut flags = 0;
|
||||
if mods & GHOSTTY_MODS_SHIFT != 0 {
|
||||
flags |= NS_FLAG_SHIFT;
|
||||
}
|
||||
if mods & GHOSTTY_MODS_CTRL != 0 {
|
||||
flags |= NS_FLAG_CONTROL;
|
||||
}
|
||||
if mods & GHOSTTY_MODS_ALT != 0 {
|
||||
flags |= NS_FLAG_OPTION;
|
||||
}
|
||||
if mods & GHOSTTY_MODS_SUPER != 0 {
|
||||
flags |= NS_FLAG_COMMAND;
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
/// Translate event modifier flags (raw `NSEvent.ModifierFlags`) to a ghostty mods enum.
|
||||
#[must_use]
|
||||
pub fn ghostty_mods(flags: u64) -> ghostty_input_mods_e {
|
||||
let mut mods = GHOSTTY_MODS_NONE;
|
||||
|
||||
if flags & NS_FLAG_SHIFT != 0 {
|
||||
mods |= GHOSTTY_MODS_SHIFT;
|
||||
}
|
||||
if flags & NS_FLAG_CONTROL != 0 {
|
||||
mods |= GHOSTTY_MODS_CTRL;
|
||||
}
|
||||
if flags & NS_FLAG_OPTION != 0 {
|
||||
mods |= GHOSTTY_MODS_ALT;
|
||||
}
|
||||
if flags & NS_FLAG_COMMAND != 0 {
|
||||
mods |= GHOSTTY_MODS_SUPER;
|
||||
}
|
||||
if flags & NS_FLAG_CAPS_LOCK != 0 {
|
||||
mods |= GHOSTTY_MODS_CAPS;
|
||||
}
|
||||
|
||||
// Handle sided input. We can't tell that both are pressed in the
|
||||
// Ghostty structure but that's okay -- we don't use that information.
|
||||
if flags & NX_DEVICERSHIFTKEYMASK != 0 {
|
||||
mods |= GHOSTTY_MODS_SHIFT_RIGHT;
|
||||
}
|
||||
if flags & NX_DEVICERCTLKEYMASK != 0 {
|
||||
mods |= GHOSTTY_MODS_CTRL_RIGHT;
|
||||
}
|
||||
if flags & NX_DEVICERALTKEYMASK != 0 {
|
||||
mods |= GHOSTTY_MODS_ALT_RIGHT;
|
||||
}
|
||||
if flags & NX_DEVICERCMDKEYMASK != 0 {
|
||||
mods |= GHOSTTY_MODS_SUPER_RIGHT;
|
||||
}
|
||||
|
||||
mods
|
||||
}
|
||||
|
||||
/// `EventModifiers(nsFlags:)` (`EventModifiers+Extension.swift`) on raw flags.
|
||||
#[must_use]
|
||||
pub fn event_modifiers_from_ns_flags(flags: u64) -> EventModifiers {
|
||||
let mut result = EventModifiers::empty();
|
||||
if flags & NS_FLAG_SHIFT != 0 {
|
||||
result.insert(EventModifiers::SHIFT);
|
||||
}
|
||||
if flags & NS_FLAG_CONTROL != 0 {
|
||||
result.insert(EventModifiers::CONTROL);
|
||||
}
|
||||
if flags & NS_FLAG_OPTION != 0 {
|
||||
result.insert(EventModifiers::OPTION);
|
||||
}
|
||||
if flags & NS_FLAG_COMMAND != 0 {
|
||||
result.insert(EventModifiers::COMMAND);
|
||||
}
|
||||
if flags & NS_FLAG_CAPS_LOCK != 0 {
|
||||
result.insert(EventModifiers::CAPS_LOCK);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// `NSEvent.ModifierFlags(swiftUIFlags:)` (`EventModifiers+Extension.swift`), as raw flags.
|
||||
#[must_use]
|
||||
pub fn ns_flags_from_event_modifiers(flags: EventModifiers) -> u64 {
|
||||
let mut result = 0;
|
||||
if flags.contains(EventModifiers::SHIFT) {
|
||||
result |= NS_FLAG_SHIFT;
|
||||
}
|
||||
if flags.contains(EventModifiers::CONTROL) {
|
||||
result |= NS_FLAG_CONTROL;
|
||||
}
|
||||
if flags.contains(EventModifiers::OPTION) {
|
||||
result |= NS_FLAG_OPTION;
|
||||
}
|
||||
if flags.contains(EventModifiers::COMMAND) {
|
||||
result |= NS_FLAG_COMMAND;
|
||||
}
|
||||
if flags.contains(EventModifiers::CAPS_LOCK) {
|
||||
result |= NS_FLAG_CAPS_LOCK;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// `Ghostty.keyToEquivalent`: a map from the Ghostty key enum to the keyEquivalent string for
|
||||
/// shortcuts. Note that not all ghostty key enum values are represented here because not all of
|
||||
/// them can be mapped to a KeyEquivalent.
|
||||
#[must_use]
|
||||
pub fn key_to_equivalent(key: ghostty_input_key_e) -> Option<KeyEquivalent> {
|
||||
Some(match key {
|
||||
// Function keys
|
||||
GHOSTTY_KEY_ARROW_UP => KeyEquivalent::UP_ARROW,
|
||||
GHOSTTY_KEY_ARROW_DOWN => KeyEquivalent::DOWN_ARROW,
|
||||
GHOSTTY_KEY_ARROW_LEFT => KeyEquivalent::LEFT_ARROW,
|
||||
GHOSTTY_KEY_ARROW_RIGHT => KeyEquivalent::RIGHT_ARROW,
|
||||
GHOSTTY_KEY_HOME => KeyEquivalent::HOME,
|
||||
GHOSTTY_KEY_END => KeyEquivalent::END,
|
||||
GHOSTTY_KEY_DELETE => KeyEquivalent::DELETE,
|
||||
GHOSTTY_KEY_PAGE_UP => KeyEquivalent::PAGE_UP,
|
||||
GHOSTTY_KEY_PAGE_DOWN => KeyEquivalent::PAGE_DOWN,
|
||||
GHOSTTY_KEY_ESCAPE => KeyEquivalent::ESCAPE,
|
||||
GHOSTTY_KEY_ENTER => KeyEquivalent::RETURN,
|
||||
GHOSTTY_KEY_TAB => KeyEquivalent::TAB,
|
||||
GHOSTTY_KEY_BACKSPACE => KeyEquivalent::DELETE,
|
||||
GHOSTTY_KEY_SPACE => KeyEquivalent::SPACE,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: Ghostty.Input.BindingFlags
|
||||
|
||||
bitflags! {
|
||||
/// `ghostty_binding_flags_e`
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub struct BindingFlags: u32 {
|
||||
const CONSUMED = GHOSTTY_BINDING_FLAGS_CONSUMED as u32;
|
||||
const ALL = GHOSTTY_BINDING_FLAGS_ALL as u32;
|
||||
const GLOBAL = GHOSTTY_BINDING_FLAGS_GLOBAL as u32;
|
||||
const PERFORMABLE = GHOSTTY_BINDING_FLAGS_PERFORMABLE as u32;
|
||||
}
|
||||
}
|
||||
|
||||
impl BindingFlags {
|
||||
/// `init(cFlags:)`.
|
||||
#[must_use]
|
||||
pub const fn from_c_flags(c: c_int) -> Self {
|
||||
Self::from_bits_retain(c as u32)
|
||||
}
|
||||
|
||||
/// `cFlags`.
|
||||
#[must_use]
|
||||
pub const fn c_flags(self) -> c_int {
|
||||
self.bits() as c_int
|
||||
}
|
||||
}
|
||||
|
||||
use std::ffi::c_int;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ghostty::capi::ghostty_input_trigger_key_u;
|
||||
|
||||
fn unicode(c: char, mods: c_int) -> ghostty_input_trigger_s {
|
||||
ghostty_input_trigger_s {
|
||||
tag: GHOSTTY_TRIGGER_UNICODE,
|
||||
key: ghostty_input_trigger_key_u { unicode: c as u32 },
|
||||
mods,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_trigger_becomes_shortcut() {
|
||||
let s = keyboard_shortcut(&unicode('q', GHOSTTY_MODS_SUPER)).unwrap();
|
||||
assert_eq!(
|
||||
s,
|
||||
KeyboardShortcut::new(KeyEquivalent::new('q'), EventModifiers::COMMAND)
|
||||
);
|
||||
let s = keyboard_shortcut(&unicode(',', GHOSTTY_MODS_SUPER | GHOSTTY_MODS_SHIFT)).unwrap();
|
||||
assert_eq!(s.modifiers, EventModifiers::COMMAND | EventModifiers::SHIFT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_and_catch_all_triggers() {
|
||||
let t = ghostty_input_trigger_s {
|
||||
tag: GHOSTTY_TRIGGER_PHYSICAL,
|
||||
key: ghostty_input_trigger_key_u {
|
||||
physical: GHOSTTY_KEY_ARROW_LEFT,
|
||||
},
|
||||
mods: GHOSTTY_MODS_ALT,
|
||||
};
|
||||
let s = keyboard_shortcut(&t).unwrap();
|
||||
assert_eq!(s.key, KeyEquivalent::LEFT_ARROW);
|
||||
assert_eq!(s.modifiers, EventModifiers::OPTION);
|
||||
|
||||
// Not a functional key.
|
||||
let t = ghostty_input_trigger_s {
|
||||
key: ghostty_input_trigger_key_u {
|
||||
physical: GHOSTTY_KEY_A,
|
||||
},
|
||||
..t
|
||||
};
|
||||
assert!(keyboard_shortcut(&t).is_none());
|
||||
let t = ghostty_input_trigger_s {
|
||||
tag: GHOSTTY_TRIGGER_CATCH_ALL,
|
||||
..t
|
||||
};
|
||||
assert!(keyboard_shortcut(&t).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_unicode_scalar_is_none() {
|
||||
let t = unicode('a', 0);
|
||||
let t = ghostty_input_trigger_s {
|
||||
key: ghostty_input_trigger_key_u { unicode: 0xD800 },
|
||||
..t
|
||||
};
|
||||
assert!(keyboard_shortcut(&t).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mods_round_trip_and_sides() {
|
||||
let flags = NS_FLAG_SHIFT | NS_FLAG_COMMAND | NS_FLAG_CAPS_LOCK | NX_DEVICERCMDKEYMASK;
|
||||
let m = ghostty_mods(flags);
|
||||
assert_eq!(
|
||||
m,
|
||||
GHOSTTY_MODS_SHIFT | GHOSTTY_MODS_SUPER | GHOSTTY_MODS_CAPS | GHOSTTY_MODS_SUPER_RIGHT
|
||||
);
|
||||
assert_eq!(event_modifier_flags(m), NS_FLAG_SHIFT | NS_FLAG_COMMAND);
|
||||
assert_eq!(
|
||||
ghostty_mods(NX_DEVICERCTLKEYMASK | NX_DEVICERALTKEYMASK | NX_DEVICERSHIFTKEYMASK),
|
||||
GHOSTTY_MODS_CTRL_RIGHT | GHOSTTY_MODS_ALT_RIGHT | GHOSTTY_MODS_SHIFT_RIGHT
|
||||
);
|
||||
assert_eq!(ghostty_mods(0), GHOSTTY_MODS_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_modifiers_conversions() {
|
||||
let m = EventModifiers::SHIFT | EventModifiers::OPTION | EventModifiers::CAPS_LOCK;
|
||||
assert_eq!(
|
||||
event_modifiers_from_ns_flags(ns_flags_from_event_modifiers(m)),
|
||||
m
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_flags() {
|
||||
let f = BindingFlags::from_c_flags(
|
||||
GHOSTTY_BINDING_FLAGS_CONSUMED | GHOSTTY_BINDING_FLAGS_GLOBAL,
|
||||
);
|
||||
assert!(f.contains(BindingFlags::CONSUMED | BindingFlags::GLOBAL));
|
||||
assert!(!f.contains(BindingFlags::PERFORMABLE));
|
||||
assert_eq!(f.c_flags(), 5);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Config>`).
|
||||
pub const GHOSTTY_CONFIG_CHANGE_KEY: &str = GHOSTTY_CONFIG_DID_CHANGE.0;
|
||||
|
||||
/// Color change. Object is the surface changing.
|
||||
pub const GHOSTTY_COLOR_DID_CHANGE: Name = Name("com.mitchellh.ghostty.ghosttyColorDidChange");
|
||||
/// `GhosttyColorChangeKey` (value: `action::ColorChange`).
|
||||
pub const GHOSTTY_COLOR_CHANGE_KEY: &str = GHOSTTY_COLOR_DID_CHANGE.0;
|
||||
|
||||
/// Goto tab. Has tab index in the userinfo.
|
||||
pub const GHOSTTY_MOVE_TAB: Name = Name("com.mitchellh.ghostty.moveTab");
|
||||
/// `GhosttyMoveTabKey` (value: `action::MoveTab`).
|
||||
pub const GHOSTTY_MOVE_TAB_KEY: &str = GHOSTTY_MOVE_TAB.0;
|
||||
|
||||
/// Close tab
|
||||
pub const GHOSTTY_CLOSE_TAB: Name = Name("com.mitchellh.ghostty.closeTab");
|
||||
|
||||
/// Close other tabs
|
||||
pub const GHOSTTY_CLOSE_OTHER_TABS: Name = Name("com.mitchellh.ghostty.closeOtherTabs");
|
||||
|
||||
/// Close tabs to the right of the focused tab
|
||||
pub const GHOSTTY_CLOSE_TABS_ON_THE_RIGHT: Name = Name("com.mitchellh.ghostty.closeTabsOnTheRight");
|
||||
|
||||
/// Close window
|
||||
pub const GHOSTTY_CLOSE_WINDOW: Name = Name("com.mitchellh.ghostty.closeWindow");
|
||||
|
||||
/// Resize the window to a default size.
|
||||
pub const GHOSTTY_RESET_WINDOW_SIZE: Name = Name("com.mitchellh.ghostty.resetWindowSize");
|
||||
|
||||
/// Ring the bell
|
||||
pub const GHOSTTY_BELL_DID_RING: Name = Name("com.mitchellh.ghostty.ghosttyBellDidRing");
|
||||
|
||||
/// Readonly mode changed
|
||||
pub const GHOSTTY_DID_CHANGE_READONLY: Name = Name("com.mitchellh.ghostty.didChangeReadonly");
|
||||
/// `ReadonlyKey` (value: `bool`).
|
||||
pub const READONLY_KEY: &str = "com.mitchellh.ghostty.didChangeReadonly.readonly";
|
||||
pub const GHOSTTY_COMMAND_PALETTE_DID_TOGGLE: Name =
|
||||
Name("com.mitchellh.ghostty.commandPaletteDidToggle");
|
||||
|
||||
/// Toggle maximize of current window
|
||||
pub const GHOSTTY_MAXIMIZE_DID_TOGGLE: Name = Name("com.mitchellh.ghostty.maximizeDidToggle");
|
||||
|
||||
/// Notification sent when scrollbar updates
|
||||
pub const GHOSTTY_DID_UPDATE_SCROLLBAR: Name = Name("com.mitchellh.ghostty.didUpdateScrollbar");
|
||||
/// `ScrollbarKey` (value: `action::Scrollbar`).
|
||||
pub const SCROLLBAR_KEY: &str = "com.mitchellh.ghostty.didUpdateScrollbar.scrollbar";
|
||||
|
||||
/// Focus the search field
|
||||
pub const GHOSTTY_SEARCH_FOCUS: Name = Name("com.mitchellh.ghostty.searchFocus");
|
||||
|
||||
// MARK: Ghostty.Notification
|
||||
|
||||
/// Used to pass a configuration along when creating a new tab/window/split
|
||||
/// (value: `SurfaceConfiguration`).
|
||||
pub const NEW_SURFACE_CONFIG_KEY: &str = "com.mitchellh.ghostty.newSurfaceConfig";
|
||||
|
||||
/// Posted when a new split is requested. The sending object will be the surface that had focus. The
|
||||
/// userdata has one key "direction" with the direction to split to.
|
||||
pub const GHOSTTY_NEW_SPLIT: Name = Name("com.mitchellh.ghostty.newSplit");
|
||||
|
||||
/// Close the calling surface.
|
||||
pub const GHOSTTY_CLOSE_SURFACE: Name = Name("com.mitchellh.ghostty.closeSurface");
|
||||
|
||||
/// Focus previous/next split. Has a SplitFocusDirection in the userinfo.
|
||||
pub const GHOSTTY_FOCUS_SPLIT: Name = Name("com.mitchellh.ghostty.focusSplit");
|
||||
/// `SplitDirectionKey`.
|
||||
pub const SPLIT_DIRECTION_KEY: &str = GHOSTTY_FOCUS_SPLIT.0;
|
||||
|
||||
/// Goto tab. Has tab index in the userinfo.
|
||||
pub const GHOSTTY_GOTO_TAB: Name = Name("com.mitchellh.ghostty.gotoTab");
|
||||
/// `GotoTabKey`.
|
||||
pub const GOTO_TAB_KEY: &str = GHOSTTY_GOTO_TAB.0;
|
||||
|
||||
/// New tab. Has base surface config requested in userinfo.
|
||||
pub const GHOSTTY_NEW_TAB: Name = Name("com.mitchellh.ghostty.newTab");
|
||||
|
||||
/// New window. Has base surface config requested in userinfo.
|
||||
pub const GHOSTTY_NEW_WINDOW: Name = Name("com.mitchellh.ghostty.newWindow");
|
||||
|
||||
/// Present terminal. Bring the surface's window to focus without activating the app.
|
||||
pub const GHOSTTY_PRESENT_TERMINAL: Name = Name("com.mitchellh.ghostty.presentTerminal");
|
||||
|
||||
/// Toggle fullscreen of current window
|
||||
pub const GHOSTTY_TOGGLE_FULLSCREEN: Name = Name("com.mitchellh.ghostty.toggleFullscreen");
|
||||
/// `FullscreenModeKey`.
|
||||
pub const FULLSCREEN_MODE_KEY: &str = GHOSTTY_TOGGLE_FULLSCREEN.0;
|
||||
|
||||
/// Notification sent to toggle split maximize/unmaximize.
|
||||
pub const DID_TOGGLE_SPLIT_ZOOM: Name = Name("com.mitchellh.ghostty.didToggleSplitZoom");
|
||||
|
||||
/// Notification
|
||||
pub const DID_RECEIVE_INITIAL_WINDOW_FRAME: Name =
|
||||
Name("com.mitchellh.ghostty.didReceiveInitialWindowFrame");
|
||||
/// `FrameKey`.
|
||||
pub const FRAME_KEY: &str = "com.mitchellh.ghostty.frame";
|
||||
|
||||
/// Notification to render the inspector for a surface
|
||||
pub const INSPECTOR_NEEDS_DISPLAY: Name = Name("com.mitchellh.ghostty.inspectorNeedsDisplay");
|
||||
|
||||
/// Notification to show/hide the inspector
|
||||
pub const DID_CONTROL_INSPECTOR: Name = Name("com.mitchellh.ghostty.didControlInspector");
|
||||
|
||||
pub const CONFIRM_CLIPBOARD: Name = Name("com.mitchellh.ghostty.confirmClipboard");
|
||||
/// `ConfirmClipboardStrKey` (value: `String`).
|
||||
pub const CONFIRM_CLIPBOARD_STR_KEY: &str = "com.mitchellh.ghostty.confirmClipboard.str";
|
||||
/// `ConfirmClipboardStateKey` (value: the opaque request state pointer as `usize`).
|
||||
pub const CONFIRM_CLIPBOARD_STATE_KEY: &str = "com.mitchellh.ghostty.confirmClipboard.state";
|
||||
/// `ConfirmClipboardRequestKey` (value: `package::ClipboardRequest`).
|
||||
pub const CONFIRM_CLIPBOARD_REQUEST_KEY: &str = "com.mitchellh.ghostty.confirmClipboard.request";
|
||||
|
||||
/// Notification sent to the active split view to resize the split.
|
||||
pub const DID_RESIZE_SPLIT: Name = Name("com.mitchellh.ghostty.didResizeSplit");
|
||||
/// `ResizeSplitDirectionKey`.
|
||||
pub const RESIZE_SPLIT_DIRECTION_KEY: &str = "com.mitchellh.ghostty.didResizeSplit.direction";
|
||||
/// `ResizeSplitAmountKey`.
|
||||
pub const RESIZE_SPLIT_AMOUNT_KEY: &str = "com.mitchellh.ghostty.didResizeSplit.amount";
|
||||
|
||||
/// Notification sent to the split root to equalize split sizes
|
||||
pub const DID_EQUALIZE_SPLITS: Name = Name("com.mitchellh.ghostty.didEqualizeSplits");
|
||||
|
||||
/// Notification that renderer health changed
|
||||
pub const DID_UPDATE_RENDERER_HEALTH: Name = Name("com.mitchellh.ghostty.didUpdateRendererHealth");
|
||||
|
||||
/// Notifications related to key sequences
|
||||
pub const DID_CONTINUE_KEY_SEQUENCE: Name = Name("com.mitchellh.ghostty.didContinueKeySequence");
|
||||
pub const DID_END_KEY_SEQUENCE: Name = Name("com.mitchellh.ghostty.didEndKeySequence");
|
||||
/// `KeySequenceKey`.
|
||||
pub const KEY_SEQUENCE_KEY: &str = "com.mitchellh.ghostty.didContinueKeySequence.key";
|
||||
|
||||
/// Notifications related to key tables
|
||||
pub const DID_CHANGE_KEY_TABLE: Name = Name("com.mitchellh.ghostty.didChangeKeyTable");
|
||||
/// `KeyTableKey`.
|
||||
pub const KEY_TABLE_KEY: &str = "com.mitchellh.ghostty.didChangeKeyTable.action";
|
||||
|
||||
/// `Notification.Name.terminalWindowBellDidChangeNotification` (declared in
|
||||
/// `BaseTerminalController.swift`; MA3 posts it) and its `userInfo` key.
|
||||
pub const TERMINAL_WINDOW_BELL_DID_CHANGE_NOTIFICATION: Name =
|
||||
Name("com.mitchellh.ghostty.terminalWindowBellDidChange");
|
||||
pub const TERMINAL_WINDOW_HAS_BELL_KEY: &str =
|
||||
"com.mitchellh.ghostty.terminalWindowBellDidChange.hasBell";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_match_swift() {
|
||||
assert_eq!(
|
||||
GHOSTTY_CONFIG_DID_CHANGE.raw_value(),
|
||||
"com.mitchellh.ghostty.configDidChange"
|
||||
);
|
||||
assert_eq!(
|
||||
GHOSTTY_CONFIG_CHANGE_KEY,
|
||||
"com.mitchellh.ghostty.configDidChange"
|
||||
);
|
||||
assert_eq!(
|
||||
READONLY_KEY,
|
||||
"com.mitchellh.ghostty.didChangeReadonly.readonly"
|
||||
);
|
||||
assert_eq!(
|
||||
KEY_SEQUENCE_KEY,
|
||||
"com.mitchellh.ghostty.didContinueKeySequence.key"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<NSEventModifierFlags>,
|
||||
) -> ghostty_input_key_s {
|
||||
let modifier_flags = event.modifierFlags();
|
||||
let mut key_ev = ghostty_input_key_s {
|
||||
action,
|
||||
mods: 0,
|
||||
consumed_mods: 0,
|
||||
keycode: u32::from(event.keyCode()),
|
||||
// We can't infer or set these safely from this method. Since text is
|
||||
// a cString, we can't use self.characters because of garbage collection.
|
||||
// We have to let the caller handle this.
|
||||
text: std::ptr::null(),
|
||||
unshifted_codepoint: 0,
|
||||
composing: false,
|
||||
};
|
||||
|
||||
// macOS provides no easy way to determine the consumed modifiers for
|
||||
// producing text. We apply a simple heuristic here that has worked for years
|
||||
// so far: control and command never contribute to the translation of text,
|
||||
// assume everything else did.
|
||||
key_ev.mods = ghostty_mods(modifier_flags.0 as u64);
|
||||
let consumed = translation_mods.unwrap_or(modifier_flags)
|
||||
& !(NSEventModifierFlags::Control | NSEventModifierFlags::Command);
|
||||
key_ev.consumed_mods = ghostty_mods(consumed.0 as u64);
|
||||
|
||||
// Our unshifted codepoint is the codepoint with no modifiers. We
|
||||
// ignore multi-codepoint values. We have to use `byApplyingModifiers`
|
||||
// instead of `charactersIgnoringModifiers` because the latter changes
|
||||
// behavior with ctrl pressed and we don't want any of that.
|
||||
key_ev.unshifted_codepoint = 0;
|
||||
let ty = event.r#type();
|
||||
if ty == NSEventType::KeyDown || ty == NSEventType::KeyUp {
|
||||
let chars = event.charactersByApplyingModifiers(NSEventModifierFlags::empty());
|
||||
if let Some(c) = chars.and_then(|chars| chars.to_string().chars().next()) {
|
||||
key_ev.unshifted_codepoint = c as u32;
|
||||
}
|
||||
}
|
||||
|
||||
key_ev
|
||||
}
|
||||
|
||||
/// Returns the text to set for a key event for Ghostty.
|
||||
///
|
||||
/// This namely contains logic to avoid control characters, since we handle control character
|
||||
/// mapping manually within Ghostty.
|
||||
#[must_use]
|
||||
pub fn ghostty_characters(event: &NSEvent) -> Option<String> {
|
||||
// If we have no characters associated with this event we do nothing.
|
||||
let characters = event.characters()?.to_string();
|
||||
ghostty_characters_for(&characters, || {
|
||||
event
|
||||
.charactersByApplyingModifiers(event.modifierFlags() & !NSEventModifierFlags::Control)
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// The pure part of [`ghostty_characters`]; `without_control` computes
|
||||
/// `characters(byApplyingModifiers: modifierFlags.subtracting(.control))`.
|
||||
fn ghostty_characters_for(
|
||||
characters: &str,
|
||||
without_control: impl FnOnce() -> Option<String>,
|
||||
) -> Option<String> {
|
||||
let mut it = characters.chars();
|
||||
if let (Some(scalar), None) = (it.next(), it.next()) {
|
||||
// If we have a single control character, then we return the characters
|
||||
// without control pressed. We do this because we handle control character
|
||||
// encoding directly within Ghostty's KeyEncoder.
|
||||
if (scalar as u32) < 0x20 {
|
||||
return without_control();
|
||||
}
|
||||
|
||||
// If we have a single value in the PUA, then it's a function key and
|
||||
// we don't want to send PUA ranges down to Ghostty.
|
||||
if (0xF700..=0xF8FF).contains(&(scalar as u32)) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(characters.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Not run on Linux (apple-only module); kept as documentation of intent.
|
||||
#[test]
|
||||
fn characters_rules() {
|
||||
assert_eq!(ghostty_characters_for("a", || None).as_deref(), Some("a"));
|
||||
assert_eq!(
|
||||
ghostty_characters_for("\u{3}", || Some("c".into())).as_deref(),
|
||||
Some("c")
|
||||
);
|
||||
assert_eq!(ghostty_characters_for("\u{F700}", || None), None);
|
||||
assert_eq!(ghostty_characters_for("ab", || None).as_deref(), Some("ab"));
|
||||
}
|
||||
}
|
||||
@@ -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::<u8>(), raw.version_len) };
|
||||
std::str::from_utf8(bytes).map_or_else(|_| "unknown".to_string(), str::to_string)
|
||||
};
|
||||
Info {
|
||||
mode: raw.build_mode,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: General Helpers
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LaunchSource {
|
||||
Cli,
|
||||
App,
|
||||
ZigRun,
|
||||
}
|
||||
|
||||
impl LaunchSource {
|
||||
/// `init?(rawValue:)`.
|
||||
#[must_use]
|
||||
pub fn from_raw_value(raw: &str) -> Option<Self> {
|
||||
match raw {
|
||||
"cli" => Some(Self::Cli),
|
||||
"app" => Some(Self::App),
|
||||
"zig_run" => Some(Self::ZigRun),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure form of [`launch_source`]: `env` is the value of
|
||||
/// `GHOSTTY_MAC_LAUNCH_SOURCE`.
|
||||
#[must_use]
|
||||
pub fn from_env_value(env: Option<&str>) -> Self {
|
||||
// We default to the CLI because the app bundle always sets the
|
||||
// source. If its unset we assume we're in a CLI environment.
|
||||
let Some(value) = env else {
|
||||
return Self::Cli;
|
||||
};
|
||||
|
||||
// If the env var is set but its unknown then we default back to the app.
|
||||
Self::from_raw_value(value).unwrap_or(Self::App)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the mechanism that launched the app. This is based on an env var so
|
||||
/// its up to the env var being set in the correct circumstance.
|
||||
#[must_use]
|
||||
pub fn launch_source() -> LaunchSource {
|
||||
LaunchSource::from_env_value(std::env::var("GHOSTTY_MAC_LAUNCH_SOURCE").ok().as_deref())
|
||||
}
|
||||
|
||||
// MARK: Swift Types for C Types
|
||||
|
||||
/// A `ghostty_string_s` that is freed with `ghostty_string_free` when dropped.
|
||||
pub struct AllocatedString {
|
||||
c_string: ghostty_string_s,
|
||||
}
|
||||
|
||||
impl AllocatedString {
|
||||
#[must_use]
|
||||
pub fn new(c: ghostty_string_s) -> Self {
|
||||
Self { c_string: c }
|
||||
}
|
||||
|
||||
/// The contents; empty if null or not valid UTF-8.
|
||||
#[must_use]
|
||||
pub fn string(&self) -> String {
|
||||
if self.c_string.ptr.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
// SAFETY: `ptr`/`len` describe the allocation owned by this value.
|
||||
let bytes = unsafe {
|
||||
std::slice::from_raw_parts(self.c_string.ptr.cast::<u8>(), self.c_string.len)
|
||||
};
|
||||
String::from_utf8(bytes.to_vec()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AllocatedString {
|
||||
fn drop(&mut self) {
|
||||
ghostty_string_free(self.c_string);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SetFloatWindow {
|
||||
On,
|
||||
Off,
|
||||
Toggle,
|
||||
}
|
||||
|
||||
impl SetFloatWindow {
|
||||
/// `from(_ c: ghostty_action_float_window_e)`.
|
||||
#[must_use]
|
||||
pub fn from_c(c: c_int) -> Option<Self> {
|
||||
match c {
|
||||
GHOSTTY_FLOAT_WINDOW_ON => Some(Self::On),
|
||||
GHOSTTY_FLOAT_WINDOW_OFF => Some(Self::Off),
|
||||
GHOSTTY_FLOAT_WINDOW_TOGGLE => Some(Self::Toggle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SetSecureInput {
|
||||
On,
|
||||
Off,
|
||||
Toggle,
|
||||
}
|
||||
|
||||
impl SetSecureInput {
|
||||
/// `from(_ c: ghostty_action_secure_input_e)`.
|
||||
#[must_use]
|
||||
pub fn from_c(c: c_int) -> Option<Self> {
|
||||
match c {
|
||||
GHOSTTY_SECURE_INPUT_ON => Some(Self::On),
|
||||
GHOSTTY_SECURE_INPUT_OFF => Some(Self::Off),
|
||||
GHOSTTY_SECURE_INPUT_TOGGLE => Some(Self::Toggle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An enum that is used for the directions that a split focus event can change.
|
||||
///
|
||||
/// TODO(MA3): `toSplitTreeFocusDirection` (`SplitTree<ViewType>.FocusDirection`)
|
||||
/// lives with the SplitTree port in `features::splits`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SplitFocusDirection {
|
||||
Previous,
|
||||
Next,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl SplitFocusDirection {
|
||||
/// Initialize from a Ghostty API enum (`ghostty_action_goto_split_e`).
|
||||
#[must_use]
|
||||
pub fn from_direction(direction: c_int) -> Option<Self> {
|
||||
match direction {
|
||||
GHOSTTY_GOTO_SPLIT_PREVIOUS => Some(Self::Previous),
|
||||
GHOSTTY_GOTO_SPLIT_NEXT => Some(Self::Next),
|
||||
GHOSTTY_GOTO_SPLIT_UP => Some(Self::Up),
|
||||
GHOSTTY_GOTO_SPLIT_DOWN => Some(Self::Down),
|
||||
GHOSTTY_GOTO_SPLIT_LEFT => Some(Self::Left),
|
||||
GHOSTTY_GOTO_SPLIT_RIGHT => Some(Self::Right),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn to_native(self) -> c_int {
|
||||
match self {
|
||||
Self::Previous => GHOSTTY_GOTO_SPLIT_PREVIOUS,
|
||||
Self::Next => GHOSTTY_GOTO_SPLIT_NEXT,
|
||||
Self::Up => GHOSTTY_GOTO_SPLIT_UP,
|
||||
Self::Down => GHOSTTY_GOTO_SPLIT_DOWN,
|
||||
Self::Left => GHOSTTY_GOTO_SPLIT_LEFT,
|
||||
Self::Right => GHOSTTY_GOTO_SPLIT_RIGHT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum used for resizing splits. This is the direction the split divider will move.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SplitResizeDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl SplitResizeDirection {
|
||||
/// `from(direction: ghostty_action_resize_split_direction_e)`.
|
||||
#[must_use]
|
||||
pub fn from_direction(direction: c_int) -> Option<Self> {
|
||||
match direction {
|
||||
GHOSTTY_RESIZE_SPLIT_UP => Some(Self::Up),
|
||||
GHOSTTY_RESIZE_SPLIT_DOWN => Some(Self::Down),
|
||||
GHOSTTY_RESIZE_SPLIT_LEFT => Some(Self::Left),
|
||||
GHOSTTY_RESIZE_SPLIT_RIGHT => Some(Self::Right),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn to_native(self) -> c_int {
|
||||
match self {
|
||||
Self::Up => GHOSTTY_RESIZE_SPLIT_UP,
|
||||
Self::Down => GHOSTTY_RESIZE_SPLIT_DOWN,
|
||||
Self::Left => GHOSTTY_RESIZE_SPLIT_LEFT,
|
||||
Self::Right => GHOSTTY_RESIZE_SPLIT_RIGHT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of an `NSPasteboard` (its address). Swift's
|
||||
/// `osc_52_write(OSPasteboard?)` holds the pasteboard object; Ghostty only ever
|
||||
/// uses the two process-lifetime pasteboards (`NSPasteboard.general` and
|
||||
/// `NSPasteboard.ghosttySelection`), so the identity is enough here and the
|
||||
/// AppKit side turns it back into the object. Not dereferenced in this module.
|
||||
pub type PasteboardHandle = usize;
|
||||
|
||||
/// The type of a clipboard request
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ClipboardRequest {
|
||||
/// A direct paste of clipboard contents
|
||||
Paste,
|
||||
|
||||
/// An application is attempting to read from the clipboard using OSC 52
|
||||
Osc52Read,
|
||||
|
||||
/// An application is attempting to write to the clipboard using OSC 52
|
||||
Osc52Write(Option<PasteboardHandle>),
|
||||
}
|
||||
|
||||
impl ClipboardRequest {
|
||||
/// The text to show in the clipboard confirmation prompt for a given request type
|
||||
#[must_use]
|
||||
pub const fn text(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Paste => {
|
||||
"Pasting this text to the terminal may be dangerous as it looks like some commands may be executed."
|
||||
}
|
||||
Self::Osc52Read => {
|
||||
"An application is attempting to read from the clipboard.\nThe current clipboard contents are shown below."
|
||||
}
|
||||
Self::Osc52Write(_) => {
|
||||
"An application is attempting to write to the clipboard.\nThe content to write is shown below."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `from(request: ghostty_clipboard_request_e)`.
|
||||
#[must_use]
|
||||
pub fn from_request(request: c_int) -> Option<Self> {
|
||||
match request {
|
||||
GHOSTTY_CLIPBOARD_REQUEST_PASTE => Some(Self::Paste),
|
||||
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ => Some(Self::Osc52Read),
|
||||
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE => Some(Self::Osc52Write(None)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClipboardContent {
|
||||
pub mime: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
impl ClipboardContent {
|
||||
/// `from(content: ghostty_clipboard_content_s)`.
|
||||
///
|
||||
/// # Safety
|
||||
/// Non-null `mime` / `data` must be NUL terminated strings.
|
||||
#[must_use]
|
||||
pub unsafe fn from_c(content: &ghostty_clipboard_content_s) -> Option<Self> {
|
||||
if content.mime.is_null() || content.data.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: non-null and NUL terminated per the contract.
|
||||
let (mime, data) = unsafe { (CStr::from_ptr(content.mime), CStr::from_ptr(content.data)) };
|
||||
Some(Self {
|
||||
mime: mime.to_string_lossy().into_owned(),
|
||||
data: data.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_source_defaults() {
|
||||
assert_eq!(LaunchSource::from_env_value(None), LaunchSource::Cli);
|
||||
assert_eq!(LaunchSource::from_env_value(Some("app")), LaunchSource::App);
|
||||
assert_eq!(
|
||||
LaunchSource::from_env_value(Some("zig_run")),
|
||||
LaunchSource::ZigRun
|
||||
);
|
||||
assert_eq!(LaunchSource::from_env_value(Some("???")), LaunchSource::App);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_directions_round_trip() {
|
||||
for d in [
|
||||
SplitFocusDirection::Previous,
|
||||
SplitFocusDirection::Next,
|
||||
SplitFocusDirection::Up,
|
||||
SplitFocusDirection::Down,
|
||||
SplitFocusDirection::Left,
|
||||
SplitFocusDirection::Right,
|
||||
] {
|
||||
assert_eq!(SplitFocusDirection::from_direction(d.to_native()), Some(d));
|
||||
}
|
||||
assert_eq!(SplitFocusDirection::from_direction(99), None);
|
||||
for d in [
|
||||
SplitResizeDirection::Up,
|
||||
SplitResizeDirection::Down,
|
||||
SplitResizeDirection::Left,
|
||||
SplitResizeDirection::Right,
|
||||
] {
|
||||
assert_eq!(SplitResizeDirection::from_direction(d.to_native()), Some(d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_request_from_c() {
|
||||
assert_eq!(
|
||||
ClipboardRequest::from_request(GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE),
|
||||
Some(ClipboardRequest::Osc52Write(None))
|
||||
);
|
||||
assert_eq!(ClipboardRequest::from_request(42), None);
|
||||
assert!(ClipboardRequest::Paste.text().contains("dangerous"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_content() {
|
||||
let c = ghostty_clipboard_content_s {
|
||||
mime: c"text/plain".as_ptr(),
|
||||
data: c"hi".as_ptr(),
|
||||
};
|
||||
// SAFETY: static C strings.
|
||||
let content = unsafe { ClipboardContent::from_c(&c) };
|
||||
assert_eq!(
|
||||
content,
|
||||
Some(ClipboardContent {
|
||||
mime: "text/plain".into(),
|
||||
data: "hi".into()
|
||||
})
|
||||
);
|
||||
let n = ghostty_clipboard_content_s {
|
||||
mime: std::ptr::null(),
|
||||
data: c"hi".as_ptr(),
|
||||
};
|
||||
// SAFETY: null is handled.
|
||||
assert!(unsafe { ClipboardContent::from_c(&n) }.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_has_version() {
|
||||
assert!(!info().version.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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\\}"),
|
||||
("<qux>", "\\<qux\\>"),
|
||||
("say\"hi\"", "say\\\"hi\\\""),
|
||||
("it's", "it\\'s"),
|
||||
("`cmd`", "\\`cmd\\`"),
|
||||
("wow!", "wow\\!"),
|
||||
("#comment", "\\#comment"),
|
||||
("$HOME", "\\$HOME"),
|
||||
("a&b", "a\\&b"),
|
||||
("a;b", "a\\;b"),
|
||||
("a|b", "a\\|b"),
|
||||
("*.txt", "\\*.txt"),
|
||||
("file?.log", "file\\?.log"),
|
||||
("col1\tcol2", "col1\\\tcol2"),
|
||||
("$(echo 'hi')", "\\$\\(echo\\ \\'hi\\'\\)"),
|
||||
("/tmp/my file (1).txt", "/tmp/my\\ file\\ \\(1\\).txt"),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(escape(input), expected, "input {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_cases() {
|
||||
let cases = [
|
||||
("", "''"),
|
||||
("filename", "filename"),
|
||||
("abcABC123@%_-+=:,./", "abcABC123@%_-+=:,./"),
|
||||
("file name", "'file name'"),
|
||||
("file$name", "'file$name'"),
|
||||
("file!name", "'file!name'"),
|
||||
("file\\name", "'file\\name'"),
|
||||
("it's", "'it'\"'\"'s'"),
|
||||
("file$'name'", "'file$'\"'\"'name'\"'\"''"),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(quote(input), expected, "input {input:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u32> {
|
||||
let mut flags: ghostty_binding_flags_e = 0;
|
||||
// SAFETY: valid surface and out pointer.
|
||||
if unsafe { ghostty_surface_key_is_binding(self.surface, event, &raw mut flags) } {
|
||||
Some(flags as u32)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the terminal has captured mouse input (mouse reporting).
|
||||
#[must_use]
|
||||
pub fn mouse_captured(&self) -> bool {
|
||||
// SAFETY: valid surface.
|
||||
unsafe { ghostty_surface_mouse_captured(self.surface) }
|
||||
}
|
||||
|
||||
/// `sendMouseButton` with the already lowered C values
|
||||
/// (`event.action.cMouseState`, `event.button.cMouseButton`, `event.mods.cMods`).
|
||||
/// TODO(MA2): typed `Input.MouseButtonEvent` overload.
|
||||
pub fn send_mouse_button(&self, state: i32, button: i32, mods: i32) {
|
||||
// SAFETY: valid surface.
|
||||
unsafe { ghostty_surface_mouse_button(self.surface, state, button, mods) };
|
||||
}
|
||||
|
||||
/// `sendMousePos` (`event.x`, `event.y`, `event.mods.cMods`).
|
||||
/// TODO(MA2): typed `Input.MousePosEvent` overload.
|
||||
pub fn send_mouse_pos(&self, x: f64, y: f64, mods: i32) {
|
||||
// SAFETY: valid surface.
|
||||
unsafe { ghostty_surface_mouse_pos(self.surface, x, y, mods) };
|
||||
}
|
||||
|
||||
/// `sendMouseScroll` (`event.x`, `event.y`, `event.mods.cScrollMods`).
|
||||
/// TODO(MA2): typed `Input.MouseScrollEvent` overload.
|
||||
pub fn send_mouse_scroll(&self, x: f64, y: f64, scroll_mods: i32) {
|
||||
// SAFETY: valid surface.
|
||||
unsafe { ghostty_surface_mouse_scroll(self.surface, x, y, scroll_mods) };
|
||||
}
|
||||
|
||||
/// Perform a keybinding action, e.g. `goto_tab:4`. Returns true if the
|
||||
/// action was performed; invalid actions return false.
|
||||
#[must_use]
|
||||
pub fn perform(&self, action: &str) -> bool {
|
||||
if action.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: valid surface; pointer/length describe `action`.
|
||||
unsafe {
|
||||
ghostty_surface_binding_action(self.surface, action.as_ptr().cast(), action.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Surface {
|
||||
fn drop(&mut self) {
|
||||
// deinit is not guaranteed to happen on the main thread and our API
|
||||
// calls into libghostty must happen there, so the free is scheduled
|
||||
// on the main queue (Swift: `Task.detached { @MainActor in ... }`).
|
||||
let surface = self.surface as usize;
|
||||
DispatchQueue::main().exec_async(move || {
|
||||
// SAFETY: the surface was owned by this wrapper, which is gone.
|
||||
unsafe { ghostty_surface_free(surface as *mut c_void) };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<f32>,
|
||||
/// Explicit working directory to set.
|
||||
pub working_directory: Option<String>,
|
||||
/// Explicit command to set.
|
||||
pub command: Option<String>,
|
||||
/// Environment variables to set for the terminal.
|
||||
pub environment_variables: BTreeMap<String, String>,
|
||||
/// Extra input to send as stdin.
|
||||
pub initial_input: Option<String>,
|
||||
/// Wait after the command.
|
||||
pub wait_after_command: bool,
|
||||
/// Context for surface creation.
|
||||
pub context: ghostty_surface_context_e,
|
||||
}
|
||||
|
||||
impl Default for SurfaceConfiguration {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font_size: None,
|
||||
working_directory: None,
|
||||
command: None,
|
||||
environment_variables: BTreeMap::new(),
|
||||
initial_input: None,
|
||||
wait_after_command: false,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_WINDOW,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an optional C string as UTF-8 (`String(cString:encoding: .utf8)`).
|
||||
///
|
||||
/// # Safety
|
||||
/// `p` must be null or a valid NUL terminated string.
|
||||
unsafe fn opt_string(p: *const c_char) -> Option<String> {
|
||||
if p.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: caller guarantees validity.
|
||||
unsafe { CStr::from_ptr(p) }
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// `withCString` semantics: a Swift string with an embedded NUL is seen by C
|
||||
/// up to the NUL.
|
||||
fn to_cstring(s: &str) -> CString {
|
||||
let end = s.find('\0').unwrap_or(s.len());
|
||||
CString::new(&s.as_bytes()[..end]).unwrap_or_default()
|
||||
}
|
||||
|
||||
impl SurfaceConfiguration {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `init(from: ghostty_surface_config_s)`.
|
||||
///
|
||||
/// # Safety
|
||||
/// The string and env var pointers of `config` must be valid (as
|
||||
/// returned by `ghostty_surface_inherited_config`).
|
||||
#[must_use]
|
||||
pub unsafe fn from_c(config: &ghostty_surface_config_s) -> Self {
|
||||
let mut this = Self {
|
||||
font_size: Some(config.font_size),
|
||||
..Self::default()
|
||||
};
|
||||
// SAFETY: per the function contract.
|
||||
unsafe {
|
||||
this.working_directory = opt_string(config.working_directory);
|
||||
this.command = opt_string(config.command);
|
||||
if config.env_var_count > 0 && !config.env_vars.is_null() {
|
||||
for i in 0..config.env_var_count {
|
||||
let env = *config.env_vars.add(i);
|
||||
if let (Some(k), Some(v)) = (opt_string(env.key), opt_string(env.value)) {
|
||||
this.environment_variables.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.context = config.context;
|
||||
this
|
||||
}
|
||||
|
||||
/// `withCValue(view:_:)`: a C configuration valid only within `body`.
|
||||
/// `view` is the address of the surface view (also the userdata);
|
||||
/// `scale_factor` is `NSScreen.main!.backingScaleFactor`.
|
||||
pub fn with_c_value<T>(
|
||||
&self,
|
||||
view: *mut c_void,
|
||||
scale_factor: f64,
|
||||
body: impl FnOnce(&mut ghostty_surface_config_s) -> T,
|
||||
) -> T {
|
||||
let mut config = ghostty_surface_config_new();
|
||||
config.userdata = view;
|
||||
config.platform_tag = GHOSTTY_PLATFORM_MACOS;
|
||||
config.platform = ghostty_platform_u {
|
||||
macos: ghostty_platform_macos_s { nsview: view },
|
||||
};
|
||||
config.scale_factor = scale_factor;
|
||||
|
||||
// Zero is our default value that means to inherit the font size.
|
||||
config.font_size = self.font_size.unwrap_or(0.0);
|
||||
config.wait_after_command = self.wait_after_command;
|
||||
config.context = self.context;
|
||||
|
||||
// The CStrings stay alive for the whole call, like `withCString`.
|
||||
let working_dir = self.working_directory.as_deref().map(to_cstring);
|
||||
let command = self.command.as_deref().map(to_cstring);
|
||||
let input = self.initial_input.as_deref().map(to_cstring);
|
||||
let ptr = |s: &Option<CString>| s.as_ref().map_or(std::ptr::null(), |s| s.as_ptr());
|
||||
config.working_directory = ptr(&working_dir);
|
||||
config.command = ptr(&command);
|
||||
config.initial_input = ptr(&input);
|
||||
|
||||
let keys: Vec<CString> = self
|
||||
.environment_variables
|
||||
.keys()
|
||||
.map(|k| to_cstring(k))
|
||||
.collect();
|
||||
let values: Vec<CString> = self
|
||||
.environment_variables
|
||||
.values()
|
||||
.map(|v| to_cstring(v))
|
||||
.collect();
|
||||
let mut env_vars: Vec<ghostty_env_var_s> = keys
|
||||
.iter()
|
||||
.zip(&values)
|
||||
.map(|(k, v)| ghostty_env_var_s {
|
||||
key: k.as_ptr(),
|
||||
value: v.as_ptr(),
|
||||
})
|
||||
.collect();
|
||||
config.env_vars = env_vars.as_mut_ptr();
|
||||
config.env_var_count = env_vars.len();
|
||||
body(&mut config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults() {
|
||||
let c = SurfaceConfiguration::new();
|
||||
assert_eq!(c.font_size, None);
|
||||
assert!(!c.wait_after_command);
|
||||
assert_eq!(c.context, GHOSTTY_SURFACE_CONTEXT_WINDOW);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_value_round_trip() {
|
||||
let mut c = SurfaceConfiguration::new();
|
||||
c.font_size = Some(14.0);
|
||||
c.working_directory = Some("/tmp".into());
|
||||
c.command = Some("echo hi".into());
|
||||
c.initial_input = Some("ls\n".into());
|
||||
c.wait_after_command = true;
|
||||
c.environment_variables.insert("A".into(), "1".into());
|
||||
c.environment_variables.insert("B".into(), "2".into());
|
||||
let mut fake = 0u8;
|
||||
let view = std::ptr::addr_of_mut!(fake).cast::<c_void>();
|
||||
let back = c.with_c_value(view, 2.0, |cfg| {
|
||||
assert_eq!(cfg.userdata, view);
|
||||
assert_eq!(cfg.platform_tag, GHOSTTY_PLATFORM_MACOS);
|
||||
assert!((cfg.scale_factor - 2.0).abs() < f64::EPSILON);
|
||||
assert!(cfg.wait_after_command);
|
||||
// SAFETY: pointers valid within the closure.
|
||||
let mut back = unsafe { SurfaceConfiguration::from_c(cfg) };
|
||||
// initial_input / wait_after_command are not read back (as in Swift).
|
||||
back.initial_input = c.initial_input.clone();
|
||||
back.wait_after_command = true;
|
||||
back
|
||||
});
|
||||
assert_eq!(back, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_strings_are_null() {
|
||||
let c = SurfaceConfiguration::new();
|
||||
c.with_c_value(std::ptr::null_mut(), 1.0, |cfg| {
|
||||
assert!(cfg.working_directory.is_null());
|
||||
assert!(cfg.command.is_null());
|
||||
assert!(cfg.initial_input.is_null());
|
||||
assert_eq!(cfg.env_var_count, 0);
|
||||
assert_eq!(cfg.font_size.to_bits(), 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub selected: Published<Option<usize>>,
|
||||
pub total: Published<Option<usize>>,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
/// `init(from startSearch:)`.
|
||||
#[must_use]
|
||||
pub fn new(start_search: &StartSearch) -> Self {
|
||||
Self {
|
||||
needle: Published::new(start_search.needle.clone().unwrap_or_default()),
|
||||
selected: Published::new(None),
|
||||
total: Published::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The instance state of a [`SurfaceView`].
|
||||
pub struct SurfaceViewIvars {
|
||||
/// `id`: unique ID per surface.
|
||||
pub id: Retained<NSUUID>,
|
||||
/// `surfaceModel` (its C value is `surface`).
|
||||
surface_model: RefCell<Option<Surface>>,
|
||||
|
||||
/// `title`: the title as defined by the pty (or set manually).
|
||||
pub title: Published<String>,
|
||||
/// `pwd`.
|
||||
pub pwd: Published<Option<String>>,
|
||||
/// `cellSize`.
|
||||
pub cell_size: Published<NSSize>,
|
||||
/// `healthy` (renderer health).
|
||||
pub healthy: Published<bool>,
|
||||
/// `hoverUrl`.
|
||||
pub hover_url: Published<Option<String>>,
|
||||
/// `progressReport`; setting it (re)starts the 15s expiry timer.
|
||||
pub progress_report: Published<Option<ProgressReport>>,
|
||||
/// `keySequence`.
|
||||
pub key_sequence: Published<Vec<KeyboardShortcut>>,
|
||||
/// `keyTables`.
|
||||
pub key_tables: Published<Vec<String>>,
|
||||
/// `searchState`.
|
||||
pub search_state: Published<Option<Rc<SearchState>>>,
|
||||
/// `pointerStyle`.
|
||||
pub pointer_style: Published<CursorStyle>,
|
||||
/// `cursorVisible`.
|
||||
pub cursor_visible: Published<bool>,
|
||||
/// `bell`.
|
||||
pub bell: Published<bool>,
|
||||
/// `readonly`.
|
||||
pub readonly: Published<bool>,
|
||||
/// `initialSize`.
|
||||
pub initial_size: Cell<Option<NSSize>>,
|
||||
/// `passwordInput`.
|
||||
password_input: Cell<bool>,
|
||||
/// `scrollbar`.
|
||||
pub scrollbar: RefCell<Option<Scrollbar>>,
|
||||
/// `notificationIdentifiers`.
|
||||
pub notification_identifiers: RefCell<HashSet<String>>,
|
||||
/// `focused` (default true, like Swift).
|
||||
focused: Cell<bool>,
|
||||
|
||||
title_change_timer: RefCell<Option<Retained<NSTimer>>>,
|
||||
title_fallback_timer: RefCell<Option<Retained<NSTimer>>>,
|
||||
progress_report_timer: RefCell<Option<Retained<NSTimer>>>,
|
||||
/// `titleFromTerminal`.
|
||||
title_from_terminal: RefCell<Option<String>>,
|
||||
|
||||
// Keeps the `title` observer (which cancels the fallback timer) alive.
|
||||
_title_sub: RefCell<Option<crate::helpers::published::Subscription>>,
|
||||
}
|
||||
|
||||
impl SurfaceViewIvars {
|
||||
fn new(id: Retained<NSUUID>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
surface_model: RefCell::new(None),
|
||||
title: Published::new(String::new()),
|
||||
pwd: Published::new(None),
|
||||
cell_size: Published::new(NSSize::new(0.0, 0.0)),
|
||||
healthy: Published::new(true),
|
||||
hover_url: Published::new(None),
|
||||
progress_report: Published::new(None),
|
||||
key_sequence: Published::new(Vec::new()),
|
||||
key_tables: Published::new(Vec::new()),
|
||||
search_state: Published::new(None),
|
||||
pointer_style: Published::new(CursorStyle::HorizontalText),
|
||||
cursor_visible: Published::new(true),
|
||||
bell: Published::new(false),
|
||||
readonly: Published::new(false),
|
||||
initial_size: Cell::new(None),
|
||||
password_input: Cell::new(false),
|
||||
scrollbar: RefCell::new(None),
|
||||
notification_identifiers: RefCell::new(HashSet::new()),
|
||||
focused: Cell::new(true),
|
||||
title_change_timer: RefCell::new(None),
|
||||
title_fallback_timer: RefCell::new(None),
|
||||
progress_report_timer: RefCell::new(None),
|
||||
title_from_terminal: RefCell::new(None),
|
||||
_title_sub: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
define_class!(
|
||||
/// The NSView implementation for a terminal surface.
|
||||
// SAFETY: NSView has no subclassing requirements we violate; no Drop impl.
|
||||
#[unsafe(super(NSView, NSResponder, NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "GhosttySurfaceView"]
|
||||
#[ivars = SurfaceViewIvars]
|
||||
pub struct SurfaceView;
|
||||
|
||||
unsafe impl NSObjectProtocol for SurfaceView {}
|
||||
|
||||
impl SurfaceView {
|
||||
/// We need to support being a first responder so that we can get input events.
|
||||
#[unsafe(method(acceptsFirstResponder))]
|
||||
fn accepts_first_responder(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(MA2): everything else of SurfaceView_AppKit.swift: input events,
|
||||
// NSTextInputClient, tracking areas, layers, drag & drop, services.
|
||||
);
|
||||
|
||||
impl SurfaceView {
|
||||
/// Create a view without a `ghostty_surface_t` yet.
|
||||
/// TODO(MA2): Swift `init(_ app:baseConfig:uuid:)` (SurfaceView_AppKit.swift:256)
|
||||
/// creates the surface with `ghostty_surface_new` and sets up the layer,
|
||||
/// tracking areas, the app-wide config and the title fallback.
|
||||
#[must_use]
|
||||
pub fn new_model(mtm: MainThreadMarker, uuid: Option<Retained<NSUUID>>) -> Retained<Self> {
|
||||
let id = uuid.unwrap_or_default();
|
||||
let this = Self::alloc(mtm).set_ivars(SurfaceViewIvars::new(id));
|
||||
// SAFETY: `initWithFrame:` is the designated initializer of NSView.
|
||||
let this: Retained<Self> = unsafe {
|
||||
msg_send![super(this), initWithFrame: NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(800.0, 600.0))]
|
||||
};
|
||||
|
||||
// `title` didSet: a non-empty title cancels the fallback timer.
|
||||
let weak = Weak::from_retained(&this);
|
||||
let bound = MainThreadBound::new(weak, mtm);
|
||||
let sub = this.ivars().title.sink(move |title| {
|
||||
if title.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Always on the main thread.
|
||||
let mtm = MainThreadMarker::new().expect("main thread");
|
||||
if let Some(view) = bound.get(mtm).load()
|
||||
&& let Some(t) = view.ivars().title_fallback_timer.borrow_mut().take()
|
||||
{
|
||||
t.invalidate();
|
||||
}
|
||||
});
|
||||
*this.ivars()._title_sub.borrow_mut() = Some(sub);
|
||||
this
|
||||
}
|
||||
|
||||
// MARK: Identity and surface
|
||||
|
||||
/// `id` (the UUID).
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &NSUUID {
|
||||
&self.ivars().id
|
||||
}
|
||||
|
||||
/// `id.uuidString`.
|
||||
#[must_use]
|
||||
pub fn id_string(&self) -> String {
|
||||
self.ivars().id.UUIDString().to_string()
|
||||
}
|
||||
|
||||
/// `surfaceModel = ...` (set once the C surface exists).
|
||||
pub fn set_surface_model(&self, surface: Option<Surface>) {
|
||||
*self.ivars().surface_model.borrow_mut() = surface;
|
||||
}
|
||||
|
||||
/// `surface`: the underlying C value.
|
||||
#[must_use]
|
||||
pub fn surface(&self) -> Option<ghostty_surface_t> {
|
||||
self.ivars()
|
||||
.surface_model
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(Surface::unsafe_c_value)
|
||||
}
|
||||
|
||||
/// `needsConfirmQuit`.
|
||||
#[must_use]
|
||||
pub fn needs_confirm_quit(&self) -> bool {
|
||||
match self.surface() {
|
||||
// SAFETY: valid surface owned by this view.
|
||||
Some(s) => unsafe { ghostty_surface_needs_confirm_quit(s) },
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `processExited`.
|
||||
#[must_use]
|
||||
pub fn process_exited(&self) -> bool {
|
||||
match self.surface() {
|
||||
// SAFETY: valid surface owned by this view.
|
||||
Some(s) => unsafe { ghostty_surface_process_exited(s) },
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Published state
|
||||
|
||||
/// `title`.
|
||||
#[must_use]
|
||||
pub fn title(&self) -> String {
|
||||
self.ivars().title.get()
|
||||
}
|
||||
|
||||
/// `focused`.
|
||||
#[must_use]
|
||||
pub fn focused(&self) -> bool {
|
||||
self.ivars().focused.get()
|
||||
}
|
||||
|
||||
/// TODO(MA2): `focusDidChange(_:)` (SurfaceView_AppKit.swift:~447) is the
|
||||
/// real setter (notifies libghostty, secure input, notification cleanup).
|
||||
pub fn set_focused_model(&self, focused: bool) {
|
||||
self.ivars().focused.set(focused);
|
||||
}
|
||||
|
||||
/// `passwordInput`.
|
||||
#[must_use]
|
||||
pub fn password_input(&self) -> bool {
|
||||
self.ivars().password_input.get()
|
||||
}
|
||||
|
||||
/// `passwordInput = ...`.
|
||||
/// TODO(integration): the Swift `didSet` updates `SecureInput.shared`
|
||||
/// (`setScoped`/`removeScoped`) -- wire once features/secure_input lands (MA4).
|
||||
pub fn set_password_input(&self, value: bool) {
|
||||
self.ivars().password_input.set(value);
|
||||
}
|
||||
|
||||
/// `pwd = ...`.
|
||||
pub fn set_pwd(&self, pwd: Option<String>) {
|
||||
self.ivars().pwd.set(pwd);
|
||||
}
|
||||
|
||||
/// `cellSize = ...`.
|
||||
pub fn set_cell_size(&self, size: NSSize) {
|
||||
self.ivars().cell_size.set(size);
|
||||
}
|
||||
|
||||
/// `initialSize = ...`.
|
||||
pub fn set_initial_size(&self, size: Option<NSSize>) {
|
||||
self.ivars().initial_size.set(size);
|
||||
}
|
||||
|
||||
/// `hoverUrl = ...`.
|
||||
pub fn set_hover_url(&self, url: Option<String>) {
|
||||
self.ivars().hover_url.set(url);
|
||||
}
|
||||
|
||||
/// `progressReport = ...`: also restarts the 15 second expiry timer.
|
||||
pub fn set_progress_report(&self, report: Option<ProgressReport>) {
|
||||
// Cancel any existing timer.
|
||||
if let Some(t) = self.ivars().progress_report_timer.borrow_mut().take() {
|
||||
t.invalidate();
|
||||
}
|
||||
let has_report = report.is_some();
|
||||
self.ivars().progress_report.set(report);
|
||||
|
||||
// If we have a new progress report, start a timer to remove it after 15 seconds.
|
||||
if has_report {
|
||||
let weak = Weak::from_retained(&self.retain());
|
||||
let bound = MainThreadBound::new(weak, self.mtm());
|
||||
let block = RcBlock::new(move |_timer: NonNull<NSTimer>| {
|
||||
let mtm = MainThreadMarker::new().expect("main thread");
|
||||
if let Some(view) = bound.get(mtm).load() {
|
||||
// Like Swift's `self?.progressReport = nil` (which re-runs didSet).
|
||||
view.set_progress_report(None);
|
||||
*view.ivars().progress_report_timer.borrow_mut() = None;
|
||||
}
|
||||
});
|
||||
// SAFETY: the block only runs on the main run loop.
|
||||
let timer = unsafe {
|
||||
NSTimer::scheduledTimerWithTimeInterval_repeats_block(15.0, false, &block)
|
||||
};
|
||||
*self.ivars().progress_report_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/// `searchState = ...`.
|
||||
/// TODO(MA2): SurfaceView_AppKit.swift:72 `didSet` -- the Combine pipeline
|
||||
/// that sends `search:<needle>` (debounced 300ms when 1-2 chars) and the
|
||||
/// `end_search` binding action when cleared.
|
||||
pub fn set_search_state(&self, state: Option<Rc<SearchState>>) {
|
||||
let had = self.ivars().search_state.get().is_some();
|
||||
let clearing = state.is_none() && had;
|
||||
self.ivars().search_state.set(state);
|
||||
if clearing && let Some(surface) = self.ivars().surface_model.borrow().as_ref() {
|
||||
let _ = surface.perform("end_search");
|
||||
}
|
||||
}
|
||||
|
||||
/// `searchState`.
|
||||
#[must_use]
|
||||
pub fn search_state(&self) -> Option<Rc<SearchState>> {
|
||||
self.ivars().search_state.get()
|
||||
}
|
||||
|
||||
// MARK: Cursor
|
||||
|
||||
/// `setCursorShape(_:)`.
|
||||
pub fn set_cursor_shape(&self, shape: c_int) {
|
||||
let style = match shape {
|
||||
GHOSTTY_MOUSE_SHAPE_DEFAULT => CursorStyle::Default,
|
||||
GHOSTTY_MOUSE_SHAPE_TEXT => CursorStyle::HorizontalText,
|
||||
GHOSTTY_MOUSE_SHAPE_GRAB => CursorStyle::GrabIdle,
|
||||
GHOSTTY_MOUSE_SHAPE_GRABBING => CursorStyle::GrabActive,
|
||||
GHOSTTY_MOUSE_SHAPE_POINTER => CursorStyle::Link,
|
||||
GHOSTTY_MOUSE_SHAPE_W_RESIZE => CursorStyle::ResizeLeft,
|
||||
GHOSTTY_MOUSE_SHAPE_E_RESIZE => CursorStyle::ResizeRight,
|
||||
GHOSTTY_MOUSE_SHAPE_N_RESIZE => CursorStyle::ResizeUp,
|
||||
GHOSTTY_MOUSE_SHAPE_S_RESIZE => CursorStyle::ResizeDown,
|
||||
GHOSTTY_MOUSE_SHAPE_NS_RESIZE => CursorStyle::ResizeUpDown,
|
||||
GHOSTTY_MOUSE_SHAPE_EW_RESIZE => CursorStyle::ResizeLeftRight,
|
||||
GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT => CursorStyle::VerticalText,
|
||||
GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU => CursorStyle::ContextMenu,
|
||||
GHOSTTY_MOUSE_SHAPE_CROSSHAIR => CursorStyle::Crosshair,
|
||||
GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED => CursorStyle::OperationNotAllowed,
|
||||
// We ignore unknown shapes.
|
||||
_ => return,
|
||||
};
|
||||
// TODO(MA2): the Swift view applies `pointerStyle` through SwiftUI's
|
||||
// `.backport.pointerStyle` / cursor rects.
|
||||
self.ivars().pointer_style.set(style);
|
||||
}
|
||||
|
||||
/// `setCursorVisibility(_:)`.
|
||||
pub fn set_cursor_visibility(&self, visible: bool) {
|
||||
self.ivars().cursor_visible.set(visible);
|
||||
// Technically this action could be called anytime we want to change the
|
||||
// mouse visibility but at the time of writing this mouse-hide-while-typing
|
||||
// is the only use case so this is the preferred method.
|
||||
NSCursor::setHiddenUntilMouseMoves(!visible);
|
||||
}
|
||||
|
||||
// MARK: Title
|
||||
|
||||
/// `setTitle(_:)`: coalesces rapid changes with a 75 ms timer.
|
||||
pub fn set_title(&self, title: &str) {
|
||||
if let Some(t) = self.ivars().title_change_timer.borrow_mut().take() {
|
||||
t.invalidate();
|
||||
}
|
||||
let weak = Weak::from_retained(&self.retain());
|
||||
let bound = MainThreadBound::new(weak, self.mtm());
|
||||
let title = title.to_owned();
|
||||
let block = RcBlock::new(move |_timer: NonNull<NSTimer>| {
|
||||
let mtm = MainThreadMarker::new().expect("main thread");
|
||||
let Some(view) = bound.get(mtm).load() else {
|
||||
return;
|
||||
};
|
||||
// Set the title if it wasn't manually set.
|
||||
if view.ivars().title_from_terminal.borrow().is_some() {
|
||||
*view.ivars().title_from_terminal.borrow_mut() = Some(title.clone());
|
||||
return;
|
||||
}
|
||||
view.ivars().title.set(title.clone());
|
||||
});
|
||||
// SAFETY: the block only runs on the main run loop.
|
||||
let timer =
|
||||
unsafe { NSTimer::scheduledTimerWithTimeInterval_repeats_block(0.075, false, &block) };
|
||||
*self.ivars().title_change_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
/// `promptTitle()`: set the title by prompting the user.
|
||||
pub fn prompt_title(&self) {
|
||||
let mtm = self.mtm();
|
||||
let alert = NSAlert::new(mtm);
|
||||
alert.setMessageText(&NSString::from_str("Change Terminal Title"));
|
||||
alert.setInformativeText(&NSString::from_str("Leave blank to restore the default."));
|
||||
alert.setAlertStyle(NSAlertStyle::Informational);
|
||||
|
||||
// Add a text field to the alert.
|
||||
let text_field = NSTextField::initWithFrame(
|
||||
NSTextField::alloc(mtm),
|
||||
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(250.0, 24.0)),
|
||||
);
|
||||
text_field.setStringValue(&NSString::from_str(&self.title()));
|
||||
alert.setAccessoryView(Some(&text_field));
|
||||
|
||||
alert.addButtonWithTitle(&NSString::from_str("OK"));
|
||||
alert.addButtonWithTitle(&NSString::from_str("Cancel"));
|
||||
|
||||
// Make the text field the first responder so it gets focus.
|
||||
alert.window().setInitialFirstResponder(Some(&text_field));
|
||||
|
||||
let weak = Weak::from_retained(&self.retain());
|
||||
let field = text_field.clone();
|
||||
let completion = move |response: NSModalResponse| {
|
||||
let Some(this) = weak.load() else { return };
|
||||
|
||||
// Check if the user clicked "OK".
|
||||
if response != NSAlertFirstButtonReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the input text.
|
||||
let new_title = field.stringValue().to_string();
|
||||
if new_title.is_empty() {
|
||||
// Empty means that user wants the title to be set automatically.
|
||||
// We also need to reload the config for the "title" property to be
|
||||
// used again by this tab.
|
||||
let prev_title = this
|
||||
.ivars()
|
||||
.title_from_terminal
|
||||
.borrow_mut()
|
||||
.take()
|
||||
.unwrap_or_else(|| "\u{1F47B}".to_owned());
|
||||
this.set_title(&prev_title);
|
||||
} else {
|
||||
// Set the title and prevent it from being changed automatically.
|
||||
*this.ivars().title_from_terminal.borrow_mut() = Some(this.title());
|
||||
this.ivars().title.set(new_title);
|
||||
}
|
||||
};
|
||||
|
||||
// We prefer to run our alert in a sheet modal if we have a window.
|
||||
if let Some(window) = self.window() {
|
||||
let block = RcBlock::new(completion);
|
||||
alert.beginSheetModalForWindow_completionHandler(&window, Some(&block));
|
||||
} else {
|
||||
// On macOS 26 RC, this codepath results in the "OK" button not being
|
||||
// visible (upstream note); the sheet path is taken most times.
|
||||
completion(alert.runModal());
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: User notifications
|
||||
|
||||
/// `showUserNotification(title:body:requireFocus:)`: show a user
|
||||
/// notification and associate it with this surface.
|
||||
pub fn show_user_notification(&self, title: &str, body: &str, require_focus: bool) {
|
||||
let content = UNMutableNotificationContent::new();
|
||||
content.setTitle(&NSString::from_str(title));
|
||||
content.setSubtitle(&NSString::from_str(&self.title()));
|
||||
content.setBody(&NSString::from_str(body));
|
||||
content.setSound(Some(&UNNotificationSound::defaultSound()));
|
||||
content.setCategoryIdentifier(&NSString::from_str(USER_NOTIFICATION_CATEGORY));
|
||||
|
||||
let surface_key = NSString::from_str("surface");
|
||||
let focus_key = NSString::from_str("requireFocus");
|
||||
let surface_val: Retained<AnyObject> =
|
||||
Retained::into_super(Retained::into_super(NSString::from_str(&self.id_string())));
|
||||
let focus_val: Retained<AnyObject> = Retained::into_super(Retained::into_super(
|
||||
Retained::into_super(NSNumber::new_bool(require_focus)),
|
||||
));
|
||||
let user_info: Retained<NSDictionary<NSString, AnyObject>> =
|
||||
NSDictionary::from_retained_objects(
|
||||
&[&*surface_key, &*focus_key],
|
||||
&[surface_val, focus_val],
|
||||
);
|
||||
// SAFETY: the dictionary holds property list objects.
|
||||
unsafe { content.setUserInfo(&Retained::cast_unchecked::<NSDictionary>(user_info)) };
|
||||
|
||||
let uuid = NSUUID::new().UUIDString().to_string();
|
||||
|
||||
let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
|
||||
&NSString::from_str(&uuid),
|
||||
&content,
|
||||
None,
|
||||
);
|
||||
|
||||
// The completion handler may be executed on a background thread so we
|
||||
// hop to the main queue (Swift: `@MainActor error in`) before touching
|
||||
// view state. `MainThreadBound` is `Send + Sync` and only yields its
|
||||
// value on the main thread.
|
||||
let mtm = self.mtm();
|
||||
let bound = Arc::new(MainThreadBound::new(
|
||||
(Weak::from_retained(&self.retain()), uuid),
|
||||
mtm,
|
||||
));
|
||||
let handler = RcBlock::new(move |err: *mut NSError| {
|
||||
// SAFETY: the error pointer is null or a valid NSError for the call.
|
||||
if let Some(err) = unsafe { err.as_ref() } {
|
||||
error!(
|
||||
"Error scheduling user notification: {}",
|
||||
err.localizedDescription()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let bound = bound.clone();
|
||||
DispatchQueue::main().exec_async(move || {
|
||||
let mtm = MainThreadMarker::new().expect("main thread");
|
||||
let (weak, uuid) = bound.get(mtm);
|
||||
let Some(this) = weak.load() else { return };
|
||||
|
||||
// We need to keep track of this notification so we can remove it
|
||||
// under certain circumstances.
|
||||
this.ivars()
|
||||
.notification_identifiers
|
||||
.borrow_mut()
|
||||
.insert(uuid.clone());
|
||||
|
||||
// If we're focused then we schedule to remove the notification
|
||||
// after a few seconds. If we gain focus we automatically remove it
|
||||
// in focusDidChange.
|
||||
if this.focused() {
|
||||
let inner =
|
||||
MainThreadBound::new((Weak::from_retained(&this), uuid.clone()), mtm);
|
||||
let block = RcBlock::new(move |_t: NonNull<NSTimer>| {
|
||||
let mtm = MainThreadMarker::new().expect("main thread");
|
||||
let (weak, uuid) = inner.get(mtm);
|
||||
if let Some(this) = weak.load() {
|
||||
this.ivars()
|
||||
.notification_identifiers
|
||||
.borrow_mut()
|
||||
.remove(uuid);
|
||||
}
|
||||
let ids = NSArray::from_retained_slice(&[NSString::from_str(uuid)]);
|
||||
UNUserNotificationCenter::currentNotificationCenter()
|
||||
.removeDeliveredNotificationsWithIdentifiers(&ids);
|
||||
});
|
||||
// SAFETY: the block only runs on the main run loop.
|
||||
let _ = unsafe {
|
||||
NSTimer::scheduledTimerWithTimeInterval_repeats_block(3.0, false, &block)
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
UNUserNotificationCenter::currentNotificationCenter()
|
||||
.addNotificationRequest_withCompletionHandler(&request, Some(&handler));
|
||||
}
|
||||
|
||||
/// `handleUserNotification(notification:focus:)`: handle a user
|
||||
/// notification click.
|
||||
pub fn handle_user_notification(&self, notification: &UNNotification, focus: bool) {
|
||||
// SAFETY: plain property getters.
|
||||
let id = notification.request().identifier().to_string();
|
||||
if !self
|
||||
.ivars()
|
||||
.notification_identifiers
|
||||
.borrow_mut()
|
||||
.remove(&id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if focus {
|
||||
if let Some(window) = self.window() {
|
||||
window.makeKeyAndOrderFront(Some(&self.retain()));
|
||||
}
|
||||
move_focus(self, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// `cellSize = convertFromBacking(size)` helper used by `Ghostty.App.setCellSize`.
|
||||
#[must_use]
|
||||
pub fn convert_size_from_backing(&self, size: NSSize) -> NSSize {
|
||||
self.convertSizeFromBacking(size)
|
||||
}
|
||||
|
||||
/// `ProgressReport` removal check used by `Ghostty.App.progressReport`.
|
||||
#[must_use]
|
||||
pub fn is_progress_remove(report: &ProgressReport) -> bool {
|
||||
report.state == ProgressReportState::Remove
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ghostty.moveFocus(to:from:delay:)` (`Surface View/SurfaceView.swift:1150`).
|
||||
///
|
||||
/// Moves first-responder focus to `to`, retrying with a doubling back-off if
|
||||
/// the view is not in a window yet.
|
||||
pub fn move_focus(to: &SurfaceView, from: Option<&SurfaceView>, delay: Option<f64>) {
|
||||
// The whole delay machinery is a bit of a hack to work around a situation
|
||||
// where the window is destroyed and the surface view will never be attached
|
||||
// to a window. Realistically, we should handle this upstream but we also
|
||||
// don't want this function to be a source of infinite loops.
|
||||
let max_delay = 0.5;
|
||||
if delay.unwrap_or(0.0) >= max_delay {
|
||||
return;
|
||||
}
|
||||
|
||||
// We start at a 50 millisecond delay and do a doubling backoff.
|
||||
let next_delay = delay.map_or(0.05, |d| d * 2.0);
|
||||
|
||||
let mtm = to.mtm();
|
||||
let to_weak = Weak::from_retained(&to.retain());
|
||||
let from_weak = from.map(|f| Weak::from_retained(&f.retain()));
|
||||
let bound = MainThreadBound::new((to_weak, from_weak), mtm);
|
||||
|
||||
let work = move || {
|
||||
let mtm = MainThreadMarker::new().expect("main thread");
|
||||
let (to_weak, from_weak) = bound.get(mtm);
|
||||
let Some(to) = to_weak.load() else { return };
|
||||
let from = from_weak.as_ref().and_then(Weak::load);
|
||||
|
||||
// If the callback runs before the surface is attached to a view then the
|
||||
// window will be nil. We just reschedule in that case.
|
||||
let Some(window) = to.window() else {
|
||||
move_focus(&to, from.as_deref(), Some(next_delay));
|
||||
return;
|
||||
};
|
||||
|
||||
// If we had a previously focused node and it's not where we're sending
|
||||
// focus, make sure that we explicitly tell it to lose focus. In theory we
|
||||
// should NOT have to do this but the focus callback isn't getting called
|
||||
// for some reason.
|
||||
if let Some(from) = from {
|
||||
let _ = from.resignFirstResponder();
|
||||
}
|
||||
|
||||
let _ = window.makeFirstResponder(Some(&to));
|
||||
};
|
||||
|
||||
match delay {
|
||||
// The Swift version dispatches async on the main queue (no delay) when
|
||||
// none was given, otherwise asyncAfter.
|
||||
None => DispatchQueue::main().exec_async(work),
|
||||
Some(d) => {
|
||||
let work = std::cell::RefCell::new(Some(work));
|
||||
let block = RcBlock::new(move |_t: NonNull<NSTimer>| {
|
||||
if let Some(w) = work.borrow_mut().take() {
|
||||
w();
|
||||
}
|
||||
});
|
||||
// SAFETY: the block only runs on the main run loop.
|
||||
let _ =
|
||||
unsafe { NSTimer::scheduledTimerWithTimeInterval_repeats_block(d, false, &block) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn(&dyn Any, &dyn Any) -> Ordering>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AnySortKey {
|
||||
value: Rc<dyn Any>,
|
||||
comparator: Comparator,
|
||||
}
|
||||
|
||||
impl AnySortKey {
|
||||
pub fn new<T: Ord + 'static>(value: T) -> Self {
|
||||
Self {
|
||||
value: Rc::new(value),
|
||||
comparator: Rc::new(|lhs, rhs| {
|
||||
let (Some(l), Some(r)) = (lhs.downcast_ref::<T>(), rhs.downcast_ref::<T>()) else {
|
||||
return Ordering::Equal;
|
||||
};
|
||||
l.cmp(r)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn compare(&self, other: &Self) -> Ordering {
|
||||
(self.comparator)(self.value.as_ref(), other.value.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for AnySortKey {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.compare(other) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for AnySortKey {}
|
||||
|
||||
impl PartialOrd for AnySortKey {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for AnySortKey {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.compare(other)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn orders_same_type() {
|
||||
assert!(AnySortKey::new(1) < AnySortKey::new(2));
|
||||
assert!(AnySortKey::new("b".to_string()) > AnySortKey::new("a".to_string()));
|
||||
assert!(AnySortKey::new(3) == AnySortKey::new(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_types_compare_equal() {
|
||||
assert!(AnySortKey::new(1) == AnySortKey::new("x".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_a_heterogeneous_vec_stably() {
|
||||
let mut v = [AnySortKey::new(3), AnySortKey::new(1), AnySortKey::new(2)];
|
||||
v.sort();
|
||||
assert!(v[0] == AnySortKey::new(1) && v[2] == AnySortKey::new(3));
|
||||
}
|
||||
}
|
||||
@@ -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<bool> {
|
||||
use objc2_foundation::{NSBundle, NSNumber, NSString};
|
||||
let info = NSBundle::mainBundle().infoDictionary()?;
|
||||
let value = info.objectForKey(&NSString::from_str(key))?;
|
||||
value.downcast_ref::<NSNumber>().map(NSNumber::boolValue)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_xcode_env() {
|
||||
assert!(is_running_in_xcode_with(
|
||||
|k| k == "__XCODE_BUILT_PRODUCTS_DIR_PATHS"
|
||||
));
|
||||
assert!(!is_running_in_xcode_with(|_| false));
|
||||
}
|
||||
}
|
||||
@@ -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<u8>;
|
||||
fn decode(bytes: &[u8]) -> Option<Self>;
|
||||
}
|
||||
|
||||
/// The key under which the encoded bytes are stored.
|
||||
pub const DATA_KEY: &str = "data";
|
||||
|
||||
/// `CodableBridge.supportsSecureCoding`.
|
||||
pub const SUPPORTS_SECURE_CODING: bool = true;
|
||||
|
||||
/// `CodableBridge(coder:)` failable decode.
|
||||
pub fn decode_from_coder<T: Codable>(coder: &NSCoder) -> Option<T> {
|
||||
// SAFETY: the class passed matches the expected NSData.
|
||||
let data: Retained<NSData> =
|
||||
unsafe { coder.decodeObjectOfClass_forKey(NSData::class(), &NSString::from_str(DATA_KEY)) }
|
||||
.and_then(|o| o.downcast::<NSData>().ok())?;
|
||||
T::decode(&data.to_vec())
|
||||
}
|
||||
|
||||
/// `CodableBridge.encode(with:)`.
|
||||
pub fn encode_to_coder<T: Codable>(value: &T, coder: &NSCoder) {
|
||||
let data = NSData::with_bytes(&value.encode());
|
||||
// SAFETY: NSData is a valid NSCoding object for the key.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
coder.encodeObject_forKey(Some(&data), &NSString::from_str(DATA_KEY));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<CursorCounter> = const { RefCell::new(CursorCounter::new()) };
|
||||
}
|
||||
|
||||
/// This helps manage the stateful nature of NSCursor hiding and unhiding.
|
||||
pub struct Cursor;
|
||||
|
||||
impl Cursor {
|
||||
#[must_use]
|
||||
pub fn is_visible() -> bool {
|
||||
COUNTER.with(|c| c.borrow().is_visible())
|
||||
}
|
||||
|
||||
pub fn hide() {
|
||||
COUNTER.with(|c| c.borrow_mut().hide());
|
||||
NSCursor::hide();
|
||||
}
|
||||
|
||||
/// Unhide the cursor. Returns true if the cursor was previously hidden.
|
||||
pub fn unhide() -> bool {
|
||||
// Its always safe to call unhide when the counter is zero because it
|
||||
// won't go negative.
|
||||
NSCursor::unhide();
|
||||
COUNTER.with(|c| c.borrow_mut().unhide())
|
||||
}
|
||||
|
||||
pub fn unhide_completely() -> u64 {
|
||||
let counter = COUNTER.with(|c| c.borrow().count());
|
||||
for _ in 0..counter {
|
||||
let was_hidden = Self::unhide();
|
||||
debug_assert!(was_hidden);
|
||||
}
|
||||
debug_assert_eq!(COUNTER.with(|c| c.borrow().count()), 0);
|
||||
counter
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CursorStyle {
|
||||
Default,
|
||||
GrabIdle,
|
||||
GrabActive,
|
||||
HorizontalText,
|
||||
VerticalText,
|
||||
Link,
|
||||
ResizeLeft,
|
||||
ResizeRight,
|
||||
ResizeUp,
|
||||
ResizeDown,
|
||||
ResizeUpDown,
|
||||
ResizeLeftRight,
|
||||
ContextMenu,
|
||||
Crosshair,
|
||||
OperationNotAllowed,
|
||||
}
|
||||
|
||||
impl CursorStyle {
|
||||
#[must_use]
|
||||
pub fn cursor(self) -> Retained<NSCursor> {
|
||||
// macOS 15 introduced the directional resize cursors.
|
||||
let new_resize = macos_at_least(15);
|
||||
match self {
|
||||
Self::Default => NSCursor::arrowCursor(),
|
||||
Self::GrabIdle => NSCursor::openHandCursor(),
|
||||
Self::GrabActive => NSCursor::closedHandCursor(),
|
||||
Self::HorizontalText => NSCursor::IBeamCursor(),
|
||||
Self::VerticalText => NSCursor::IBeamCursorForVerticalLayout(),
|
||||
Self::Link => NSCursor::pointingHandCursor(),
|
||||
Self::ResizeLeft if new_resize => {
|
||||
NSCursor::columnResizeCursorInDirections(NSHorizontalDirections::Left)
|
||||
}
|
||||
Self::ResizeLeft => NSCursor::resizeLeftCursor(),
|
||||
Self::ResizeRight if new_resize => {
|
||||
NSCursor::columnResizeCursorInDirections(NSHorizontalDirections::Right)
|
||||
}
|
||||
Self::ResizeRight => NSCursor::resizeRightCursor(),
|
||||
Self::ResizeUp if new_resize => {
|
||||
NSCursor::rowResizeCursorInDirections(NSVerticalDirections::Up)
|
||||
}
|
||||
Self::ResizeUp => NSCursor::resizeUpCursor(),
|
||||
Self::ResizeDown if new_resize => {
|
||||
NSCursor::rowResizeCursorInDirections(NSVerticalDirections::Down)
|
||||
}
|
||||
Self::ResizeDown => NSCursor::resizeDownCursor(),
|
||||
Self::ResizeUpDown if new_resize => NSCursor::rowResizeCursor(),
|
||||
Self::ResizeUpDown => NSCursor::resizeUpDownCursor(),
|
||||
Self::ResizeLeftRight if new_resize => NSCursor::columnResizeCursor(),
|
||||
Self::ResizeLeftRight => NSCursor::resizeLeftRightCursor(),
|
||||
Self::ContextMenu => NSCursor::contextualMenuCursor(),
|
||||
Self::Crosshair => NSCursor::crosshairCursor(),
|
||||
Self::OperationNotAllowed => NSCursor::operationNotAllowedCursor(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>(F);
|
||||
|
||||
// SAFETY: the wrapped closure is only invoked on the main queue. Callers of
|
||||
// `main_async*` are on the main thread (AppKit objects are created there) or
|
||||
// pass only `Send` data; non-`Send` state is never touched off the main thread.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe impl<F> Send for MainOnly<F> {}
|
||||
|
||||
/// `DispatchQueue.main.async { f() }`.
|
||||
pub fn main_async(f: impl FnOnce() + 'static) {
|
||||
let wrapped = MainOnly(f);
|
||||
DispatchQueue::main().exec_async(move || {
|
||||
let wrapped = wrapped;
|
||||
(wrapped.0)();
|
||||
});
|
||||
}
|
||||
|
||||
/// `DispatchQueue.main.asyncAfter(deadline: .now() + delay) { f() }`.
|
||||
pub fn main_async_after(delay: Duration, f: impl FnOnce() + 'static) {
|
||||
let wrapped = MainOnly(f);
|
||||
let when = DispatchTime::try_from(delay).unwrap_or(DispatchTime::FOREVER);
|
||||
let _ = DispatchQueue::main().after(when, move || {
|
||||
let wrapped = wrapped;
|
||||
(wrapped.0)();
|
||||
});
|
||||
}
|
||||
@@ -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<Option<Weak<AnyObject>>>,
|
||||
/// Timer that triggers expiration after the specified duration.
|
||||
timer: RefCell<Option<Retained<NSTimer>>>,
|
||||
/// The undo manager from which to remove actions when this target expires.
|
||||
undo_manager: RefCell<Option<Weak<NSUndoManager>>>,
|
||||
}
|
||||
|
||||
impl Drop for TargetIvars {
|
||||
fn drop(&mut self) {
|
||||
if let Some(timer) = self.timer.get_mut().take() {
|
||||
timer.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
define_class!(
|
||||
/// A target object for ExpiringUndoManager that removes itself from the
|
||||
/// undo manager after it expires.
|
||||
///
|
||||
/// This class acts as a proxy for the real target object in undo
|
||||
/// operations. It holds a weak reference to the actual target and
|
||||
/// automatically removes all associated undo operations when either the
|
||||
/// specified duration expires or `expire()` is called manually.
|
||||
// SAFETY: NSObject has no subclassing requirements; the ivars' Drop is the
|
||||
// only destructor logic.
|
||||
#[unsafe(super(NSObject))]
|
||||
#[name = "GhosttyExpiringTarget"]
|
||||
#[ivars = TargetIvars]
|
||||
struct ExpiringTarget;
|
||||
);
|
||||
|
||||
impl ExpiringTarget {
|
||||
fn new(target: &AnyObject, duration: Duration, undo_manager: &NSUndoManager) -> Retained<Self> {
|
||||
let this = Self::alloc().set_ivars(TargetIvars {
|
||||
target: RefCell::new(Some(Weak::new(target))),
|
||||
timer: RefCell::new(None),
|
||||
undo_manager: RefCell::new(Some(Weak::new(undo_manager))),
|
||||
});
|
||||
// SAFETY: NSObject's init is the designated initializer.
|
||||
let this: Retained<Self> = unsafe { msg_send![super(this), init] };
|
||||
|
||||
let weak = Weak::new(&*this);
|
||||
// SAFETY: the block is a valid `void (^)(NSTimer *)`.
|
||||
let timer = unsafe {
|
||||
NSTimer::scheduledTimerWithTimeInterval_repeats_block(
|
||||
duration.time_interval(),
|
||||
false,
|
||||
&RcBlock::new(move |_timer: NonNull<NSTimer>| {
|
||||
if let Some(this) = weak.load() {
|
||||
this.expire();
|
||||
}
|
||||
}),
|
||||
)
|
||||
};
|
||||
*this.ivars().timer.borrow_mut() = Some(timer);
|
||||
this
|
||||
}
|
||||
|
||||
/// The actual target object, if it is still alive.
|
||||
fn target(&self) -> Option<Retained<AnyObject>> {
|
||||
self.ivars().target.borrow().as_ref().and_then(Weak::load)
|
||||
}
|
||||
|
||||
/// Manually expires the target, removing all associated undo actions and
|
||||
/// invalidating the timer.
|
||||
///
|
||||
/// This method is called automatically when the timer fires, but can also
|
||||
/// be called manually to expire the target before the timer duration has
|
||||
/// elapsed.
|
||||
fn expire(&self) {
|
||||
*self.ivars().target.borrow_mut() = None;
|
||||
let manager = self
|
||||
.ivars()
|
||||
.undo_manager
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.and_then(Weak::load);
|
||||
if let Some(manager) = manager {
|
||||
// SAFETY: `self` is a valid target object.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
manager.removeAllActionsWithTarget(self);
|
||||
}
|
||||
}
|
||||
if let Some(timer) = self.ivars().timer.borrow_mut().take() {
|
||||
timer.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance variables of the manager.
|
||||
#[derive(Default)]
|
||||
pub struct ManagerIvars {
|
||||
/// The set of expiring targets so we can properly clean them up when
|
||||
/// removeAllActions is called with the real target.
|
||||
expiring_targets: RefCell<Vec<Retained<ExpiringTarget>>>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
/// An UndoManager subclass that supports registering undo operations that
|
||||
/// automatically expire after a specified duration.
|
||||
// SAFETY: NSUndoManager may be subclassed; we override only documented
|
||||
// methods and call super; no Drop.
|
||||
#[unsafe(super(NSUndoManager))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "GhosttyExpiringUndoManager"]
|
||||
#[ivars = ManagerIvars]
|
||||
pub struct ExpiringUndoManager;
|
||||
|
||||
impl ExpiringUndoManager {
|
||||
/// Removes all undo and redo operations from the undo manager.
|
||||
///
|
||||
/// This override ensures that all expiring targets are also cleared
|
||||
/// when the undo manager is reset.
|
||||
#[unsafe(method(removeAllActions))]
|
||||
fn remove_all_actions(&self) {
|
||||
// SAFETY: calling the superclass implementation.
|
||||
let _: () = unsafe { msg_send![super(self), removeAllActions] };
|
||||
self.ivars().expiring_targets.borrow_mut().clear();
|
||||
}
|
||||
|
||||
/// Removes all undo and redo operations involving the specified target.
|
||||
///
|
||||
/// This override ensures that when actions are removed for a target,
|
||||
/// any associated expiring targets are also properly cleaned up.
|
||||
#[unsafe(method(removeAllActionsWithTarget:))]
|
||||
fn remove_all_actions_with_target(&self, target: &AnyObject) {
|
||||
// Call super to handle standard removal
|
||||
// SAFETY: calling the superclass implementation.
|
||||
let _: () = unsafe { msg_send![super(self), removeAllActionsWithTarget: target] };
|
||||
|
||||
let target_ptr: *const AnyObject = target;
|
||||
// If the target is an expiring target, remove it.
|
||||
let is_expiring = self
|
||||
.ivars()
|
||||
.expiring_targets
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|e| std::ptr::eq((&**e as *const ExpiringTarget).cast::<AnyObject>(), target_ptr));
|
||||
if is_expiring {
|
||||
self.ivars()
|
||||
.expiring_targets
|
||||
.borrow_mut()
|
||||
.retain(|e| !std::ptr::eq((&**e as *const ExpiringTarget).cast::<AnyObject>(), target_ptr));
|
||||
} else {
|
||||
// Find and remove any ExpiringTarget instances that wrap this target.
|
||||
let matching: Vec<Retained<ExpiringTarget>> = self
|
||||
.ivars()
|
||||
.expiring_targets
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|e| e.target().is_none_or(|t| std::ptr::eq(&*t as *const AnyObject, target_ptr)))
|
||||
.cloned()
|
||||
.collect();
|
||||
for e in matching {
|
||||
// Technically they'll always expire when they get
|
||||
// deinitialized but we want to make sure it happens right now.
|
||||
e.expire();
|
||||
self.ivars().expiring_targets.borrow_mut().retain(|x| !std::ptr::eq(Retained::as_ptr(x), Retained::as_ptr(&e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
impl ExpiringUndoManager {
|
||||
#[must_use]
|
||||
pub fn new(mtm: objc2::MainThreadMarker) -> Retained<Self> {
|
||||
let this = Self::alloc(mtm).set_ivars(ManagerIvars::default());
|
||||
// SAFETY: NSUndoManager's init is the designated initializer.
|
||||
unsafe { msg_send![super(this), init] }
|
||||
}
|
||||
|
||||
/// Registers an undo operation that automatically expires after the
|
||||
/// specified duration.
|
||||
///
|
||||
/// - `target`: the target object for the undo operation. The undo
|
||||
/// operation will be removed if this object is deallocated before the
|
||||
/// operation is invoked.
|
||||
/// - `duration`: the duration after which the undo operation should expire
|
||||
/// and be removed from the undo stack.
|
||||
/// - `handler`: the closure to execute when the undo operation is invoked.
|
||||
/// It receives the target object as its parameter.
|
||||
pub fn register_undo(
|
||||
&self,
|
||||
target: &AnyObject,
|
||||
expires_after: Duration,
|
||||
handler: impl Fn(&AnyObject) + 'static,
|
||||
) {
|
||||
// Ignore instantly expiring undos
|
||||
if expires_after.time_interval() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore when undo registration is disabled. UndoManager still lets
|
||||
// registration happen then cancels later but I was seeing some
|
||||
// weird behavior with this so let's just guard on it.
|
||||
if !self.isUndoRegistrationEnabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let expiring_target = ExpiringTarget::new(target, expires_after, self);
|
||||
self.ivars()
|
||||
.expiring_targets
|
||||
.borrow_mut()
|
||||
.push(expiring_target.clone());
|
||||
|
||||
let weak_self = Weak::new(self);
|
||||
let block = RcBlock::new(move |raw: NonNull<AnyObject>| {
|
||||
// SAFETY: the undo manager passes back the registered target.
|
||||
#[allow(unsafe_code)]
|
||||
let raw = unsafe { raw.as_ref() };
|
||||
if let Some(this) = weak_self.load() {
|
||||
this.ivars().expiring_targets.borrow_mut().retain(|e| {
|
||||
!std::ptr::eq(
|
||||
(&**e as *const ExpiringTarget).cast::<AnyObject>(),
|
||||
raw as *const AnyObject,
|
||||
)
|
||||
});
|
||||
}
|
||||
// SAFETY: `raw` is the ExpiringTarget we registered.
|
||||
#[allow(unsafe_code)]
|
||||
let expiring: &ExpiringTarget =
|
||||
unsafe { &*(raw as *const AnyObject).cast::<ExpiringTarget>() };
|
||||
let Some(target) = expiring.target() else {
|
||||
return;
|
||||
};
|
||||
handler(&target);
|
||||
});
|
||||
// SAFETY: the target is an object and the handler block matches
|
||||
// `void (^)(id)`.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
self.registerUndoWithTarget_handler(&expiring_target, &block);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
//! `Helpers/Extensions/Array+Extension.swift`.
|
||||
|
||||
use std::ffi::CString;
|
||||
|
||||
/// `extension Array`.
|
||||
pub trait ArrayExt<T> {
|
||||
/// `subscript(safe:)`.
|
||||
fn get_safe(&self, index: isize) -> Option<&T>;
|
||||
/// Returns the index before i, with wraparound. Assumes i is a valid index.
|
||||
fn index_wrapping_before(&self, i: usize) -> usize;
|
||||
/// Returns the index after i, with wraparound. Assumes i is a valid index.
|
||||
fn index_wrapping_after(&self, i: usize) -> usize;
|
||||
}
|
||||
|
||||
impl<T> ArrayExt<T> for [T] {
|
||||
fn get_safe(&self, index: isize) -> Option<&T> {
|
||||
usize::try_from(index).ok().and_then(|i| self.get(i))
|
||||
}
|
||||
|
||||
fn index_wrapping_before(&self, i: usize) -> usize {
|
||||
if i == 0 {
|
||||
return self.len() - 1;
|
||||
}
|
||||
i - 1
|
||||
}
|
||||
|
||||
fn index_wrapping_after(&self, i: usize) -> usize {
|
||||
if i == self.len() - 1 {
|
||||
return 0;
|
||||
}
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// `Array<String>.withCStrings`: NUL terminated copies of the strings (the
|
||||
/// returned `CString`s own the memory the pointers of `as_ptr()` refer to).
|
||||
/// A string containing an interior NUL is truncated at it, as a C consumer
|
||||
/// would read it.
|
||||
#[must_use]
|
||||
pub fn to_c_strings<S: AsRef<str>>(strings: &[S]) -> Vec<CString> {
|
||||
strings
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let bytes = s.as_ref().as_bytes();
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
CString::new(&bytes[..end]).unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn safe_subscript() {
|
||||
let a = [1, 2, 3];
|
||||
assert_eq!(a.get_safe(0), Some(&1));
|
||||
assert_eq!(a.get_safe(3), None);
|
||||
assert_eq!(a.get_safe(-1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_indices() {
|
||||
let a = [1, 2, 3];
|
||||
assert_eq!(a.index_wrapping_before(0), 2);
|
||||
assert_eq!(a.index_wrapping_before(2), 1);
|
||||
assert_eq!(a.index_wrapping_after(2), 0);
|
||||
assert_eq!(a.index_wrapping_after(0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_strings() {
|
||||
let c = to_c_strings(&["a", "b\0c", ""]);
|
||||
assert_eq!(c[0].to_str().unwrap(), "a");
|
||||
assert_eq!(c[1].to_str().unwrap(), "b");
|
||||
assert_eq!(c[2].as_bytes().len(), 0);
|
||||
assert_eq!(to_c_strings::<&str>(&[]).len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -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>) -> f64;
|
||||
}
|
||||
|
||||
impl DoubleExt for f64 {
|
||||
fn clamped_to(self, range: RangeInclusive<f64>) -> f64 {
|
||||
self.max(*range.start()).min(*range.end())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clamps() {
|
||||
assert_eq!(5.0.clamped_to(0.0..=1.0), 1.0);
|
||||
assert_eq!((-5.0).clamped_to(0.0..=1.0), 0.0);
|
||||
assert_eq!(0.5.clamped_to(0.0..=1.0), 0.5);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<W: Write + ?Sized> WriteStringExt for W {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn writes_utf8() {
|
||||
let mut v = Vec::new();
|
||||
v.write_string("héllo").unwrap();
|
||||
assert_eq!(v, "héllo".as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Retained<NSAppearance>> {
|
||||
let theme = config.window_theme()?;
|
||||
// SAFETY: reading AppKit's immutable appearance name constants.
|
||||
let (aqua, dark) = unsafe { (NSAppearanceNameAqua, NSAppearanceNameDarkAqua) };
|
||||
match theme.as_str() {
|
||||
"dark" => NSAppearance::appearanceNamed(dark),
|
||||
"light" => NSAppearance::appearanceNamed(aqua),
|
||||
"auto" => {
|
||||
let c = config.background_color();
|
||||
if os_color::is_light_color(c.red, c.green, c.blue) {
|
||||
NSAppearance::appearanceNamed(aqua)
|
||||
} else {
|
||||
NSAppearance::appearanceNamed(dark)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -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<HashMap<usize, u64>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Pure ref counting behind `acquire/releasePresentationOption`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OptionCounts(HashMap<usize, u64>);
|
||||
|
||||
/// What the caller must do to the app's presentation options.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum OptionChange {
|
||||
Insert,
|
||||
Remove,
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl OptionCounts {
|
||||
/// `acquirePresentationOption`: always (re)inserts the option.
|
||||
pub fn acquire(&mut self, option: usize) -> OptionChange {
|
||||
*self.0.entry(option).or_insert(0) += 1;
|
||||
OptionChange::Insert
|
||||
}
|
||||
|
||||
/// `releasePresentationOption`: removes the option when the last
|
||||
/// reference is released.
|
||||
pub fn release(&mut self, option: usize) -> OptionChange {
|
||||
let Some(&value) = self.0.get(&option) else {
|
||||
return OptionChange::Nothing;
|
||||
};
|
||||
if value == 0 {
|
||||
return OptionChange::Nothing;
|
||||
}
|
||||
if value == 1 {
|
||||
self.0.remove(&option);
|
||||
OptionChange::Remove
|
||||
} else {
|
||||
self.0.insert(option, value - 1);
|
||||
OptionChange::Nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NSApplicationExt {
|
||||
/// Add a presentation option to the application and maintain a reference
|
||||
/// count so that an equal number of releases is required to disable it.
|
||||
/// This is useful so that multiple classes can affect global app state
|
||||
/// without overriding others.
|
||||
fn acquire_presentation_option(&self, option: NSApplicationPresentationOptions);
|
||||
|
||||
/// See `acquire_presentation_option`.
|
||||
fn release_presentation_option(&self, option: NSApplicationPresentationOptions);
|
||||
|
||||
/// True if the application is frontmost. This isn't exactly the same as
|
||||
/// isActive because an app can be active but not be frontmost if the
|
||||
/// window with activity is an NSPanel.
|
||||
fn is_frontmost(&self) -> bool;
|
||||
}
|
||||
|
||||
impl NSApplicationExt for NSApplication {
|
||||
fn acquire_presentation_option(&self, option: NSApplicationPresentationOptions) {
|
||||
PRESENTATION_OPTION_COUNTS.with(|c| {
|
||||
let mut counts = OptionCounts(std::mem::take(&mut *c.borrow_mut()));
|
||||
let change = counts.acquire(option.0);
|
||||
*c.borrow_mut() = counts.0;
|
||||
debug_assert_eq!(change, OptionChange::Insert);
|
||||
});
|
||||
self.setPresentationOptions(self.presentationOptions() | option);
|
||||
}
|
||||
|
||||
fn release_presentation_option(&self, option: NSApplicationPresentationOptions) {
|
||||
let change = PRESENTATION_OPTION_COUNTS.with(|c| {
|
||||
let mut counts = OptionCounts(std::mem::take(&mut *c.borrow_mut()));
|
||||
let change = counts.release(option.0);
|
||||
*c.borrow_mut() = counts.0;
|
||||
change
|
||||
});
|
||||
if change == OptionChange::Remove {
|
||||
self.setPresentationOptions(self.presentationOptions() - option);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_frontmost(&self) -> bool {
|
||||
let front: Option<_> = NSWorkspace::sharedWorkspace().frontmostApplication();
|
||||
let front_id =
|
||||
front.and_then(|a: objc2::rc::Retained<NSRunningApplication>| a.bundleIdentifier());
|
||||
let main_id = NSBundle::mainBundle().bundleIdentifier();
|
||||
front_id.map(|s| s.to_string()) == main_id.map(|s| s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ref_counts() {
|
||||
let mut c = OptionCounts::default();
|
||||
assert_eq!(c.release(1), OptionChange::Nothing);
|
||||
assert_eq!(c.acquire(1), OptionChange::Insert);
|
||||
assert_eq!(c.acquire(1), OptionChange::Insert);
|
||||
assert_eq!(c.release(1), OptionChange::Nothing);
|
||||
assert_eq!(c.release(1), OptionChange::Remove);
|
||||
assert_eq!(c.release(1), OptionChange::Nothing);
|
||||
}
|
||||
}
|
||||
@@ -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<Retained<NSColorList>> =
|
||||
NSColorList::colorListNamed(&NSString::from_str("Apple"));
|
||||
}
|
||||
|
||||
/// `NSColor(named:)`: a color from the "Apple" color list by (capitalized) name.
|
||||
#[must_use]
|
||||
pub fn color_named(name: &str) -> Option<Retained<NSColor>> {
|
||||
let color = APPLE_COLOR_LIST.with(|list| {
|
||||
let list = list.as_ref()?;
|
||||
list.colorWithKey(&NSString::from_str(&capitalized(name)))
|
||||
})?;
|
||||
let components = color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
|
||||
Some(NSColor::colorWithRed_green_blue_alpha(
|
||||
components.redComponent(),
|
||||
components.greenComponent(),
|
||||
components.blueComponent(),
|
||||
components.alphaComponent(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Swift's `String.capitalized`: first letter of each word upper, rest lower.
|
||||
fn capitalized(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut start = true;
|
||||
for c in s.chars() {
|
||||
if c.is_alphanumeric() {
|
||||
if start {
|
||||
out.extend(c.to_uppercase());
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
start = false;
|
||||
} else {
|
||||
out.push(c);
|
||||
start = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `NSColor.colorNames`.
|
||||
#[must_use]
|
||||
pub fn color_names() -> Vec<String> {
|
||||
APPLE_COLOR_LIST.with(|list| {
|
||||
list.as_ref()
|
||||
.map(|l| {
|
||||
l.allKeys()
|
||||
.iter()
|
||||
.map(|k| k.to_string().to_lowercase())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// `OSColor(hex:)`.
|
||||
#[must_use]
|
||||
pub fn color_from_hex(hex: &str) -> Option<Retained<NSColor>> {
|
||||
let (r, g, b, a) = os_color::parse_hex(hex)?;
|
||||
Some(NSColor::colorWithRed_green_blue_alpha(r, g, b, a))
|
||||
}
|
||||
|
||||
/// `OSColor(ghostty: ghostty_config_color_s)` from the 8-bit components.
|
||||
#[must_use]
|
||||
pub fn color_from_ghostty(r: u8, g: u8, b: u8) -> Retained<NSColor> {
|
||||
let (r, g, b) = os_color::from_ghostty(r, g, b);
|
||||
NSColor::colorWithRed_green_blue_alpha(r, g, b, 1.0)
|
||||
}
|
||||
|
||||
pub trait NSColorExt {
|
||||
/// Returns a new color with its saturation multiplied by the given
|
||||
/// factor, clamped to [0, 1].
|
||||
fn adjusting_saturation(&self, factor: f64) -> Retained<NSColor>;
|
||||
/// Calculates the perceptual distance to another color in RGB space.
|
||||
fn distance_to(&self, other: &NSColor) -> f64;
|
||||
/// `OSColor.luminance`.
|
||||
fn luminance(&self) -> f64;
|
||||
/// `OSColor.isLightColor`.
|
||||
fn is_light_color(&self) -> bool;
|
||||
/// `OSColor.hexString`.
|
||||
fn hex_string(&self) -> Option<String>;
|
||||
/// `OSColor.darken(by:)`.
|
||||
fn darken(&self, amount: f64) -> Retained<NSColor>;
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn hsb(color: &NSColor) -> (f64, f64, f64, f64) {
|
||||
let (mut h, mut s, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
|
||||
// SAFETY: the out-pointers are valid for writes.
|
||||
unsafe { color.getHue_saturation_brightness_alpha(&mut h, &mut s, &mut b, &mut a) };
|
||||
(h, s, b, a)
|
||||
}
|
||||
|
||||
fn srgb(color: &NSColor) -> Option<Retained<NSColor>> {
|
||||
color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())
|
||||
}
|
||||
|
||||
impl NSColorExt for NSColor {
|
||||
fn adjusting_saturation(&self, factor: f64) -> Retained<NSColor> {
|
||||
let hsb_color = srgb(self).unwrap_or_else(|| self.retain());
|
||||
let (h, s, b, a) = hsb(&hsb_color);
|
||||
NSColor::colorWithHue_saturation_brightness_alpha(
|
||||
h,
|
||||
os_color::adjusted_saturation(s, factor),
|
||||
b,
|
||||
a,
|
||||
)
|
||||
}
|
||||
|
||||
fn distance_to(&self, other: &NSColor) -> f64 {
|
||||
let (Some(a), Some(b)) = (srgb(self), srgb(other)) else {
|
||||
return f64::INFINITY;
|
||||
};
|
||||
os_color::distance(
|
||||
(a.redComponent(), a.greenComponent(), a.blueComponent()),
|
||||
(b.redComponent(), b.greenComponent(), b.blueComponent()),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn luminance(&self) -> f64 {
|
||||
// getRed:green:blue:alpha requires sRGB space
|
||||
let Some(rgb) = srgb(self) else { return 0.0 };
|
||||
os_color::luminance(
|
||||
rgb.redComponent(),
|
||||
rgb.greenComponent(),
|
||||
rgb.blueComponent(),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_light_color(&self) -> bool {
|
||||
self.luminance() > 0.5
|
||||
}
|
||||
|
||||
fn hex_string(&self) -> Option<String> {
|
||||
let rgb = self.colorUsingColorSpace(&NSColorSpace::deviceRGBColorSpace())?;
|
||||
Some(os_color::hex_string(
|
||||
rgb.redComponent(),
|
||||
rgb.greenComponent(),
|
||||
rgb.blueComponent(),
|
||||
))
|
||||
}
|
||||
|
||||
fn darken(&self, amount: f64) -> Retained<NSColor> {
|
||||
let (h, s, b, a) = hsb(self);
|
||||
NSColor::colorWithHue_saturation_brightness_alpha(
|
||||
h,
|
||||
s,
|
||||
os_color::darkened_brightness(b, amount),
|
||||
a,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::capitalized;
|
||||
|
||||
#[test]
|
||||
fn capitalized_matches_swift() {
|
||||
assert_eq!(capitalized("red"), "Red");
|
||||
assert_eq!(capitalized("DARK GRAY"), "Dark Gray");
|
||||
assert_eq!(capitalized("a-b"), "A-B");
|
||||
}
|
||||
}
|
||||
@@ -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<Retained<objc2_core_graphics::CGImage>> {
|
||||
// SAFETY: nil rect/context/hints are allowed.
|
||||
unsafe { image.CGImageForProposedRect_context_hints(std::ptr::null_mut(), None, None) }
|
||||
}
|
||||
|
||||
pub trait NSImageExt {
|
||||
/// Apply a gradient onto this image, using this image as a mask.
|
||||
fn gradient(&self, colors: &[Retained<NSColor>]) -> Option<Retained<NSImage>>;
|
||||
/// Tint an NSImage with the given color by applying a basic fill on top of it.
|
||||
fn tint(&self, color: &NSColor) -> Option<Retained<NSImage>>;
|
||||
}
|
||||
|
||||
/// `NSImage.combine(images:blendingModes:)`: combine multiple images with the
|
||||
/// given blend modes. This is useful given a set of layers to create a final
|
||||
/// rasterized image.
|
||||
#[allow(unsafe_code)]
|
||||
#[must_use]
|
||||
pub fn combine(
|
||||
images: &[Retained<NSImage>],
|
||||
blending_modes: &[CGBlendMode],
|
||||
) -> Option<Retained<NSImage>> {
|
||||
if images.len() != blending_modes.len() || images.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The final size will be the same size as our first image.
|
||||
let size = images[0].size();
|
||||
|
||||
// Create a bitmap context manually
|
||||
let space = CGColorSpace::new_device_rgb();
|
||||
// SAFETY: null data lets CoreGraphics allocate the backing store.
|
||||
let ctx = unsafe {
|
||||
CGBitmapContextCreate(
|
||||
std::ptr::null_mut(),
|
||||
size.width as usize,
|
||||
size.height as usize,
|
||||
8,
|
||||
0,
|
||||
space.as_deref(),
|
||||
CGImageAlphaInfo::PremultipliedLast.0,
|
||||
)
|
||||
}?;
|
||||
|
||||
// Clear the context
|
||||
CGContext::set_fill_color_with_color(Some(&ctx), None);
|
||||
CGContext::clear_rect(Some(&ctx), zero_rect(size));
|
||||
|
||||
// Draw each image with its corresponding blend mode
|
||||
for (image, mode) in images.iter().zip(blending_modes) {
|
||||
let cg = cg_image(image)?;
|
||||
CGContext::set_blend_mode(Some(&ctx), *mode);
|
||||
CGContext::draw_image(Some(&ctx), zero_rect(size), Some(&cg));
|
||||
}
|
||||
|
||||
// Create a CGImage from the context
|
||||
let combined = CGBitmapContextCreateImage(Some(&ctx))?;
|
||||
|
||||
// Wrap the CGImage in an NSImage
|
||||
Some(NSImage::initWithCGImage_size(
|
||||
NSImage::alloc(),
|
||||
&combined,
|
||||
size,
|
||||
))
|
||||
}
|
||||
|
||||
impl NSImageExt for NSImage {
|
||||
fn gradient(&self, colors: &[Retained<NSColor>]) -> Option<Retained<NSImage>> {
|
||||
let size = self.size();
|
||||
let result = NSImage::initWithSize(NSImage::alloc(), size);
|
||||
result.lockFocus();
|
||||
let out = (|| {
|
||||
// Draw the gradient
|
||||
let array = NSArray::from_retained_slice(colors);
|
||||
let gradient = NSGradient::initWithColors(NSGradient::alloc(), &array)?;
|
||||
gradient.drawInRect_angle(zero_rect(size), 90.0);
|
||||
|
||||
// Apply the mask
|
||||
self.drawAtPoint_fromRect_operation_fraction(
|
||||
NSPoint::new(0.0, 0.0),
|
||||
NSRect::ZERO,
|
||||
NSCompositingOperation::DestinationIn,
|
||||
1.0,
|
||||
);
|
||||
Some(())
|
||||
})();
|
||||
result.unlockFocus();
|
||||
out.map(|()| result)
|
||||
}
|
||||
|
||||
fn tint(&self, color: &NSColor) -> Option<Retained<NSImage>> {
|
||||
let size = self.size();
|
||||
|
||||
// Create a new image with the same size as the base image
|
||||
let new_image = NSImage::initWithSize(NSImage::alloc(), size);
|
||||
|
||||
// Draw into the new image
|
||||
new_image.lockFocus();
|
||||
let out = (|| {
|
||||
// Set up the drawing context
|
||||
let context = NSGraphicsContext::currentContext()?.CGContext();
|
||||
|
||||
// Draw the base image
|
||||
let cg = cg_image(self)?;
|
||||
CGContext::draw_image(Some(&context), zero_rect(size), Some(&cg));
|
||||
|
||||
// Set the tint color and blend mode
|
||||
CGContext::set_fill_color_with_color(Some(&context), Some(&color.CGColor()));
|
||||
CGContext::set_blend_mode(Some(&context), CGBlendMode::SourceAtop);
|
||||
|
||||
// Apply the tint color over the entire image
|
||||
CGContext::fill_rect(Some(&context), zero_rect(size));
|
||||
Some(context)
|
||||
})();
|
||||
if let Some(context) = &out {
|
||||
CGContext::restore_g_state(Some(context));
|
||||
}
|
||||
new_image.unlockFocus();
|
||||
out.map(|_| new_image)
|
||||
}
|
||||
}
|
||||
@@ -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<usize>;
|
||||
|
||||
/// Removes all menu items whose identifier is in the given set.
|
||||
fn remove_items_with_identifiers(&self, identifiers: &HashSet<String>);
|
||||
}
|
||||
|
||||
impl NSMenuExt for NSMenu {
|
||||
fn insert_item_after(&self, item: &NSMenuItem, action: Sel) -> Option<usize> {
|
||||
if let Some(identifier) = item.identifier() {
|
||||
let existing = self
|
||||
.itemArray()
|
||||
.iter()
|
||||
.find(|i| i.identifier().is_some_and(|id| id == identifier));
|
||||
if let Some(existing) = existing {
|
||||
self.removeItem(&existing);
|
||||
}
|
||||
}
|
||||
|
||||
let idx = self
|
||||
.itemArray()
|
||||
.iter()
|
||||
.position(|i| i.action() == Some(action))?;
|
||||
|
||||
let insertion_index = idx + 1;
|
||||
self.insertItem_atIndex(item, insertion_index as isize);
|
||||
Some(insertion_index)
|
||||
}
|
||||
|
||||
fn remove_items_with_identifiers(&self, identifiers: &HashSet<String>) {
|
||||
let items = self.itemArray();
|
||||
for (index, item) in items.to_vec().into_iter().enumerate().rev() {
|
||||
let Some(identifier) = item.identifier() else {
|
||||
continue;
|
||||
};
|
||||
if identifiers.contains(&identifier.to_string()) {
|
||||
self.removeItemAtIndex(index as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Retained<NSImage>> =
|
||||
NSImage::imageWithSystemSymbolName_accessibilityDescription(
|
||||
&NSString::from_str(system_symbol_name),
|
||||
Some(&title),
|
||||
);
|
||||
self.setImage(image.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NSNotificationCenter>,
|
||||
token: Retained<ProtocolObject<dyn NSObjectProtocol>>,
|
||||
}
|
||||
|
||||
impl NsObserver {
|
||||
/// Keep observing for the life of the process.
|
||||
pub fn forget(self) {
|
||||
std::mem::forget(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NsObserver {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `token` was returned by addObserverForName on this center.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
self.center.removeObserver(self.token.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NotificationCenter.default.addObserver(forName:object:queue:nil)`:
|
||||
/// `f` runs synchronously on the posting thread.
|
||||
pub fn observe(
|
||||
name: &NSNotificationName,
|
||||
object: Option<&AnyObject>,
|
||||
f: impl Fn(&NSNotification) + 'static,
|
||||
) -> NsObserver {
|
||||
let center = NSNotificationCenter::defaultCenter();
|
||||
let block = RcBlock::new(move |n: NonNull<NSNotification>| {
|
||||
// SAFETY: the notification is valid for the duration of the callback.
|
||||
#[allow(unsafe_code)]
|
||||
f(unsafe { n.as_ref() });
|
||||
});
|
||||
// SAFETY: the block is a valid `void (^)(NSNotification *)`.
|
||||
#[allow(unsafe_code)]
|
||||
let token = unsafe {
|
||||
center.addObserverForName_object_queue_usingBlock(Some(name), object, None, &block)
|
||||
};
|
||||
NsObserver { center, token }
|
||||
}
|
||||
|
||||
/// `NSWindow.didBecomeKeyNotification`.
|
||||
#[must_use]
|
||||
pub fn name_window_did_become_key() -> &'static NSNotificationName {
|
||||
// SAFETY: reading AppKit's immutable notification name constant.
|
||||
unsafe { objc2_app_kit::NSWindowDidBecomeKeyNotification }
|
||||
}
|
||||
@@ -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<NSString> {
|
||||
// Explicit mappings for common MIME types
|
||||
if mime_type == "text/plain" {
|
||||
// SAFETY: reading AppKit's immutable string constant.
|
||||
#[allow(unsafe_code)]
|
||||
return unsafe { NSPasteboardTypeString }.copy();
|
||||
}
|
||||
|
||||
// Try to get UTType from MIME type; otherwise use the MIME type directly.
|
||||
match uti::identifier_for_mime_type(mime_type) {
|
||||
Some(identifier) => NSString::from_str(&identifier),
|
||||
None => NSString::from_str(mime_type),
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// `NSPasteboard.ghosttySelection`.
|
||||
static GHOSTTY_SELECTION: Retained<NSPasteboard> =
|
||||
NSPasteboard::pasteboardWithName(&NSString::from_str("com.mitchellh.ghostty.selection"));
|
||||
}
|
||||
|
||||
/// The pasteboard to used for Ghostty selection.
|
||||
#[must_use]
|
||||
pub fn ghostty_selection() -> Retained<NSPasteboard> {
|
||||
GHOSTTY_SELECTION.with(Clone::clone)
|
||||
}
|
||||
|
||||
/// `NSPasteboard.ghostty(_:)`: the pasteboard for the Ghostty enum type.
|
||||
#[must_use]
|
||||
pub fn ghostty(clipboard: c_int) -> Option<Retained<NSPasteboard>> {
|
||||
match clipboard {
|
||||
GHOSTTY_CLIPBOARD_STANDARD => Some(NSPasteboard::generalPasteboard()),
|
||||
GHOSTTY_CLIPBOARD_SELECTION => Some(ghostty_selection()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NSPasteboardExt {
|
||||
/// Gets the contents of the pasteboard as a string following a specific
|
||||
/// set of semantics. Does these things in order:
|
||||
/// - Tries to get the absolute filesystem path of the file in the
|
||||
/// pasteboard if there is one and ensures the file path is properly escaped.
|
||||
/// - Tries to get any string from the pasteboard.
|
||||
///
|
||||
/// If all of the above fail, returns None.
|
||||
fn get_opinionated_string_contents(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
impl NSPasteboardExt for NSPasteboard {
|
||||
#[allow(unsafe_code)]
|
||||
fn get_opinionated_string_contents(&self) -> Option<String> {
|
||||
let classes = NSArray::from_slice(&[NSURL::class()]);
|
||||
// SAFETY: `readObjectsForClasses:options:` takes an array of classes.
|
||||
let objects: Option<Retained<NSArray<AnyObject>>> = unsafe {
|
||||
msg_send![self, readObjectsForClasses: &*classes, options: std::ptr::null::<AnyObject>()]
|
||||
};
|
||||
if let Some(objects) = objects {
|
||||
let urls: Vec<String> = objects
|
||||
.iter()
|
||||
.filter_map(|o| o.downcast::<NSURL>().ok())
|
||||
.map(|url| {
|
||||
if url.isFileURL() {
|
||||
url.path()
|
||||
.map(|p| shell::escape(&p.to_string()))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
url.absoluteString()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !urls.is_empty() {
|
||||
return Some(urls.join(" "));
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: reading AppKit's immutable string constant.
|
||||
self.stringForType(unsafe { NSPasteboardTypeString })
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use objc2_app_kit::{NSPasteboardTypeHTML, NSPasteboardTypePNG};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Test text/plain MIME type converts to .string
|
||||
#[test]
|
||||
fn text_plain_mime_type() {
|
||||
// SAFETY: reading AppKit constants.
|
||||
#[allow(unsafe_code)]
|
||||
let expected = unsafe { NSPasteboardTypeString }.to_string();
|
||||
assert_eq!(
|
||||
pasteboard_type_from_mime("text/plain").to_string(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
/// Test text/html MIME type converts to .html
|
||||
#[test]
|
||||
fn text_html_mime_type() {
|
||||
#[allow(unsafe_code)]
|
||||
let expected = unsafe { NSPasteboardTypeHTML }.to_string();
|
||||
assert_eq!(pasteboard_type_from_mime("text/html").to_string(), expected);
|
||||
}
|
||||
|
||||
/// Test image/png MIME type
|
||||
#[test]
|
||||
fn image_png_mime_type() {
|
||||
#[allow(unsafe_code)]
|
||||
let expected = unsafe { NSPasteboardTypePNG }.to_string();
|
||||
assert_eq!(pasteboard_type_from_mime("image/png").to_string(), expected);
|
||||
}
|
||||
}
|
||||
@@ -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<u32>;
|
||||
/// The stable UUID for this display, suitable for tracking across
|
||||
/// reconnects and NSScreen garbage collection.
|
||||
fn display_uuid(&self) -> Option<Retained<NSUUID>>;
|
||||
/// Returns true if the given screen has a visible dock. This isn't
|
||||
/// point-in-time visible, this is true if the dock is always visible AND
|
||||
/// present on this screen.
|
||||
fn has_dock(&self) -> bool;
|
||||
/// Returns true if the screen has a visible notch (i.e., a non-zero safe
|
||||
/// area inset at the top).
|
||||
fn has_notch(&self) -> bool;
|
||||
/// Converts top-left offset coordinates to bottom-left origin coordinates
|
||||
/// for window positioning.
|
||||
fn origin_from_top_left_offset(&self, x: f64, y: f64, window_size: NSSize) -> NSPoint;
|
||||
}
|
||||
|
||||
impl NSScreenExt for NSScreen {
|
||||
fn display_id(&self) -> Option<u32> {
|
||||
let description = self.deviceDescription();
|
||||
let key = NSString::from_str("NSScreenNumber");
|
||||
let value = description.objectForKey(&key)?;
|
||||
value
|
||||
.downcast_ref::<NSNumber>()
|
||||
.map(NSNumber::unsignedIntValue)
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn display_uuid(&self) -> Option<Retained<NSUUID>> {
|
||||
let display_id = self.display_id()?;
|
||||
// SAFETY: plain integer argument; result is +1 retained or null.
|
||||
let raw = unsafe { CGDisplayCreateUUIDFromDisplayID(display_id) };
|
||||
let raw = std::ptr::NonNull::new(raw.cast_mut())?;
|
||||
// SAFETY: adopting the +1 reference.
|
||||
let cfuuid: CFRetained<CFUUID> = unsafe { CFRetained::from_raw(raw) };
|
||||
uuid_from_cfuuid(&cfuuid)
|
||||
}
|
||||
|
||||
fn has_dock(&self) -> bool {
|
||||
// If the dock autohides then we don't have a dock ever.
|
||||
let domain =
|
||||
user_defaults::ghostty().persistentDomainForName(&NSString::from_str("com.apple.dock"));
|
||||
let autohide = domain
|
||||
.and_then(|d| d.objectForKey(&NSString::from_str("autohide")))
|
||||
.and_then(|v| v.downcast_ref::<NSNumber>().map(NSNumber::boolValue));
|
||||
if autohide == Some(true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// There is no public API to directly ask about dock visibility, so we
|
||||
// compare visibleFrame and frame (see ns_screen_logic).
|
||||
let mtm = crate::helpers::main_thread();
|
||||
let menu_height = NSApplication::sharedApplication(mtm)
|
||||
.mainMenu()
|
||||
.map_or(0.0, |m| m.menuBarHeight());
|
||||
ns_screen_logic::has_visible_dock(
|
||||
to_rect(self.visibleFrame()),
|
||||
to_rect(self.frame()),
|
||||
menu_height,
|
||||
self.safeAreaInsets().top,
|
||||
)
|
||||
}
|
||||
|
||||
fn has_notch(&self) -> bool {
|
||||
// We assume that a top safe area means notch, since we don't
|
||||
// currently know any other situation this is true.
|
||||
ns_screen_logic::has_notch(self.safeAreaInsets().top)
|
||||
}
|
||||
|
||||
fn origin_from_top_left_offset(&self, x: f64, y: f64, window_size: NSSize) -> NSPoint {
|
||||
let p = ns_screen_logic::origin_from_top_left_offset(
|
||||
to_rect(self.visibleFrame()),
|
||||
x,
|
||||
y,
|
||||
Size {
|
||||
width: window_size.width,
|
||||
height: window_size.height,
|
||||
},
|
||||
);
|
||||
NSPoint::new(p.x, p.y)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Retained<NSImage>>;
|
||||
/// Returns the absolute root view by walking up the superview chain.
|
||||
fn root_view(&self) -> Retained<NSView>;
|
||||
/// Checks if a view contains another view in its hierarchy.
|
||||
fn contains_view(&self, view: &NSView) -> bool;
|
||||
/// Checks if the view contains the given class in its hierarchy.
|
||||
fn contains_class_name(&self, name: &str) -> bool;
|
||||
/// Finds the superview with the given class name.
|
||||
fn first_superview_with_class_name(&self, name: &str) -> Option<Retained<NSView>>;
|
||||
/// Recursively finds and returns the first descendant view that has the given class name.
|
||||
fn first_descendant_with_class_name(&self, name: &str) -> Option<Retained<NSView>>;
|
||||
/// Recursively finds and returns descendant views that have the given class name.
|
||||
fn descendants_with_class_name(&self, name: &str) -> Vec<Retained<NSView>>;
|
||||
/// Recursively finds and returns the first descendant view that has the given identifier.
|
||||
fn first_descendant_with_id(&self, id: &str) -> Option<Retained<NSView>>;
|
||||
/// Finds and returns the first view with the given class name starting
|
||||
/// from the absolute root of the view hierarchy. This includes private
|
||||
/// views like title bar views.
|
||||
fn first_view_from_root_with_class_name(&self, name: &str) -> Option<Retained<NSView>>;
|
||||
/// Prints the view hierarchy from the root in a tree-like ASCII format.
|
||||
///
|
||||
/// I need this because the "Capture View Hierarchy" was broken under some
|
||||
/// scenarios in Xcode 26 (FB17912569). But, I kept it around because it
|
||||
/// might be useful to print out the view hierarchy without halting the
|
||||
/// program.
|
||||
fn print_view_hierarchy(&self);
|
||||
/// Returns a string representation of the view hierarchy in a tree-like format.
|
||||
fn view_hierarchy_description(&self, indent: &str, is_last: bool) -> String;
|
||||
}
|
||||
|
||||
impl NSViewExt for NSView {
|
||||
fn is_in_responder_chain(&self) -> bool {
|
||||
let mut responder: Option<Retained<NSResponder>> =
|
||||
self.window().and_then(|w| w.firstResponder());
|
||||
while let Some(current) = responder {
|
||||
if std::ptr::eq(
|
||||
(&*current as *const NSResponder).cast::<AnyObject>(),
|
||||
(self as *const NSView).cast::<AnyObject>(),
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// SAFETY: plain getter on a live responder.
|
||||
responder = unsafe { current.nextResponder() };
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn screenshot(&self) -> Option<Retained<NSImage>> {
|
||||
use objc2::AnyThread;
|
||||
let bounds = self.bounds();
|
||||
let bitmap_rep = self.bitmapImageRepForCachingDisplayInRect(bounds)?;
|
||||
self.cacheDisplayInRect_toBitmapImageRep(bounds, &bitmap_rep);
|
||||
let image = NSImage::initWithSize(NSImage::alloc(), bounds.size);
|
||||
image.addRepresentation(&bitmap_rep);
|
||||
Some(image)
|
||||
}
|
||||
|
||||
fn root_view(&self) -> Retained<NSView> {
|
||||
let mut root = self.retain();
|
||||
// SAFETY: plain getter on a live view.
|
||||
while let Some(superview) = unsafe { root.superview() } {
|
||||
root = superview;
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
fn contains_view(&self, view: &NSView) -> bool {
|
||||
if std::ptr::eq(self, view) {
|
||||
return true;
|
||||
}
|
||||
self.subviews().iter().any(|s| s.contains_view(view))
|
||||
}
|
||||
|
||||
fn contains_class_name(&self, name: &str) -> bool {
|
||||
if class_name(self) == name {
|
||||
return true;
|
||||
}
|
||||
self.subviews().iter().any(|s| s.contains_class_name(name))
|
||||
}
|
||||
|
||||
fn first_superview_with_class_name(&self, name: &str) -> Option<Retained<NSView>> {
|
||||
// SAFETY: plain getter on a live view.
|
||||
let superview = unsafe { self.superview() }?;
|
||||
if class_name(&superview) == name {
|
||||
return Some(superview);
|
||||
}
|
||||
superview.first_superview_with_class_name(name)
|
||||
}
|
||||
|
||||
fn first_descendant_with_class_name(&self, name: &str) -> Option<Retained<NSView>> {
|
||||
for subview in self.subviews() {
|
||||
if class_name(&subview) == name {
|
||||
return Some(subview);
|
||||
} else if let Some(found) = subview.first_descendant_with_class_name(name) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn descendants_with_class_name(&self, name: &str) -> Vec<Retained<NSView>> {
|
||||
let mut result = Vec::new();
|
||||
for subview in self.subviews() {
|
||||
if class_name(&subview) == name {
|
||||
result.push(subview.clone());
|
||||
}
|
||||
result.extend(subview.descendants_with_class_name(name));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn first_descendant_with_id(&self, id: &str) -> Option<Retained<NSView>> {
|
||||
let want = NSString::from_str(id);
|
||||
for subview in self.subviews() {
|
||||
if subview.identifier().is_some_and(|i| *i == *want) {
|
||||
return Some(subview);
|
||||
} else if let Some(found) = subview.first_descendant_with_id(id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn first_view_from_root_with_class_name(&self, name: &str) -> Option<Retained<NSView>> {
|
||||
let root = self.root_view();
|
||||
|
||||
// Check if the root view itself matches
|
||||
if class_name(&root) == name {
|
||||
return Some(root);
|
||||
}
|
||||
|
||||
// Otherwise search descendants
|
||||
root.first_descendant_with_class_name(name)
|
||||
}
|
||||
|
||||
fn print_view_hierarchy(&self) {
|
||||
let root = self.root_view();
|
||||
println!("View Hierarchy from Root:");
|
||||
println!("{}", root.view_hierarchy_description("", true));
|
||||
}
|
||||
|
||||
fn view_hierarchy_description(&self, indent: &str, is_last: bool) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
// Add the tree branch characters
|
||||
result.push_str(indent);
|
||||
if !indent.is_empty() {
|
||||
result.push_str(if is_last { "└── " } else { "├── " });
|
||||
}
|
||||
|
||||
// Add the class name and optional identifier
|
||||
result.push_str(&class_name(self));
|
||||
|
||||
// Add identifier if present
|
||||
if let Some(identifier) = self.identifier() {
|
||||
let _ = write!(result, " (id: {identifier})");
|
||||
}
|
||||
|
||||
// Add frame info
|
||||
let f = self.frame();
|
||||
let _ = write!(
|
||||
result,
|
||||
" [frame: (({}, {}), ({}, {}))]",
|
||||
f.origin.x, f.origin.y, f.size.width, f.size.height
|
||||
);
|
||||
|
||||
// Add visual properties
|
||||
let mut properties: Vec<String> = Vec::new();
|
||||
if self.isHidden() {
|
||||
properties.push("hidden".into());
|
||||
}
|
||||
properties.push(
|
||||
if self.isOpaque() {
|
||||
"opaque"
|
||||
} else {
|
||||
"transparent"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
if self.wantsLayer() {
|
||||
properties.push("layer-backed".into());
|
||||
if let Some(bg) = self.layer().and_then(|l| l.backgroundColor()) {
|
||||
{
|
||||
let color = objc2_app_kit::NSColor::colorWithCGColor(&bg);
|
||||
let rgb = color.and_then(|c| {
|
||||
c.colorUsingColorSpace(&objc2_app_kit::NSColorSpace::deviceRGBColorSpace())
|
||||
});
|
||||
if let Some(rgb) = rgb {
|
||||
properties.push(format!(
|
||||
"bg:rgba({:.0},{:.0},{:.0},{:.2})",
|
||||
rgb.redComponent() * 255.0,
|
||||
rgb.greenComponent() * 255.0,
|
||||
rgb.blueComponent() * 255.0,
|
||||
rgb.alphaComponent()
|
||||
));
|
||||
} else {
|
||||
properties.push(format!("bg:{bg:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writeln!(result, " [{}]", properties.join(", "));
|
||||
|
||||
// Process subviews
|
||||
let subviews = self.subviews();
|
||||
let count = subviews.len();
|
||||
for (index, subview) in subviews.iter().enumerate() {
|
||||
let is_last_subview = index == count - 1;
|
||||
let new_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
|
||||
result.push_str(&subview.view_hierarchy_description(&new_indent, is_last_subview));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -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<u32>;
|
||||
/// Adjusts the window frame if necessary to ensure the window remains
|
||||
/// visible on screen. This constrains both the size (to not exceed the
|
||||
/// screen) and the origin (to keep the window on screen).
|
||||
fn constrain_to_screen(&self);
|
||||
/// True if this is the first window in the tab group.
|
||||
fn is_first_window_in_tab_group(&self) -> bool;
|
||||
/// Wraps `addTabbedWindow` with an Objective-C exception catcher because
|
||||
/// AppKit can throw NSExceptions in visual tab picker flows.
|
||||
fn add_tabbed_window_safely(&self, child: &NSWindow, ordered: NSWindowOrderingMode) -> bool;
|
||||
|
||||
// Native tabbing private API usage. :(
|
||||
|
||||
/// The [private] titlebar view. In normal windows `NSTabBar` typically
|
||||
/// appears as a subview of `NSTitlebarView` within `NSThemeFrame`. In
|
||||
/// fullscreen, the system creates a dedicated fullscreen window and the
|
||||
/// view hierarchy changes; in that case, the `titlebarView` is only
|
||||
/// accessible via a reference on `NSThemeFrame`.
|
||||
fn titlebar_view(&self) -> Option<Retained<NSView>>;
|
||||
/// Returns the [private] NSTabBar view, if it exists.
|
||||
fn tab_bar_view(&self) -> Option<Retained<NSView>>;
|
||||
/// Returns tab button views in visual order from left to right.
|
||||
fn tab_buttons_in_visual_order(&self) -> Vec<Retained<NSView>>;
|
||||
/// Returns the visual tab index and matching tab button at the given screen point.
|
||||
fn tab_button_hit(&self, screen_point: NSPoint) -> Option<(usize, Retained<NSView>)>;
|
||||
/// Returns the index of the tab button at the given screen point, if any.
|
||||
fn tab_index(&self, screen_point: NSPoint) -> Option<usize>;
|
||||
}
|
||||
|
||||
impl NSWindowExt for NSWindow {
|
||||
fn cg_window_id(&self) -> Option<u32> {
|
||||
ns_window_logic::cg_window_id(self.windowNumber())
|
||||
}
|
||||
|
||||
fn constrain_to_screen(&self) {
|
||||
let screen: Option<Retained<NSScreen>> = self
|
||||
.screen()
|
||||
.or_else(|| NSScreen::mainScreen(crate::helpers::main_thread()));
|
||||
let Some(screen) = screen else { return };
|
||||
let window_frame = to_rect(self.frame());
|
||||
if let Some(new_frame) =
|
||||
ns_window_logic::constrained_frame(window_frame, to_rect(screen.visibleFrame()))
|
||||
{
|
||||
self.setFrame_display(to_ns_rect(new_frame), true);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_first_window_in_tab_group(&self) -> bool {
|
||||
let Some(group) = self.tabGroup() else {
|
||||
return true;
|
||||
};
|
||||
let Some(first) = group.windows().firstObject() else {
|
||||
return true;
|
||||
};
|
||||
std::ptr::eq(&*first, self)
|
||||
}
|
||||
|
||||
fn add_tabbed_window_safely(&self, child: &NSWindow, ordered: NSWindowOrderingMode) -> bool {
|
||||
match objc_exception_catcher::add_tabbed_window_safely(self, child, ordered) {
|
||||
Ok(()) => true,
|
||||
Err(error) => {
|
||||
log::error!("addTabbedWindow failed: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn titlebar_view(&self) -> Option<Retained<NSView>> {
|
||||
let theme_frame_view = self.contentView()?.root_view();
|
||||
// SAFETY: `respondsToSelector:` is safe to send to any object.
|
||||
let responds: bool =
|
||||
unsafe { msg_send![&*theme_frame_view, respondsToSelector: sel!(titlebarView)] };
|
||||
if !responds {
|
||||
return None;
|
||||
}
|
||||
// `value(forKey: "titlebarView") as? NSView`
|
||||
let value: Option<Retained<AnyObject>> = unsafe {
|
||||
msg_send![&*theme_frame_view, valueForKey: &*NSString::from_str("titlebarView")]
|
||||
};
|
||||
value?.downcast::<NSView>().ok()
|
||||
}
|
||||
|
||||
fn tab_bar_view(&self) -> Option<Retained<NSView>> {
|
||||
self.titlebar_view()?
|
||||
.first_descendant_with_class_name("NSTabBar")
|
||||
}
|
||||
|
||||
fn tab_buttons_in_visual_order(&self) -> Vec<Retained<NSView>> {
|
||||
let Some(tab_bar_view) = self.tab_bar_view() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut buttons = tab_bar_view.descendants_with_class_name("NSTabButton");
|
||||
buttons.sort_by(|a, b| a.frame().origin.x.total_cmp(&b.frame().origin.x));
|
||||
buttons
|
||||
}
|
||||
|
||||
fn tab_button_hit(&self, screen_point: NSPoint) -> Option<(usize, Retained<NSView>)> {
|
||||
let tab_bar_view = self.tab_bar_view()?;
|
||||
let tab_bar_window = tab_bar_view.window()?;
|
||||
|
||||
// In fullscreen, AppKit can host the titlebar and tab bar in a
|
||||
// separate NSToolbarFullScreenWindow. Hit testing has to use that
|
||||
// window's base coordinate space or content clicks can be
|
||||
// misinterpreted as tab clicks.
|
||||
let location_in_tab_bar_window = tab_bar_window.convertPointFromScreen(screen_point);
|
||||
let location_in_tab_bar =
|
||||
tab_bar_view.convertPoint_fromView(location_in_tab_bar_window, None);
|
||||
if !point_in_rect(location_in_tab_bar, tab_bar_view.bounds()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
for (index, tab_button) in self.tab_buttons_in_visual_order().into_iter().enumerate() {
|
||||
let location_in_tab_button =
|
||||
tab_button.convertPoint_fromView(location_in_tab_bar_window, None);
|
||||
if point_in_rect(location_in_tab_button, tab_button.bounds()) {
|
||||
return Some((index, tab_button));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn tab_index(&self, screen_point: NSPoint) -> Option<usize> {
|
||||
self.tab_button_hit(screen_point).map(|(i, _)| i)
|
||||
}
|
||||
}
|
||||
|
||||
/// `CGRect.contains(CGPoint)`.
|
||||
fn point_in_rect(p: NSPoint, r: NSRect) -> bool {
|
||||
p.x >= r.origin.x
|
||||
&& p.x < r.origin.x + r.size.width
|
||||
&& p.y >= r.origin.y
|
||||
&& p.y < r.origin.y + r.size.height
|
||||
}
|
||||
@@ -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<Rect> {
|
||||
let mut f = window_frame;
|
||||
|
||||
f.size.width = f.size.width.min(visible_frame.size.width);
|
||||
f.size.height = f.size.height.min(visible_frame.size.height);
|
||||
|
||||
f.origin.x = visible_frame
|
||||
.min_x()
|
||||
.max(f.origin.x.min(visible_frame.max_x() - f.width()));
|
||||
f.origin.y = visible_frame
|
||||
.min_y()
|
||||
.max(f.origin.y.min(visible_frame.max_y() - f.height()));
|
||||
|
||||
(f != window_frame).then_some(f)
|
||||
}
|
||||
|
||||
/// `cgWindowId`: the window number if it has a window device (> 0).
|
||||
#[must_use]
|
||||
pub fn cg_window_id(window_number: isize) -> Option<u32> {
|
||||
if window_number > 0 {
|
||||
u32::try_from(window_number).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const V: Rect = Rect::new(0.0, 0.0, 1000.0, 800.0);
|
||||
|
||||
#[test]
|
||||
fn already_inside() {
|
||||
assert!(constrained_frame(Rect::new(10.0, 10.0, 200.0, 200.0), V).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinks_and_moves() {
|
||||
let f = constrained_frame(Rect::new(-50.0, 700.0, 2000.0, 100.0), V).unwrap();
|
||||
assert_eq!(f, Rect::new(0.0, 700.0, 1000.0, 100.0));
|
||||
let f = constrained_frame(Rect::new(900.0, 750.0, 200.0, 200.0), V).unwrap();
|
||||
assert_eq!(f, Rect::new(800.0, 600.0, 200.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_ids() {
|
||||
assert_eq!(cg_window_id(0), None);
|
||||
assert_eq!(cg_window_id(-1), None);
|
||||
assert_eq!(cg_window_id(42), Some(42));
|
||||
}
|
||||
}
|
||||
@@ -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<Retained<NSURL>>;
|
||||
|
||||
/// The URL of the default terminal (Unix Executable) application, if any.
|
||||
fn default_terminal(&self) -> Option<Retained<NSURL>>;
|
||||
|
||||
/// The URL of the default application for opening files with the
|
||||
/// specified content type (UTI identifier).
|
||||
fn default_application_url_for_content_type(
|
||||
&self,
|
||||
content_type: &str,
|
||||
) -> Option<Retained<NSURL>>;
|
||||
|
||||
/// The URL of the default application for opening files with the
|
||||
/// specified file extension.
|
||||
fn default_application_url_for_extension(&self, ext: &str) -> Option<Retained<NSURL>>;
|
||||
}
|
||||
|
||||
impl NSWorkspaceExt for NSWorkspace {
|
||||
fn default_text_editor(&self) -> Option<Retained<NSURL>> {
|
||||
self.default_application_url_for_content_type(uti::PLAIN_TEXT)
|
||||
}
|
||||
|
||||
fn default_terminal(&self) -> Option<Retained<NSURL>> {
|
||||
self.default_application_url_for_content_type(uti::UNIX_EXECUTABLE)
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn default_application_url_for_content_type(
|
||||
&self,
|
||||
content_type: &str,
|
||||
) -> Option<Retained<NSURL>> {
|
||||
let content_type = CFString::from_str(content_type);
|
||||
// SAFETY: valid CFString; a null error out-parameter is allowed.
|
||||
let raw = unsafe {
|
||||
LSCopyDefaultApplicationURLForContentType(
|
||||
&content_type,
|
||||
LS_ROLES_ALL,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
let raw = NonNull::new(raw)?;
|
||||
// SAFETY: the +1 retained CFURL is toll-free bridged to NSURL and we
|
||||
// adopt that reference.
|
||||
unsafe { Retained::from_raw(raw.as_ptr().cast::<NSURL>()) }
|
||||
}
|
||||
|
||||
fn default_application_url_for_extension(&self, ext: &str) -> Option<Retained<NSURL>> {
|
||||
let uti = uti::identifier_for_extension(ext)?;
|
||||
self.default_application_url_for_content_type(&uti)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//! `Helpers/Extensions/Optional+Extension.swift`.
|
||||
|
||||
use std::ffi::{CString, c_char};
|
||||
|
||||
/// `Optional<String>.withCString`: run `body` with a NUL terminated pointer,
|
||||
/// or null for `None`.
|
||||
pub fn with_c_string<T>(s: Option<&str>, body: impl FnOnce(*const c_char) -> T) -> T {
|
||||
match s {
|
||||
Some(s) => {
|
||||
let end = s.bytes().position(|b| b == 0).unwrap_or(s.len());
|
||||
let c = CString::new(&s.as_bytes()[..end]).unwrap_or_default();
|
||||
body(c.as_ptr())
|
||||
}
|
||||
None => body(std::ptr::null()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::CStr;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn some_and_none() {
|
||||
let got = with_c_string(Some("hi"), |p| {
|
||||
// SAFETY: p is a valid NUL terminated string for the call.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe { CStr::from_ptr(p) }.to_str().unwrap().to_string()
|
||||
});
|
||||
assert_eq!(got, "hi");
|
||||
assert!(with_c_string(None, <*const c_char>::is_null));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 `<tmp>/<filename>.txt`
|
||||
/// (errors are ignored like `try?`) and return the URL path.
|
||||
#[must_use]
|
||||
pub fn temporary_file(contents: &str, temp_dir: &std::path::Path, filename: &str) -> PathBuf {
|
||||
let mut path = temp_dir.join(filename);
|
||||
path.set_extension("txt");
|
||||
let _ = std::fs::write(&path, contents);
|
||||
path
|
||||
}
|
||||
|
||||
/// `String.fourCharCode`: a four-character ASCII string as a big-endian
|
||||
/// `FourCharCode` (`UInt32`).
|
||||
#[must_use]
|
||||
pub fn four_char_code(s: &str) -> u32 {
|
||||
debug_assert!(
|
||||
s.chars().count() <= 4,
|
||||
"FourCharCode string must be at most 4 characters"
|
||||
);
|
||||
s.bytes()
|
||||
.take(4)
|
||||
.fold(0u32, |acc, b| (acc << 8) | u32::from(b))
|
||||
}
|
||||
|
||||
/// The home-dependent wrapper (`FileManager.homeDirectoryForCurrentUser`).
|
||||
#[must_use]
|
||||
pub fn abbreviated_path_for_current_user(path: &str) -> String {
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
abbreviated_path(path, &home)
|
||||
}
|
||||
|
||||
pub trait StringExt {
|
||||
fn truncate(&self, length: usize) -> String;
|
||||
fn abbreviated_path(&self) -> String;
|
||||
fn four_char_code(&self) -> u32;
|
||||
}
|
||||
|
||||
impl StringExt for str {
|
||||
fn truncate(&self, length: usize) -> String {
|
||||
truncate_default(self, length)
|
||||
}
|
||||
fn abbreviated_path(&self) -> String {
|
||||
abbreviated_path_for_current_user(self)
|
||||
}
|
||||
fn four_char_code(&self) -> u32 {
|
||||
four_char_code(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_cases() {
|
||||
assert_eq!(truncate_default("hello", 10), "hello");
|
||||
assert_eq!(truncate_default("hello world", 8), "hello w…");
|
||||
assert_eq!(truncate_default("hello", 5), "hello");
|
||||
assert_eq!(truncate_default("", 3), "");
|
||||
// maxLength <= 0 returns self
|
||||
assert_eq!(truncate("abcdef", 1, "…"), "abcdef");
|
||||
assert_eq!(truncate("abcdef", 3, ".."), "a..");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviates_home() {
|
||||
assert_eq!(abbreviated_path("/Users/x/dev", "/Users/x"), "~/dev");
|
||||
assert_eq!(abbreviated_path("/opt/x", "/Users/x"), "/opt/x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_cc() {
|
||||
assert_eq!(four_char_code("why?"), 0x7768_793F);
|
||||
assert_eq!(four_char_code("ab"), 0x6162);
|
||||
assert_eq!(four_char_code(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_file_written() {
|
||||
let dir = std::env::temp_dir();
|
||||
let p = temporary_file("data", &dir, "ghostty-macos-test-temp");
|
||||
assert_eq!(p.extension().unwrap(), "txt");
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "data");
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
if cfg!(debug_assertions) {
|
||||
std::env::var("GHOSTTY_USER_DEFAULTS_SUITE").ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `UserDefaults.ghostty`: the suite if configured, else the standard defaults.
|
||||
#[must_use]
|
||||
pub fn ghostty() -> Retained<NSUserDefaults> {
|
||||
ghostty_suite()
|
||||
.and_then(|suite| {
|
||||
NSUserDefaults::initWithSuiteName(
|
||||
NSUserDefaults::alloc(),
|
||||
Some(&NSString::from_str(&suite)),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(NSUserDefaults::standardUserDefaults)
|
||||
}
|
||||
|
||||
/// `UserDefaults.ghostty.register(defaults: [...])` with boolean values.
|
||||
pub fn register_defaults(defaults: &[(&str, bool)]) {
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2_foundation::{NSDictionary, NSNumber};
|
||||
let keys: Vec<Retained<NSString>> = defaults
|
||||
.iter()
|
||||
.map(|(k, _)| NSString::from_str(k))
|
||||
.collect();
|
||||
let values: Vec<Retained<NSNumber>> = defaults
|
||||
.iter()
|
||||
.map(|(_, v)| NSNumber::new_bool(*v))
|
||||
.collect();
|
||||
let key_refs: Vec<&NSString> = keys.iter().map(|k| &**k).collect();
|
||||
let value_refs: Vec<&AnyObject> = values.iter().map(|v| &***v as &AnyObject).collect();
|
||||
let dict = NSDictionary::from_slices(&key_refs, &value_refs);
|
||||
// SAFETY: the dictionary holds NSNumbers keyed by NSStrings (a plist).
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
ghostty().registerDefaults(&dict);
|
||||
}
|
||||
}
|
||||
|
||||
/// `UserDefaults.ghostty.removePersistentDomain(forName: suite)` when a
|
||||
/// suite is configured (debug builds; see [`ghostty_suite`]).
|
||||
pub fn remove_persistent_domain_for_ghostty_suite() {
|
||||
if let Some(suite) = ghostty_suite() {
|
||||
ghostty().removePersistentDomainForName(&NSString::from_str(&suite));
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
let tag = CFString::from_str(tag);
|
||||
// SAFETY: both arguments are valid CFStrings; the result is +1 retained
|
||||
// or null and adopted by `CFRetained`.
|
||||
let raw = unsafe { UTTypeCreatePreferredIdentifierForTag(class, &tag, None) };
|
||||
let raw = std::ptr::NonNull::new(raw.cast_mut())?;
|
||||
// SAFETY: see above, ownership transferred.
|
||||
let s: CFRetained<CFString> = unsafe { CFRetained::from_raw(raw) };
|
||||
Some(s.to_string())
|
||||
}
|
||||
|
||||
/// `UTType(filenameExtension:)?.identifier` (unknown extensions yield a
|
||||
/// `dyn.*` identifier like `UTType` does).
|
||||
#[allow(unsafe_code)]
|
||||
#[must_use]
|
||||
pub fn identifier_for_extension(ext: &str) -> Option<String> {
|
||||
// SAFETY: reading an immutable constant exported by CoreServices.
|
||||
identifier_for_tag(unsafe { kUTTagClassFilenameExtension }, ext)
|
||||
}
|
||||
|
||||
/// `UTType(mimeType:)?.identifier`. Identifiers CoreServices makes up for
|
||||
/// undeclared types (`dyn.*`) are reported as `None`, so callers use their
|
||||
/// "unknown MIME type" fallback.
|
||||
#[allow(unsafe_code)]
|
||||
#[must_use]
|
||||
pub fn identifier_for_mime_type(mime: &str) -> Option<String> {
|
||||
// SAFETY: reading an immutable constant exported by CoreServices.
|
||||
identifier_for_tag(unsafe { kUTTagClassMIMEType }, mime).filter(|id| !id.starts_with("dyn."))
|
||||
}
|
||||
@@ -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<Retained<NSUUID>> {
|
||||
let s = CFUUID::new_string(None, Some(cfuuid))?;
|
||||
NSUUID::initWithUUIDString(NSUUID::alloc(), &NSString::from_str(&s.to_string()))
|
||||
}
|
||||
@@ -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<NSWindow>`).
|
||||
pub const FULLSCREEN_DID_ENTER: Name = Name("com.mitchellh.fullscreenDidEnter");
|
||||
/// `Notification.Name.fullscreenDidExit`.
|
||||
pub const FULLSCREEN_DID_EXIT: Name = Name("com.mitchellh.fullscreenDidExit");
|
||||
|
||||
/// Delegate that can be implemented for fullscreen implementations.
|
||||
pub trait FullscreenDelegate {
|
||||
/// Called whenever the fullscreen state changed. You can call
|
||||
/// `is_fullscreen` to see the current state.
|
||||
fn fullscreen_did_change(&self);
|
||||
}
|
||||
|
||||
/// Protocol that must be implemented by all fullscreen styles.
|
||||
pub trait FullscreenStyle {
|
||||
fn set_delegate(&self, delegate: Option<Weak<dyn FullscreenDelegate>>);
|
||||
fn fullscreen_mode(&self) -> FullscreenMode;
|
||||
fn is_fullscreen(&self) -> bool;
|
||||
fn supports_tabs(&self) -> bool;
|
||||
fn enter(&self);
|
||||
fn exit(&self);
|
||||
}
|
||||
|
||||
/// `FullscreenMode.style(for:)`: initializes the fullscreen style
|
||||
/// implementation for the mode. This will not toggle any fullscreen
|
||||
/// properties. This may fail if the window isn't configured properly for a
|
||||
/// given mode.
|
||||
#[must_use]
|
||||
pub fn style_for(mode: FullscreenMode, window: &NSWindow) -> Option<Rc<dyn FullscreenStyle>> {
|
||||
match mode {
|
||||
FullscreenMode::Native => Some(NativeFullscreen::new(window)),
|
||||
_ => Some(NonNativeFullscreen::new(window, mode)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `TerminalWindow.isTabBar(_:)`.
|
||||
pub type IsTabBarFn = fn(&NSWindow, &NSTitlebarAccessoryViewController) -> bool;
|
||||
|
||||
thread_local! {
|
||||
/// TODO(MA3): `TerminalWindow.isTabBar(_:)`, registered by the terminal
|
||||
/// window feature. Non-native fullscreen exit uses it to skip restoring
|
||||
/// the (private) tab bar accessory.
|
||||
static IS_TAB_BAR: Cell<Option<IsTabBarFn>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Register `TerminalWindow.isTabBar(_:)`.
|
||||
pub fn set_is_tab_bar_hook(f: IsTabBarFn) {
|
||||
IS_TAB_BAR.with(|h| h.set(Some(f)));
|
||||
}
|
||||
|
||||
fn is_tab_bar(window: &NSWindow, c: &NSTitlebarAccessoryViewController) -> bool {
|
||||
IS_TAB_BAR.with(Cell::get).is_some_and(|f| f(window, c))
|
||||
}
|
||||
|
||||
/// The base class for fullscreen implementations, cannot be used as a
|
||||
/// FullscreenStyle on its own.
|
||||
struct FullscreenBase {
|
||||
window: Retained<NSWindow>,
|
||||
delegate: RefCell<Option<Weak<dyn FullscreenDelegate>>>,
|
||||
observers: RefCell<Vec<NsObserver>>,
|
||||
}
|
||||
|
||||
impl FullscreenBase {
|
||||
fn new(window: &NSWindow) -> Rc<Self> {
|
||||
let base = Rc::new(Self {
|
||||
window: window.retain(),
|
||||
delegate: RefCell::new(None),
|
||||
observers: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
// We want to trigger delegate methods on window native fullscreen
|
||||
// changes (didEnterFullScreenNotification, etc.) no matter what our
|
||||
// fullscreen style is.
|
||||
let window_obj: &AnyObject = window.as_ref();
|
||||
// SAFETY: reading AppKit's immutable notification name constants.
|
||||
#[allow(unsafe_code)]
|
||||
let (enter, exit) = unsafe {
|
||||
(
|
||||
NSWindowDidEnterFullScreenNotification,
|
||||
NSWindowDidExitFullScreenNotification,
|
||||
)
|
||||
};
|
||||
let weak = Rc::downgrade(&base);
|
||||
let o1 = ns_notification::observe(enter, Some(window_obj), move |_| {
|
||||
if let Some(b) = weak.upgrade() {
|
||||
b.notify(FULLSCREEN_DID_ENTER);
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(&base);
|
||||
let o2 = ns_notification::observe(exit, Some(window_obj), move |_| {
|
||||
if let Some(b) = weak.upgrade() {
|
||||
b.notify(FULLSCREEN_DID_EXIT);
|
||||
}
|
||||
});
|
||||
base.observers.borrow_mut().extend([o1, o2]);
|
||||
base
|
||||
}
|
||||
|
||||
/// Post the Ghostty notification and call the delegate.
|
||||
fn notify(&self, name: Name) {
|
||||
let id = Retained::as_ptr(&self.window) as usize;
|
||||
NotificationCenter::default_center().post(
|
||||
name,
|
||||
Some(Sender::new(id, self.window.clone())),
|
||||
UserInfo::new(),
|
||||
);
|
||||
self.fullscreen_did_change();
|
||||
}
|
||||
|
||||
fn fullscreen_did_change(&self) {
|
||||
let delegate = self.delegate.borrow().as_ref().and_then(Weak::upgrade);
|
||||
if let Some(d) = delegate {
|
||||
d.fullscreen_did_change();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS native fullscreen. This is the typical behavior you get by pressing
|
||||
/// the green fullscreen button on regular titlebars.
|
||||
pub struct NativeFullscreen {
|
||||
base: Rc<FullscreenBase>,
|
||||
}
|
||||
|
||||
impl NativeFullscreen {
|
||||
#[must_use]
|
||||
pub fn new(window: &NSWindow) -> Rc<Self> {
|
||||
// TODO: There are many requirements for native fullscreen we should
|
||||
// check here such as the stylemask.
|
||||
Rc::new(Self {
|
||||
base: FullscreenBase::new(window),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FullscreenStyle for NativeFullscreen {
|
||||
fn set_delegate(&self, delegate: Option<Weak<dyn FullscreenDelegate>>) {
|
||||
*self.base.delegate.borrow_mut() = delegate;
|
||||
}
|
||||
|
||||
fn fullscreen_mode(&self) -> FullscreenMode {
|
||||
FullscreenMode::Native
|
||||
}
|
||||
|
||||
fn is_fullscreen(&self) -> bool {
|
||||
self.base
|
||||
.window
|
||||
.styleMask()
|
||||
.contains(NSWindowStyleMask::FullScreen)
|
||||
}
|
||||
|
||||
fn supports_tabs(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn enter(&self) {
|
||||
if self.is_fullscreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// The titlebar separator shows up erroneously in fullscreen if the tab
|
||||
// bar is made to appear and then disappear by opening and then closing
|
||||
// a tab. We get rid of the separator while in fullscreen to prevent this.
|
||||
self.base
|
||||
.window
|
||||
.setTitlebarSeparatorStyle(NSTitlebarSeparatorStyle::None);
|
||||
|
||||
// Enter fullscreen
|
||||
self.base
|
||||
.window
|
||||
.toggleFullScreen(Some(self.base.window.as_ref()));
|
||||
|
||||
// Note: we don't call our delegate here because the base class
|
||||
// will always trigger the delegate on native fullscreen notifications
|
||||
// and we don't want to double notify.
|
||||
}
|
||||
|
||||
fn exit(&self) {
|
||||
if !self.is_fullscreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore titlebar separator style. See enter for explanation.
|
||||
self.base
|
||||
.window
|
||||
.setTitlebarSeparatorStyle(NSTitlebarSeparatorStyle::Automatic);
|
||||
|
||||
self.base.window.toggleFullScreen(None);
|
||||
|
||||
// Note: we don't call our delegate here because the base class
|
||||
// will always trigger the delegate on native fullscreen notifications
|
||||
// and we don't want to double notify.
|
||||
}
|
||||
}
|
||||
|
||||
/// The state that must be saved for non-native fullscreen to exit fullscreen.
|
||||
struct SavedState {
|
||||
screen_id: Option<u32>,
|
||||
tab_group: Option<Retained<NSWindowTabGroup>>,
|
||||
tab_group_index: Option<usize>,
|
||||
content_frame: NSRect,
|
||||
style_mask: NSWindowStyleMask,
|
||||
toolbar: Option<Retained<NSToolbar>>,
|
||||
toolbar_style: NSWindowToolbarStyle,
|
||||
titlebar_accessory_view_controllers: Vec<Retained<NSTitlebarAccessoryViewController>>,
|
||||
dock: bool,
|
||||
menu: bool,
|
||||
}
|
||||
|
||||
impl SavedState {
|
||||
fn new(window: &NSWindow) -> Option<Self> {
|
||||
let content_view = window.contentView()?;
|
||||
|
||||
let tab_group = window.tabGroup();
|
||||
let tab_group_index = tab_group
|
||||
.as_ref()
|
||||
.and_then(|g| g.windows().iter().position(|w| std::ptr::eq(&*w, window)));
|
||||
|
||||
// Accessing titlebarAccessoryViewControllers without a titlebar
|
||||
// triggers a crash.
|
||||
// SAFETY: `hasTitleBar` is a plain BOOL getter on NSWindow.
|
||||
#[allow(unsafe_code)]
|
||||
let has_title_bar: bool = unsafe { msg_send![window, hasTitleBar] };
|
||||
let titlebar_accessory_view_controllers = if has_title_bar {
|
||||
window.titlebarAccessoryViewControllers().to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let menu = if let Some(cg_window_id) = window.cg_window_id() {
|
||||
// We hide the menu only if this window is not on any fullscreen
|
||||
// spaces. We do this because fullscreen spaces already hide the
|
||||
// menu and if we insert/remove this presentation option we get
|
||||
// issues (see #7075)
|
||||
let active_space = CGSSpace::active();
|
||||
let spaces = CGSSpace::list(cg_window_id, CGSSpaceMask::ALL_SPACES);
|
||||
let flags: Vec<(bool, bool)> = spaces
|
||||
.iter()
|
||||
.map(|s| {
|
||||
(
|
||||
*s == active_space,
|
||||
s.space_type() == Some(CGSSpaceType::Fullscreen),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
fullscreen_logic::can_hide_menu(
|
||||
&flags,
|
||||
active_space.space_type() == Some(CGSSpaceType::Fullscreen),
|
||||
)
|
||||
} else {
|
||||
// Window doesn't have a window device, its not visible or
|
||||
// something. In this case, we assume we can hide the menu. We may
|
||||
// want to do something more sophisticated but this works for now.
|
||||
true
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
screen_id: window.screen().and_then(|s| s.display_id()),
|
||||
tab_group,
|
||||
tab_group_index,
|
||||
content_frame: window.convertRectToScreen(content_view.frame()),
|
||||
style_mask: window.styleMask(),
|
||||
toolbar: window.toolbar(),
|
||||
toolbar_style: window.toolbarStyle(),
|
||||
titlebar_accessory_view_controllers,
|
||||
dock: window.screen().is_some_and(|s| s.has_dock()),
|
||||
menu,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct NonNativeInner {
|
||||
base: Rc<FullscreenBase>,
|
||||
mode: FullscreenMode,
|
||||
saved_state: RefCell<Option<SavedState>>,
|
||||
screen_observer: RefCell<Option<NsObserver>>,
|
||||
close_observer: RefCell<Option<NsObserver>>,
|
||||
}
|
||||
|
||||
/// Non-native fullscreen, with the variants selected by [`FullscreenMode`]
|
||||
/// (`NonNativeFullscreen`, `...VisibleMenu`, `...PaddedNotch`).
|
||||
pub struct NonNativeFullscreen {
|
||||
inner: Rc<NonNativeInner>,
|
||||
}
|
||||
|
||||
fn to_rect(r: NSRect) -> Rect {
|
||||
Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height)
|
||||
}
|
||||
|
||||
impl NonNativeFullscreen {
|
||||
/// `mode` must be one of the non-native modes (anything else behaves like
|
||||
/// plain `NonNative`).
|
||||
#[must_use]
|
||||
pub fn new(window: &NSWindow, mode: FullscreenMode) -> Rc<Self> {
|
||||
let base = FullscreenBase::new(window);
|
||||
let inner = Rc::new(NonNativeInner {
|
||||
base,
|
||||
mode: if mode == FullscreenMode::Native {
|
||||
FullscreenMode::NonNative
|
||||
} else {
|
||||
mode
|
||||
},
|
||||
saved_state: RefCell::new(None),
|
||||
screen_observer: RefCell::new(None),
|
||||
close_observer: RefCell::new(None),
|
||||
});
|
||||
|
||||
let weak = Rc::downgrade(&inner);
|
||||
let window_obj: &AnyObject = window.as_ref();
|
||||
// SAFETY: reading AppKit's immutable notification name constant.
|
||||
#[allow(unsafe_code)]
|
||||
let will_close = unsafe { objc2_app_kit::NSWindowWillCloseNotification };
|
||||
let observer = ns_notification::observe(will_close, Some(window_obj), move |_| {
|
||||
// When the window closes we need to explicitly exit non-native
|
||||
// fullscreen otherwise some state like the menu bar can remain hidden.
|
||||
if let Some(i) = weak.upgrade() {
|
||||
i.exit();
|
||||
}
|
||||
});
|
||||
*inner.close_observer.borrow_mut() = Some(observer);
|
||||
Rc::new(Self { inner })
|
||||
}
|
||||
}
|
||||
|
||||
impl NonNativeInner {
|
||||
/// The default properties for this style.
|
||||
fn properties(&self) -> Properties {
|
||||
Properties::for_mode(self.mode)
|
||||
}
|
||||
|
||||
fn window(&self) -> &Retained<NSWindow> {
|
||||
&self.base.window
|
||||
}
|
||||
|
||||
fn is_fullscreen(&self) -> bool {
|
||||
// isFullscreen is dependent on if we have saved state currently. We
|
||||
// could one day try to do fancier stuff like inspecting the window
|
||||
// state but there isn't currently a need for it.
|
||||
self.saved_state.borrow().is_some()
|
||||
}
|
||||
|
||||
fn enter(self: &Rc<Self>) {
|
||||
// If we are in fullscreen we don't do it again.
|
||||
if self.is_fullscreen() {
|
||||
return;
|
||||
}
|
||||
let window = self.window().clone();
|
||||
|
||||
// If we are in native fullscreen, exit native fullscreen. This is
|
||||
// counter intuitive but if we entered native fullscreen (through the
|
||||
// green max button or an external event) and we press the fullscreen
|
||||
// keybind, we probably want to EXIT fullscreen.
|
||||
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
|
||||
window.toggleFullScreen(None);
|
||||
return;
|
||||
}
|
||||
|
||||
// This is the screen that we're going to go fullscreen on. We use the
|
||||
// screen the window is currently on.
|
||||
let Some(screen) = window.screen() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Save the state that we need to exit again
|
||||
let Some(saved_state) = SavedState::new(&window) else {
|
||||
return;
|
||||
};
|
||||
let (save_dock, save_menu) = (saved_state.dock, saved_state.menu);
|
||||
*self.saved_state.borrow_mut() = Some(saved_state);
|
||||
|
||||
// Get our current first responder on this window. For non-native
|
||||
// fullscreen we have to restore this because for some reason the
|
||||
// operations below lose it (see:
|
||||
// https://github.com/ghostty-org/ghostty/issues/6999). I don't know
|
||||
// the root cause here so if we can figure that out there may be a
|
||||
// nicer way than this.
|
||||
let first_responder: Option<Retained<NSResponder>> = window.firstResponder();
|
||||
|
||||
// We hide the dock if the window is on a screen with the dock.
|
||||
// We must hide the dock FIRST then hide the menu:
|
||||
// If you specify autoHideMenuBar, it must be accompanied by either
|
||||
// hideDock or autoHideDock.
|
||||
if save_dock {
|
||||
self.hide_dock();
|
||||
}
|
||||
|
||||
// Hide the menu if requested
|
||||
if self.properties().hide_menu && save_menu {
|
||||
self.hide_menu();
|
||||
}
|
||||
|
||||
// When we change screens we need to redo everything.
|
||||
let weak = Rc::downgrade(self);
|
||||
let window_obj: &AnyObject = window.as_ref();
|
||||
// SAFETY: reading AppKit's immutable notification name constant.
|
||||
#[allow(unsafe_code)]
|
||||
let did_change_screen = unsafe { NSWindowDidChangeScreenNotification };
|
||||
*self.screen_observer.borrow_mut() = Some(ns_notification::observe(
|
||||
did_change_screen,
|
||||
Some(window_obj),
|
||||
move |_| {
|
||||
if let Some(i) = weak.upgrade() {
|
||||
i.window_did_change_screen();
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
// Being untitled let's our content take up the full frame.
|
||||
window.setStyleMask(window.styleMask() - NSWindowStyleMask::Titled);
|
||||
|
||||
// We don't want the non-native fullscreen window to be resizable
|
||||
// from the edges.
|
||||
window.setStyleMask(window.styleMask() - NSWindowStyleMask::Resizable);
|
||||
|
||||
// Focus window
|
||||
window.makeKeyAndOrderFront(None);
|
||||
|
||||
// Set frame to screen size, accounting for any elements such as the
|
||||
// menu bar. We do this async so that all the style edits above (title
|
||||
// removal, dock hide, menu hide, etc.) take effect. This fixes:
|
||||
// https://github.com/ghostty-org/ghostty/issues/1996
|
||||
let this = self.clone();
|
||||
main_async(move || {
|
||||
this.window()
|
||||
.setFrame_display(this.fullscreen_frame(&screen), true);
|
||||
if let Some(first_responder) = &first_responder {
|
||||
this.window().makeFirstResponder(Some(first_responder));
|
||||
}
|
||||
|
||||
this.base.notify(FULLSCREEN_DID_ENTER);
|
||||
});
|
||||
}
|
||||
|
||||
fn exit(&self) {
|
||||
if !self.is_fullscreen() {
|
||||
return;
|
||||
}
|
||||
let Some(saved_state) = self.saved_state.borrow().as_ref().map(SavedStateView::of) else {
|
||||
return;
|
||||
};
|
||||
let window = self.window().clone();
|
||||
|
||||
// Remove all our notifications. We remove them one by one because we
|
||||
// don't want to remove the observers that our superclass sets.
|
||||
*self.screen_observer.borrow_mut() = None;
|
||||
|
||||
// See enter where we do the same thing to understand why.
|
||||
let first_responder: Option<Retained<NSResponder>> = window.firstResponder();
|
||||
|
||||
// Unhide our elements
|
||||
if saved_state.dock {
|
||||
self.unhide_dock();
|
||||
}
|
||||
if self.properties().hide_menu && saved_state.menu {
|
||||
self.unhide_menu();
|
||||
}
|
||||
|
||||
// Restore our saved state
|
||||
let saved = self.saved_state.borrow_mut().take().expect("checked above");
|
||||
window.setStyleMask(saved.style_mask);
|
||||
window.setFrame_display(window.frameRectForContentRect(saved.content_frame), true);
|
||||
|
||||
// Removing the "titled" style also derefs all our accessory view
|
||||
// controllers so we need to restore those.
|
||||
for c in &saved.titlebar_accessory_view_controllers {
|
||||
// Restoring the tab bar causes all sorts of problems. Its best to
|
||||
// just ignore it, even though this is kind of a hack.
|
||||
if is_tab_bar(&window, c) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let present = window
|
||||
.titlebarAccessoryViewControllers()
|
||||
.iter()
|
||||
.any(|x| std::ptr::eq(Retained::as_ptr(&x), Retained::as_ptr(c)));
|
||||
if !present {
|
||||
window.addTitlebarAccessoryViewController(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Removing "titled" also clears our toolbar
|
||||
window.setToolbar(saved.toolbar.as_deref());
|
||||
window.setToolbarStyle(saved.toolbar_style);
|
||||
|
||||
// If the window was previously in a tab group that isn't empty now,
|
||||
// we re-add it. We have to do this because our process of doing
|
||||
// non-native fullscreen removes the window from the tab group.
|
||||
if let (Some(tab_group), Some(tab_index)) = (&saved.tab_group, saved.tab_group_index) {
|
||||
let windows = tab_group.windows();
|
||||
if windows.count() > 0 {
|
||||
match fullscreen_logic::tab_reinsert(tab_index, windows.count()) {
|
||||
TabReinsert::BelowFirst => {
|
||||
// We were previously the first tab. Add it before
|
||||
// ("below") the first window in the tab group currently.
|
||||
windows
|
||||
.objectAtIndex(0)
|
||||
.add_tabbed_window_safely(&window, NSWindowOrderingMode::Below);
|
||||
}
|
||||
TabReinsert::AboveIndex(i) => {
|
||||
// We were somewhere in the middle
|
||||
windows
|
||||
.objectAtIndex(i)
|
||||
.add_tabbed_window_safely(&window, NSWindowOrderingMode::Above);
|
||||
}
|
||||
TabReinsert::BelowLast => {
|
||||
// We were at the end
|
||||
windows
|
||||
.objectAtIndex(windows.count() - 1)
|
||||
.add_tabbed_window_safely(&window, NSWindowOrderingMode::Below);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(first_responder) = &first_responder {
|
||||
window.makeFirstResponder(Some(first_responder));
|
||||
}
|
||||
|
||||
// Focus window
|
||||
window.makeKeyAndOrderFront(None);
|
||||
|
||||
// Notify the delegate
|
||||
self.base.notify(FULLSCREEN_DID_EXIT);
|
||||
}
|
||||
|
||||
fn fullscreen_frame(&self, screen: &NSScreen) -> NSRect {
|
||||
let mtm = super::main_thread();
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
let options = app.presentationOptions();
|
||||
let menu_hidden = options.contains(NSApplicationPresentationOptions::AutoHideMenuBar)
|
||||
|| options.contains(NSApplicationPresentationOptions::HideMenuBar);
|
||||
// NOTE on macOS bugs: macOS used to have a bug where menuBarHeight
|
||||
// didn't account for the notch. I reported this as a radar and it was
|
||||
// fixed at some point. I don't know when that was so I can't put an
|
||||
// #available check, but it was in a bug fix release so I think if a
|
||||
// bug is reported to Ghostty we can just advise the user to update.
|
||||
let f = fullscreen_logic::fullscreen_frame(
|
||||
to_rect(screen.frame()),
|
||||
menu_hidden,
|
||||
app.mainMenu().map_or(0.0, |m| m.menuBarHeight()),
|
||||
self.properties().padded_notch,
|
||||
screen.safeAreaInsets().top,
|
||||
);
|
||||
NSRect::new(
|
||||
objc2_foundation::NSPoint::new(f.origin.x, f.origin.y),
|
||||
NSSize::new(f.size.width, f.size.height),
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Window Events
|
||||
|
||||
fn window_did_change_screen(&self) {
|
||||
if !self.is_fullscreen() {
|
||||
return;
|
||||
}
|
||||
let saved_screen_id = match self.saved_state.borrow().as_ref() {
|
||||
Some(s) => s.screen_id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Our screens must have changed
|
||||
if saved_screen_id == self.window().screen().and_then(|s| s.display_id()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When we change screens, we simply exit fullscreen. Changing
|
||||
// screens shouldn't naturally be possible, it can only happen
|
||||
// through external window managers. There's a lot of accounting
|
||||
// to do to get the screen change right so instead of breaking
|
||||
// we just exit out. The user can re-enter fullscreen thereafter.
|
||||
self.exit();
|
||||
}
|
||||
|
||||
// MARK: Dock
|
||||
|
||||
#[allow(clippy::unused_self)]
|
||||
fn hide_dock(&self) {
|
||||
NSApplication::sharedApplication(super::main_thread())
|
||||
.acquire_presentation_option(NSApplicationPresentationOptions::AutoHideDock);
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_self)]
|
||||
fn unhide_dock(&self) {
|
||||
NSApplication::sharedApplication(super::main_thread())
|
||||
.release_presentation_option(NSApplicationPresentationOptions::AutoHideDock);
|
||||
}
|
||||
|
||||
// MARK: Menu
|
||||
|
||||
#[allow(clippy::unused_self)]
|
||||
fn hide_menu(&self) {
|
||||
NSApplication::sharedApplication(super::main_thread())
|
||||
.acquire_presentation_option(NSApplicationPresentationOptions::AutoHideMenuBar);
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_self)]
|
||||
fn unhide_menu(&self) {
|
||||
NSApplication::sharedApplication(super::main_thread())
|
||||
.release_presentation_option(NSApplicationPresentationOptions::AutoHideMenuBar);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Bool` flags of the saved state (what `exit` reads before it is taken).
|
||||
struct SavedStateView {
|
||||
dock: bool,
|
||||
menu: bool,
|
||||
}
|
||||
|
||||
impl SavedStateView {
|
||||
fn of(s: &SavedState) -> Self {
|
||||
Self {
|
||||
dock: s.dock,
|
||||
menu: s.menu,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FullscreenStyle for NonNativeFullscreen {
|
||||
fn set_delegate(&self, delegate: Option<Weak<dyn FullscreenDelegate>>) {
|
||||
*self.inner.base.delegate.borrow_mut() = delegate;
|
||||
}
|
||||
|
||||
fn fullscreen_mode(&self) -> FullscreenMode {
|
||||
self.inner.mode
|
||||
}
|
||||
|
||||
fn is_fullscreen(&self) -> bool {
|
||||
self.inner.is_fullscreen()
|
||||
}
|
||||
|
||||
// Non-native fullscreen never supports tabs because tabs require the
|
||||
// "titled" style and we don't have it for non-native fullscreen.
|
||||
fn supports_tabs(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enter(&self) {
|
||||
self.inner.enter();
|
||||
}
|
||||
|
||||
fn exit(&self) {
|
||||
self.inner.exit();
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
match raw {
|
||||
"native" => Some(Self::Native),
|
||||
"nonNative" => Some(Self::NonNative),
|
||||
"nonNativeVisibleMenu" => Some(Self::NonNativeVisibleMenu),
|
||||
"nonNativePaddedNotch" => Some(Self::NonNativePaddedNotch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NonNativeFullscreen.Properties`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Properties {
|
||||
pub hide_menu: bool,
|
||||
pub padded_notch: bool,
|
||||
}
|
||||
|
||||
impl Default for Properties {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hide_menu: true,
|
||||
padded_notch: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Properties {
|
||||
/// The properties of the non-native style for `mode` (native has none).
|
||||
#[must_use]
|
||||
pub fn for_mode(mode: FullscreenMode) -> Self {
|
||||
match mode {
|
||||
FullscreenMode::NonNativeVisibleMenu => Self {
|
||||
hide_menu: false,
|
||||
..Self::default()
|
||||
},
|
||||
FullscreenMode::NonNativePaddedNotch => Self {
|
||||
padded_notch: true,
|
||||
..Self::default()
|
||||
},
|
||||
_ => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NonNativeFullscreen.fullscreenFrame`: it would make more sense to use
|
||||
/// `visibleFrame` but that omits space by the dock and isn't updated until an
|
||||
/// event loop tick, so the frame is computed from `screen_frame`.
|
||||
#[must_use]
|
||||
pub fn fullscreen_frame(
|
||||
screen_frame: Rect,
|
||||
menu_bar_hidden: bool,
|
||||
menu_bar_height: f64,
|
||||
padded_notch: bool,
|
||||
safe_area_top: f64,
|
||||
) -> Rect {
|
||||
let mut frame = screen_frame;
|
||||
if !menu_bar_hidden {
|
||||
// We need to subtract the menu height since we're still showing it.
|
||||
frame.size.height -= menu_bar_height;
|
||||
} else if padded_notch {
|
||||
// We are hiding the menu, we may need to avoid the notch.
|
||||
frame.size.height -= safe_area_top;
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
/// `SavedState.menu`: hide the menu only if this window is not on any
|
||||
/// fullscreen space (fullscreen spaces already hide the menu; inserting and
|
||||
/// removing the presentation option causes issues, see #7075).
|
||||
///
|
||||
/// `spaces` is `(is_active_space, is_fullscreen_space)` per space of the window.
|
||||
#[must_use]
|
||||
pub fn can_hide_menu(spaces: &[(bool, bool)], active_is_fullscreen: bool) -> bool {
|
||||
if spaces.iter().any(|&(is_active, _)| is_active) {
|
||||
!active_is_fullscreen
|
||||
} else {
|
||||
spaces.iter().all(|&(_, fullscreen)| !fullscreen)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a non-native-fullscreen window re-enters its tab group on exit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TabReinsert {
|
||||
/// Before ("below") the first window of the group.
|
||||
BelowFirst,
|
||||
/// After ("above") the window at this index.
|
||||
AboveIndex(usize),
|
||||
/// Below the last window.
|
||||
BelowLast,
|
||||
}
|
||||
|
||||
/// `exit()`'s placement: `tab_index` was the saved index, `count` the current
|
||||
/// number of windows in the (non-empty) group.
|
||||
#[must_use]
|
||||
pub fn tab_reinsert(tab_index: usize, count: usize) -> TabReinsert {
|
||||
if tab_index == 0 {
|
||||
TabReinsert::BelowFirst
|
||||
} else if tab_index <= count {
|
||||
TabReinsert::AboveIndex(tab_index - 1)
|
||||
} else {
|
||||
TabReinsert::BelowLast
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn raw_values_roundtrip() {
|
||||
for m in [
|
||||
FullscreenMode::Native,
|
||||
FullscreenMode::NonNative,
|
||||
FullscreenMode::NonNativeVisibleMenu,
|
||||
FullscreenMode::NonNativePaddedNotch,
|
||||
] {
|
||||
assert_eq!(FullscreenMode::from_raw_value(m.raw_value()), Some(m));
|
||||
}
|
||||
assert_eq!(FullscreenMode::from_raw_value("x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_per_mode() {
|
||||
assert!(Properties::for_mode(FullscreenMode::NonNative).hide_menu);
|
||||
assert!(!Properties::for_mode(FullscreenMode::NonNativeVisibleMenu).hide_menu);
|
||||
assert!(Properties::for_mode(FullscreenMode::NonNativePaddedNotch).padded_notch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_subtracts_menu_or_notch() {
|
||||
let f = Rect::new(0.0, 0.0, 1000.0, 800.0);
|
||||
assert_eq!(fullscreen_frame(f, false, 24.0, true, 32.0).height(), 776.0);
|
||||
assert_eq!(fullscreen_frame(f, true, 24.0, true, 32.0).height(), 768.0);
|
||||
assert_eq!(fullscreen_frame(f, true, 24.0, false, 32.0).height(), 800.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_hiding_decision() {
|
||||
assert!(can_hide_menu(&[], false));
|
||||
assert!(!can_hide_menu(&[(true, true)], true));
|
||||
assert!(can_hide_menu(&[(true, false), (false, true)], false));
|
||||
assert!(!can_hide_menu(&[(false, true)], false));
|
||||
assert!(can_hide_menu(&[(false, false)], false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_positions() {
|
||||
assert_eq!(tab_reinsert(0, 3), TabReinsert::BelowFirst);
|
||||
assert_eq!(tab_reinsert(2, 3), TabReinsert::AboveIndex(1));
|
||||
assert_eq!(tab_reinsert(4, 3), TabReinsert::BelowLast);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
// SAFETY: TISCopyCurrentKeyboardInputSource returns a +1 retained
|
||||
// CFTypeRef or null; `CFRetained` takes ownership (released on drop).
|
||||
// TISGetInputSourceProperty returns a borrowed CFString valid while
|
||||
// the source is alive, which we copy into a Rust String before then.
|
||||
unsafe {
|
||||
let raw = NonNull::new(TISCopyCurrentKeyboardInputSource())?;
|
||||
let source: CFRetained<CFString> = CFRetained::from_raw(raw.cast());
|
||||
let ptr = TISGetInputSourceProperty(raw.as_ptr(), kTISPropertyInputSourceID);
|
||||
let id = (!ptr.is_null()).then(|| (*ptr.cast::<CFString>()).to_string());
|
||||
drop(source);
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
let mut result = Vec::new();
|
||||
if self.modifiers.contains(EventModifiers::CONTROL) {
|
||||
result.push("⌃".to_string());
|
||||
}
|
||||
if self.modifiers.contains(EventModifiers::OPTION) {
|
||||
result.push("⌥".to_string());
|
||||
}
|
||||
if self.modifiers.contains(EventModifiers::SHIFT) {
|
||||
result.push("⇧".to_string());
|
||||
}
|
||||
if self.modifiers.contains(EventModifiers::COMMAND) {
|
||||
result.push("⌘".to_string());
|
||||
}
|
||||
|
||||
let key_string = match self.key {
|
||||
KeyEquivalent::RETURN => "⏎".to_string(),
|
||||
KeyEquivalent::ESCAPE => "⎋".to_string(),
|
||||
KeyEquivalent::DELETE => "⌫".to_string(),
|
||||
KeyEquivalent::SPACE => "␣".to_string(),
|
||||
KeyEquivalent::TAB => "⇥".to_string(),
|
||||
KeyEquivalent::UP_ARROW => "▲".to_string(),
|
||||
KeyEquivalent::DOWN_ARROW => "▼".to_string(),
|
||||
KeyEquivalent::LEFT_ARROW => "◀".to_string(),
|
||||
KeyEquivalent::RIGHT_ARROW => "▶".to_string(),
|
||||
KeyEquivalent::PAGE_UP => "↑".to_string(),
|
||||
KeyEquivalent::PAGE_DOWN => "↓".to_string(),
|
||||
KeyEquivalent::HOME => "⤒".to_string(),
|
||||
KeyEquivalent::END => "⤓".to_string(),
|
||||
other => other.character.to_uppercase().collect(),
|
||||
};
|
||||
result.push(key_string);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for KeyboardShortcut {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.key_list().join(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn description_lists_modifiers_in_swift_order() {
|
||||
let s = KeyboardShortcut::new(
|
||||
KeyEquivalent::new('t'),
|
||||
EventModifiers::COMMAND | EventModifiers::SHIFT | EventModifiers::CONTROL,
|
||||
);
|
||||
assert_eq!(s.to_string(), "⌃⇧⌘T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_keys_use_symbols() {
|
||||
let s = KeyboardShortcut::new(KeyEquivalent::UP_ARROW, EventModifiers::OPTION);
|
||||
assert_eq!(s.key_list(), vec!["⌥", "▲"]);
|
||||
let s = KeyboardShortcut::new(KeyEquivalent::DELETE, EventModifiers::empty());
|
||||
assert_eq!(s.to_string(), "⌫");
|
||||
}
|
||||
}
|
||||
@@ -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<Retained<NSNumber>> = rect.iter().map(|v| NSNumber::new_f64(*v)).collect();
|
||||
let array = NSArray::from_retained_slice(&numbers);
|
||||
// SAFETY: an NSArray of NSNumbers is a valid property-list value.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
user_defaults::ghostty()
|
||||
.setObject_forKey(Some(&array), &NSString::from_str(POSITION_KEY));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Restores a previously saved window frame (or parts of it) onto the
|
||||
/// given window.
|
||||
///
|
||||
/// - `restore_origin`: whether to restore the saved position. Pass `false`
|
||||
/// when the config specifies an explicit `window-position-x`/`window-position-y`.
|
||||
/// - `restore_size`: whether to restore the saved size. Pass `false` when
|
||||
/// the config specifies an explicit `window-width`/`window-height`.
|
||||
///
|
||||
/// Returns `true` if the frame was modified, `false` if there was nothing
|
||||
/// to restore.
|
||||
pub fn restore(&self, window: &NSWindow, restore_origin: bool, restore_size: bool) -> bool {
|
||||
if !(restore_origin || restore_size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let defaults = user_defaults::ghostty();
|
||||
let Some(array) = defaults.arrayForKey(&NSString::from_str(POSITION_KEY)) else {
|
||||
return false;
|
||||
};
|
||||
// `as? [Double]`: every element must be a number.
|
||||
let mut values = Vec::new();
|
||||
for o in &array {
|
||||
let Some(n) = o.downcast_ref::<NSNumber>() else {
|
||||
return false;
|
||||
};
|
||||
values.push(n.doubleValue());
|
||||
}
|
||||
if values.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let screen: Option<Retained<NSScreen>> = window
|
||||
.screen()
|
||||
.or_else(|| NSScreen::mainScreen(super::main_thread()));
|
||||
let Some(screen) = screen else { return false };
|
||||
let to_rect = |r: NSRect| Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
|
||||
|
||||
let Some(new_frame) = restored_frame(
|
||||
&values,
|
||||
to_rect(window.frame()),
|
||||
to_rect(screen.visibleFrame()),
|
||||
restore_origin,
|
||||
restore_size,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
window.setFrame_display(
|
||||
NSRect::new(
|
||||
NSPoint::new(new_frame.origin.x, new_frame.origin.y),
|
||||
NSSize::new(new_frame.size.width, new_frame.size.height),
|
||||
),
|
||||
true,
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -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<Rect> {
|
||||
if !(restore_origin || restore_size) || values.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut new_frame = window_frame;
|
||||
if restore_origin {
|
||||
new_frame.origin.x = values[0];
|
||||
new_frame.origin.y = values[1];
|
||||
}
|
||||
|
||||
if restore_size && values.len() >= 4 {
|
||||
new_frame.size.width = values[2].min(visible_frame.width());
|
||||
new_frame.size.height = values[3].min(visible_frame.height());
|
||||
}
|
||||
|
||||
// If the new frame is not constrained to the visible screen, shift it a
|
||||
// little before AppKit does so the correct size is saved beforehand.
|
||||
if restore_origin && !visible_frame.contains_rect(&new_frame) {
|
||||
new_frame.origin.x = visible_frame
|
||||
.min_x()
|
||||
.max((visible_frame.max_x() - new_frame.width()).min(new_frame.origin.x));
|
||||
new_frame.origin.y = visible_frame
|
||||
.min_y()
|
||||
.max((visible_frame.max_y() - new_frame.height()).min(new_frame.origin.y));
|
||||
}
|
||||
|
||||
Some(new_frame)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VISIBLE: Rect = Rect::new(0.0, 0.0, 1000.0, 800.0);
|
||||
|
||||
#[test]
|
||||
fn nothing_to_restore() {
|
||||
let w = Rect::new(5.0, 5.0, 100.0, 100.0);
|
||||
assert!(restored_frame(&[1.0, 2.0], w, VISIBLE, false, false).is_none());
|
||||
assert!(restored_frame(&[1.0], w, VISIBLE, true, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_origin_only() {
|
||||
let w = Rect::new(5.0, 5.0, 100.0, 100.0);
|
||||
let r = restored_frame(&[10.0, 20.0, 300.0, 300.0], w, VISIBLE, true, false).unwrap();
|
||||
assert_eq!(r, Rect::new(10.0, 20.0, 100.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_is_clamped_and_origin_shifted_onscreen() {
|
||||
let w = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let r = restored_frame(&[900.0, 700.0, 5000.0, 400.0], w, VISIBLE, true, true).unwrap();
|
||||
assert_eq!(r.size.width, 1000.0);
|
||||
assert_eq!(r.size.height, 400.0);
|
||||
assert_eq!(r.origin.x, 0.0);
|
||||
assert_eq!(r.origin.y, 400.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_roundtrip() {
|
||||
assert_eq!(encode(&Rect::new(1.0, 2.0, 3.0, 4.0)), [1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
}
|
||||
@@ -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<T>` is `std::rc::Weak` / `objc2::rc::Weak`.
|
||||
//!
|
||||
//! Pure logic (string/color/frame math, counters, decisions) lives in
|
||||
//! platform independent modules so it is unit-tested on every target; the
|
||||
//! AppKit parts are thin `cfg(target_vendor = "apple")` wrappers around it.
|
||||
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used))]
|
||||
|
||||
// Platform independent (tested on every target).
|
||||
pub mod any_sort_key;
|
||||
pub mod app_info;
|
||||
pub mod color;
|
||||
pub mod cursor_counter;
|
||||
pub mod extensions;
|
||||
pub mod fullscreen_logic;
|
||||
pub mod geometry;
|
||||
pub mod keyboard_shortcut;
|
||||
pub mod last_window_position_logic;
|
||||
pub mod notification_center;
|
||||
pub mod published;
|
||||
|
||||
// AppKit.
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod codable_bridge;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod cross_kit;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod cursor;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod dispatch;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod expiring_undo_manager;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod fullscreen;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod keyboard_layout;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod last_window_position;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod objc_exception_catcher;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod private;
|
||||
#[cfg(target_vendor = "apple")]
|
||||
pub mod vibrant_layer;
|
||||
|
||||
/// The main thread marker. Every AppKit object is created and used on the main
|
||||
/// thread (the Swift app uses `@MainActor` / `DispatchQueue.main`); panics
|
||||
/// elsewhere, which is a programming error.
|
||||
#[cfg(target_vendor = "apple")]
|
||||
#[must_use]
|
||||
pub fn main_thread() -> objc2::MainThreadMarker {
|
||||
objc2::MainThreadMarker::new().expect("must be called on the main thread")
|
||||
}
|
||||
@@ -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<dyn Any>` and the
|
||||
//! sender is a [`Sender`] (identity + a type-erased strong handle that
|
||||
//! [`Sender::downcast_ref`] turns back into e.g. `Retained<SurfaceView>`).
|
||||
//!
|
||||
//! Semantics follow `NotificationCenter`:
|
||||
//! * delivery is synchronous, on the posting thread (always the main thread:
|
||||
//! nothing here is `Send`);
|
||||
//! * an observer registered with a sender only receives notifications posted
|
||||
//! with that same sender; one registered without a sender receives every
|
||||
//! notification of that name;
|
||||
//! * observers registered or removed while a notification is being delivered do
|
||||
//! not disturb the delivery (the observer list is snapshotted; an observer
|
||||
//! removed meanwhile is skipped);
|
||||
//! * dropping the [`ObserverToken`] removes the observer (the equivalent of
|
||||
//! `removeObserver`, and of storing a Combine `AnyCancellable`).
|
||||
//!
|
||||
//! AppKit's own notifications (`NSWindow.didBecomeKeyNotification`, ...) go
|
||||
//! through `helpers::extensions::ns_notification::observe` instead.
|
||||
|
||||
use std::{any::Any, cell::RefCell, collections::HashMap, fmt, rc::Rc};
|
||||
|
||||
/// A notification name (`Notification.Name`); the string is the same as in the
|
||||
/// Swift app (`"com.mitchellh.ghostty.configDidChange"`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Name(pub &'static str);
|
||||
|
||||
impl Name {
|
||||
#[must_use]
|
||||
pub const fn raw_value(self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The sender of a notification (`Notification.object`).
|
||||
#[derive(Clone)]
|
||||
pub struct Sender {
|
||||
id: usize,
|
||||
handle: Rc<dyn Any>,
|
||||
}
|
||||
|
||||
impl Sender {
|
||||
/// `id` is the object's identity (the address of the Objective-C object),
|
||||
/// `handle` keeps it alive and lets receivers downcast it.
|
||||
pub fn new<T: 'static>(id: usize, handle: T) -> Self {
|
||||
Self {
|
||||
id,
|
||||
handle: Rc::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn id(&self) -> usize {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// The sender as `T` (the type passed to [`Sender::new`]).
|
||||
#[must_use]
|
||||
pub fn downcast_ref<T: 'static>(&self) -> Option<&T> {
|
||||
self.handle.downcast_ref::<T>()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Sender {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Sender({:#x})", self.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Notification.userInfo`.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct UserInfo(HashMap<&'static str, Rc<dyn Any>>);
|
||||
|
||||
impl UserInfo {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Builder style insert.
|
||||
#[must_use]
|
||||
pub fn with<T: 'static>(mut self, key: &'static str, value: T) -> Self {
|
||||
self.insert(key, value);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn insert<T: 'static>(&mut self, key: &'static str, value: T) {
|
||||
self.0.insert(key, Rc::new(value));
|
||||
}
|
||||
|
||||
/// `userInfo?[key] as? T`.
|
||||
#[must_use]
|
||||
pub fn get<T: 'static>(&self, key: &str) -> Option<&T> {
|
||||
self.0.get(key).and_then(|v| v.downcast_ref::<T>())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.0.contains_key(key)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Foundation.Notification`.
|
||||
#[derive(Clone)]
|
||||
pub struct Notification {
|
||||
pub name: Name,
|
||||
pub object: Option<Sender>,
|
||||
pub user_info: UserInfo,
|
||||
}
|
||||
|
||||
/// An observer callback.
|
||||
pub type Handler = Rc<dyn Fn(&Notification)>;
|
||||
|
||||
struct Observer {
|
||||
id: u64,
|
||||
name: Name,
|
||||
object: Option<usize>,
|
||||
handler: Handler,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
next_id: u64,
|
||||
observers: Vec<Observer>,
|
||||
}
|
||||
|
||||
/// A notification center. Use [`NotificationCenter::default_center`] for the
|
||||
/// process-wide one (thread-local, i.e. main thread).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct NotificationCenter {
|
||||
inner: Rc<RefCell<Inner>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static DEFAULT: NotificationCenter = NotificationCenter::default();
|
||||
}
|
||||
|
||||
/// Removes its observer when dropped.
|
||||
#[must_use = "dropping the token removes the observer"]
|
||||
pub struct ObserverToken {
|
||||
center: std::rc::Weak<RefCell<Inner>>,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
impl ObserverToken {
|
||||
/// Keep the observer registered for the life of the process
|
||||
/// (`addObserver` without a matching `removeObserver`).
|
||||
pub fn forget(self) {
|
||||
std::mem::forget(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ObserverToken {
|
||||
fn drop(&mut self) {
|
||||
if let Some(inner) = self.center.upgrade() {
|
||||
inner.borrow_mut().observers.retain(|o| o.id != self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationCenter {
|
||||
/// `NotificationCenter.default`.
|
||||
#[must_use]
|
||||
pub fn default_center() -> Self {
|
||||
DEFAULT.with(Clone::clone)
|
||||
}
|
||||
|
||||
/// `addObserver(forName:object:queue:using:)` (always delivered inline).
|
||||
pub fn add_observer(
|
||||
&self,
|
||||
name: Name,
|
||||
object: Option<&Sender>,
|
||||
handler: impl Fn(&Notification) + 'static,
|
||||
) -> ObserverToken {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
let id = inner.next_id;
|
||||
inner.next_id += 1;
|
||||
inner.observers.push(Observer {
|
||||
id,
|
||||
name,
|
||||
object: object.map(Sender::id),
|
||||
handler: Rc::new(handler),
|
||||
});
|
||||
ObserverToken {
|
||||
center: Rc::downgrade(&self.inner),
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
/// `post(name:object:userInfo:)`.
|
||||
pub fn post(&self, name: Name, object: Option<Sender>, user_info: UserInfo) {
|
||||
self.post_notification(&Notification {
|
||||
name,
|
||||
object,
|
||||
user_info,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn post_notification(&self, n: &Notification) {
|
||||
let snapshot: Vec<(u64, Handler)> = self
|
||||
.inner
|
||||
.borrow()
|
||||
.observers
|
||||
.iter()
|
||||
.filter(|o| {
|
||||
o.name == n.name
|
||||
&& match (o.object, &n.object) {
|
||||
(None, _) => true,
|
||||
(Some(want), Some(got)) => want == got.id(),
|
||||
(Some(_), None) => false,
|
||||
}
|
||||
})
|
||||
.map(|o| (o.id, o.handler.clone()))
|
||||
.collect();
|
||||
for (id, handler) in snapshot {
|
||||
// Skip observers removed by an earlier handler of this delivery.
|
||||
if self.inner.borrow().observers.iter().any(|o| o.id == id) {
|
||||
handler(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::*;
|
||||
|
||||
const A: Name = Name("test.a");
|
||||
const B: Name = Name("test.b");
|
||||
|
||||
#[test]
|
||||
fn delivers_by_name_and_sender() {
|
||||
let nc = NotificationCenter::default();
|
||||
let any = Rc::new(Cell::new(0));
|
||||
let only = Rc::new(Cell::new(0));
|
||||
let s1 = Sender::new(1, 1u32);
|
||||
let s2 = Sender::new(2, 2u32);
|
||||
let _t1 = nc.add_observer(A, None, {
|
||||
let any = any.clone();
|
||||
move |_| any.set(any.get() + 1)
|
||||
});
|
||||
let _t2 = nc.add_observer(A, Some(&s1), {
|
||||
let only = only.clone();
|
||||
move |n| {
|
||||
assert_eq!(
|
||||
n.object.as_ref().and_then(|o| o.downcast_ref::<u32>()),
|
||||
Some(&1)
|
||||
);
|
||||
only.set(only.get() + 1);
|
||||
}
|
||||
});
|
||||
nc.post(A, Some(s1.clone()), UserInfo::new());
|
||||
nc.post(A, Some(s2), UserInfo::new());
|
||||
nc.post(A, None, UserInfo::new());
|
||||
nc.post(B, Some(s1), UserInfo::new());
|
||||
assert_eq!(any.get(), 3);
|
||||
assert_eq!(only.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_token_removes_observer() {
|
||||
let nc = NotificationCenter::default();
|
||||
let hits = Rc::new(Cell::new(0));
|
||||
let t = nc.add_observer(A, None, {
|
||||
let hits = hits.clone();
|
||||
move |_| hits.set(hits.get() + 1)
|
||||
});
|
||||
nc.post(A, None, UserInfo::new());
|
||||
drop(t);
|
||||
nc.post(A, None, UserInfo::new());
|
||||
assert_eq!(hits.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_info_is_typed() {
|
||||
let info = UserInfo::new().with("k", String::from("v")).with("n", 5u64);
|
||||
assert_eq!(info.get::<String>("k").map(String::as_str), Some("v"));
|
||||
assert_eq!(info.get::<u64>("n"), Some(&5));
|
||||
assert!(info.get::<u32>("n").is_none());
|
||||
assert!(info.get::<u64>("missing").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_may_remove_a_later_observer_during_delivery() {
|
||||
let nc = NotificationCenter::default();
|
||||
let hits = Rc::new(Cell::new(0));
|
||||
let victim: Rc<RefCell<Option<ObserverToken>>> = Rc::new(RefCell::new(None));
|
||||
let _first = nc.add_observer(A, None, {
|
||||
let victim = victim.clone();
|
||||
move |_| drop(victim.borrow_mut().take())
|
||||
});
|
||||
*victim.borrow_mut() = Some(nc.add_observer(A, None, {
|
||||
let hits = hits.clone();
|
||||
move |_| hits.set(hits.get() + 1)
|
||||
}));
|
||||
nc.post(A, None, UserInfo::new());
|
||||
assert_eq!(hits.get(), 0);
|
||||
}
|
||||
}
|
||||
@@ -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::<NSException>().ok());
|
||||
ObjCException {
|
||||
name: ns
|
||||
.as_ref()
|
||||
.map(|e| e.name().to_string())
|
||||
.unwrap_or_default(),
|
||||
reason: ns.and_then(|e| e.reason()).map_or_else(
|
||||
|| "Unknown Objective-C exception".to_string(),
|
||||
|r| r.to_string(),
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<CFNumber>,
|
||||
) -> *const CFArray<CFNumber>;
|
||||
}
|
||||
|
||||
/// <https://github.com/NUIKit/CGSInternal/blob/c4f6f559d624dc1cfc2bf24c8c19dbf653317fcf/CGSSpace.h#L40>
|
||||
/// converted to Rust.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct CGSSpaceMask(pub u32);
|
||||
|
||||
impl CGSSpaceMask {
|
||||
pub const INCLUDES_CURRENT: Self = Self(1 << 0);
|
||||
pub const INCLUDES_OTHERS: Self = Self(1 << 1);
|
||||
pub const INCLUDES_USER: Self = Self(1 << 2);
|
||||
pub const INCLUDES_VISIBLE: Self = Self(1 << 16);
|
||||
|
||||
pub const CURRENT_SPACE: Self = Self(Self::INCLUDES_USER.0 | Self::INCLUDES_CURRENT.0);
|
||||
pub const OTHER_SPACES: Self = Self(Self::INCLUDES_OTHERS.0 | Self::INCLUDES_CURRENT.0);
|
||||
pub const ALL_SPACES: Self =
|
||||
Self(Self::INCLUDES_USER.0 | Self::INCLUDES_OTHERS.0 | Self::INCLUDES_CURRENT.0);
|
||||
pub const ALL_VISIBLE_SPACES: Self = Self(Self::INCLUDES_VISIBLE.0 | Self::ALL_SPACES.0);
|
||||
}
|
||||
|
||||
/// `CGSSpaceType`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CGSSpaceType {
|
||||
User = 0,
|
||||
System = 2,
|
||||
Fullscreen = 4,
|
||||
}
|
||||
|
||||
impl CGSSpaceType {
|
||||
#[must_use]
|
||||
pub fn from_raw(raw: u32) -> Option<Self> {
|
||||
match raw {
|
||||
0 => Some(Self::User),
|
||||
2 => Some(Self::System),
|
||||
4 => Some(Self::Fullscreen),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a unique identifier for a macOS Space (Desktop, Fullscreen, etc).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct CGSSpace {
|
||||
pub raw_value: CGSSpaceID,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CGSSpace {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "SpaceID({})", self.raw_value)
|
||||
}
|
||||
}
|
||||
|
||||
impl CGSSpace {
|
||||
/// Returns the currently active space.
|
||||
#[must_use]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn active() -> Self {
|
||||
// SAFETY: private but stable CoreGraphics calls taking/returning integers.
|
||||
let space = unsafe { CGSGetActiveSpace(CGSMainConnectionID()) };
|
||||
Self { raw_value: space }
|
||||
}
|
||||
|
||||
/// List the spaces for the given window.
|
||||
#[must_use]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn list(window_id: u32, mask: CGSSpaceMask) -> Vec<Self> {
|
||||
let ids = CFArray::from_retained_objects(&[CFNumber::new_i64(i64::from(window_id))]);
|
||||
// SAFETY: `ids` is a valid CFArray of CFNumbers for the call; the
|
||||
// returned array (if non-null) is +1 retained and adopted below.
|
||||
let spaces = unsafe { CGSCopySpacesForWindows(CGSMainConnectionID(), mask.0, &ids) };
|
||||
let Some(spaces) = std::ptr::NonNull::new(spaces.cast_mut()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// SAFETY: ownership of the +1 reference is transferred.
|
||||
let spaces: CFRetained<CFArray<CFNumber>> = unsafe { CFRetained::from_raw(spaces) };
|
||||
spaces
|
||||
.iter()
|
||||
.filter_map(|n| n.as_i64())
|
||||
.map(|v| Self {
|
||||
raw_value: v as CGSSpaceID,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `type`.
|
||||
#[must_use]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn space_type(&self) -> Option<CGSSpaceType> {
|
||||
// SAFETY: integer arguments only.
|
||||
let raw = unsafe { CGSSpaceGetType(CGSMainConnectionID(), self.raw_value) };
|
||||
CGSSpaceType::from_raw(raw)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn masks() {
|
||||
assert_eq!(CGSSpaceMask::ALL_SPACES.0, 0b111);
|
||||
assert_eq!(CGSSpaceMask::ALL_VISIBLE_SPACES.0, (1 << 16) | 0b111);
|
||||
assert_eq!(CGSSpaceMask::CURRENT_SPACE.0, 0b101);
|
||||
assert_eq!(CGSSpaceType::from_raw(4), Some(CGSSpaceType::Fullscreen));
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
match raw {
|
||||
1 => Some(Self::Top),
|
||||
2 => Some(Self::Bottom),
|
||||
3 => Some(Self::Left),
|
||||
4 => Some(Self::Right),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Dock;
|
||||
|
||||
impl Dock {
|
||||
/// Returns the orientation of the dock or None if it can't be determined.
|
||||
#[must_use]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn orientation() -> Option<DockOrientation> {
|
||||
let mut orientation = 0i32;
|
||||
let mut pinning = 0i32;
|
||||
// SAFETY: both out-pointers are valid for writes for the call.
|
||||
unsafe { CoreDockGetOrientationAndPinning(&raw mut orientation, &raw mut pinning) };
|
||||
DockOrientation::from_raw(orientation)
|
||||
}
|
||||
|
||||
/// The dock autohide.
|
||||
#[must_use]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn auto_hide_enabled() -> bool {
|
||||
// SAFETY: no arguments; plain getter.
|
||||
unsafe { CoreDockGetAutoHideEnabled() }
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
pub fn set_auto_hide_enabled(value: bool) {
|
||||
// SAFETY: plain setter taking a bool.
|
||||
unsafe { CoreDockSetAutoHideEnabled(value) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn orientation_raw() {
|
||||
assert_eq!(DockOrientation::from_raw(3), Some(DockOrientation::Left));
|
||||
assert_eq!(DockOrientation::from_raw(0), None);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
//! `Helpers/Private/{CGS,Dock}.swift`: private CoreGraphics / CoreDock APIs.
|
||||
|
||||
pub mod cgs;
|
||||
pub mod dock;
|
||||
@@ -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<T> = Rc<dyn Fn(&T)>;
|
||||
|
||||
struct Inner<T> {
|
||||
value: T,
|
||||
next_id: u64,
|
||||
subscribers: Vec<(u64, Callback<T>)>,
|
||||
}
|
||||
|
||||
/// `@Published var value: T`. Cloning shares the same cell.
|
||||
pub struct Published<T> {
|
||||
inner: Rc<RefCell<Inner<T>>>,
|
||||
}
|
||||
|
||||
impl<T> Clone for Published<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: self.inner.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the subscription when dropped.
|
||||
#[must_use = "dropping the subscription cancels it"]
|
||||
pub struct Subscription {
|
||||
cancel: Option<Box<dyn FnOnce()>>,
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
/// Keep the subscription for the life of the cell.
|
||||
pub fn forget(mut self) {
|
||||
self.cancel = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Subscription {
|
||||
fn drop(&mut self) {
|
||||
if let Some(cancel) = self.cancel.take() {
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone + 'static> Published<T> {
|
||||
pub fn new(value: T) -> Self {
|
||||
Self {
|
||||
inner: Rc::new(RefCell::new(Inner {
|
||||
value,
|
||||
next_id: 0,
|
||||
subscribers: Vec::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current value.
|
||||
#[must_use]
|
||||
pub fn get(&self) -> T {
|
||||
self.inner.borrow().value.clone()
|
||||
}
|
||||
|
||||
/// Assign a new value and notify every subscriber with it.
|
||||
pub fn set(&self, value: T) {
|
||||
let subscribers: Vec<Callback<T>> = {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
inner.value = value.clone();
|
||||
inner.subscribers.iter().map(|(_, cb)| cb.clone()).collect()
|
||||
};
|
||||
for cb in subscribers {
|
||||
cb(&value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutate in place (`value.field = x` on a `@Published` struct) and notify.
|
||||
pub fn update(&self, f: impl FnOnce(&mut T)) {
|
||||
let mut value = self.get();
|
||||
f(&mut value);
|
||||
self.set(value);
|
||||
}
|
||||
|
||||
/// Subscribe (`$property.sink`). Like Combine's `@Published` publisher the
|
||||
/// callback is first invoked with the current value.
|
||||
pub fn sink(&self, f: impl Fn(&T) + 'static) -> Subscription {
|
||||
let cb: Callback<T> = Rc::new(f);
|
||||
let (id, current) = {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
let id = inner.next_id;
|
||||
inner.next_id += 1;
|
||||
inner.subscribers.push((id, cb.clone()));
|
||||
(id, inner.value.clone())
|
||||
};
|
||||
cb(¤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);
|
||||
}
|
||||
}
|
||||
@@ -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<VibrantLayerType>]
|
||||
pub struct VibrantLayer;
|
||||
|
||||
impl VibrantLayer {
|
||||
// SAFETY: matches `- (id)compositingFilter`.
|
||||
#[unsafe(method_id(compositingFilter))]
|
||||
fn compositing_filter(&self) -> Option<Retained<AnyObject>> {
|
||||
let name = NSString::from_str(compositing_filter_name(self.ivars().get()));
|
||||
// SAFETY: NSString is an object; upcasting to AnyObject is valid.
|
||||
Some(unsafe { Retained::cast_unchecked::<AnyObject>(name) })
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
impl VibrantLayer {
|
||||
/// `init(forAppearance:)`.
|
||||
#[must_use]
|
||||
pub fn init_for_appearance(ty: VibrantLayerType) -> Retained<Self> {
|
||||
let this = Self::alloc().set_ivars(Cell::new(ty));
|
||||
// SAFETY: `init` is the designated initializer of CALayer.
|
||||
unsafe { msg_send![super(this), init] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_names() {
|
||||
assert_eq!(compositing_filter_name(VibrantLayerType::Light), "plusD");
|
||||
assert_eq!(compositing_filter_name(VibrantLayerType::Dark), "plusL");
|
||||
}
|
||||
}
|
||||
@@ -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/<Area>` | [`features`]`::<area>` |
|
||||
//!
|
||||
//! Everything that touches AppKit is `cfg(target_vendor = "apple")`; on other
|
||||
//! targets the crate only contains the platform independent logic (config
|
||||
//! value parsing, shell quoting, menu tables, bundle metadata, ...) so it is
|
||||
//! tested on Linux and the workspace stays green. The AppKit code can only be
|
||||
//! compile-checked off macOS (`scripts/macos-crosscheck.sh`); see
|
||||
//! `docs/rewrite/notes/macos-app-design.md`.
|
||||
|
||||
pub mod app;
|
||||
pub mod features;
|
||||
pub mod ghostty;
|
||||
pub mod helpers;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user