From 49d4ecefaea7c725af572f0f740541caccc04df6 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 06:38:33 +0200 Subject: [PATCH] feat(lib): port libghostty, Ghostty's embedded C API Ports apprt/embedded.zig, main_c.zig and config/CApi.zig + c_get.zig as crates/ghostty-lib (cdylib/staticlib/rlib `libghostty`), exporting every function of the unchanged include/ghostty.h - the ABI Ghostty's Swift macOS/iOS app links against. The embedded runtime implements the runtime traits over the host's C callbacks, so the real core drives it. - ghostty-core selects the renderer per target: Metal on Apple (surface NSView/UIView via RtSurface::platform_view), OpenGL elsewhere. - ghostty_config_get follows Zig's c_get rules via a CValue type (143 supported keys, checked against the Zig field types). - The inspector C API is complete; its Metal backend replaces ImGui's Metal renderer and has not run. Verification: generated C ABI probe (sizeof/alignof/offsetof of every struct/union, 755 enum values, every function linked, nm exports, headers byte-identical to upstream); a C host on Linux covering init, CLI actions, config, app, keys, focus and color scheme; an opt-in test-platform feature creating real surfaces on Linux with headless EGL and a pty (13 tests). Apple targets are clippy-clean but not run; the Swift app has not been built against it. Test Plan: - cargo test -p ghostty-lib (and --features test-platform) - cargo clippy --target aarch64-apple-darwin -p ghostty-lib (cross env in docs/rewrite/notes/macos-M1.md) --- Cargo.lock | 32 + Cargo.toml | 2 + crates/ghostty-apprt/src/runtime_api.rs | 6 + crates/ghostty-config/src/c_value.rs | 64 + crates/ghostty-config/src/cli_args.rs | 71 +- crates/ghostty-config/src/config.rs | 91 + crates/ghostty-config/src/input_fields.rs | 10 + crates/ghostty-config/src/lib.rs | 1 + crates/ghostty-config/src/macros.rs | 21 + crates/ghostty-config/src/path.rs | 14 + .../src/repeatable_string_map.rs | 8 + .../src/testdata/c_get_supported.txt | 143 ++ crates/ghostty-config/src/types_a.rs | 30 + crates/ghostty-config/src/types_b.rs | 82 + crates/ghostty-core/Cargo.toml | 1 + crates/ghostty-core/src/app.rs | 31 +- crates/ghostty-core/src/global.rs | 32 +- crates/ghostty-core/src/surface/mod.rs | 90 +- crates/ghostty-core/tests/e2e_bindings.rs | 3 + crates/ghostty-core/tests/e2e_ime.rs | 3 + crates/ghostty-core/tests/e2e_inspector.rs | 3 + crates/ghostty-core/tests/e2e_keys.rs | 3 + crates/ghostty-core/tests/e2e_mouse.rs | 3 + crates/ghostty-core/tests/e2e_term.rs | 3 + crates/ghostty-font/src/face/coretext.rs | 14 + crates/ghostty-lib/Cargo.toml | 85 + crates/ghostty-lib/include/ghostty.h | 1178 +++++++++ crates/ghostty-lib/include/module.modulemap | 7 + crates/ghostty-lib/src/action_c.rs | 348 +++ crates/ghostty-lib/src/benchmark.rs | 98 + crates/ghostty-lib/src/config_capi.rs | 727 ++++++ crates/ghostty-lib/src/embedded.rs | 2157 +++++++++++++++++ crates/ghostty-lib/src/inspector.rs | 477 ++++ crates/ghostty-lib/src/inspector_metal.rs | 633 +++++ crates/ghostty-lib/src/lib.rs | 29 + crates/ghostty-lib/src/main_c.rs | 257 ++ crates/ghostty-lib/src/types.rs | 579 +++++ crates/ghostty-lib/tests/abi.rs | 1385 +++++++++++ crates/ghostty-lib/tests/c/host.c | 385 +++ crates/ghostty-lib/tests/c_host.rs | 81 + crates/ghostty-lib/tests/common/mod.rs | 163 ++ crates/ghostty-lib/tests/surface.rs | 810 +++++++ crates/ghostty-renderer/src/generic.rs | 6 + crates/ghostty-renderer/src/metal/mod.rs | 17 + docs/rewrite/notes/ghostty-apprt-gtk.md | 2 +- docs/rewrite/notes/libghostty-L1.md | 139 ++ docs/rewrite/notes/windows-WIN.md | 6 +- 47 files changed, 10296 insertions(+), 34 deletions(-) create mode 100644 crates/ghostty-config/src/c_value.rs create mode 100644 crates/ghostty-config/src/testdata/c_get_supported.txt create mode 100644 crates/ghostty-lib/Cargo.toml create mode 100644 crates/ghostty-lib/include/ghostty.h create mode 100644 crates/ghostty-lib/include/module.modulemap create mode 100644 crates/ghostty-lib/src/action_c.rs create mode 100644 crates/ghostty-lib/src/benchmark.rs create mode 100644 crates/ghostty-lib/src/config_capi.rs create mode 100644 crates/ghostty-lib/src/embedded.rs create mode 100644 crates/ghostty-lib/src/inspector.rs create mode 100644 crates/ghostty-lib/src/inspector_metal.rs create mode 100644 crates/ghostty-lib/src/lib.rs create mode 100644 crates/ghostty-lib/src/main_c.rs create mode 100644 crates/ghostty-lib/src/types.rs create mode 100644 crates/ghostty-lib/tests/abi.rs create mode 100644 crates/ghostty-lib/tests/c/host.c create mode 100644 crates/ghostty-lib/tests/c_host.rs create mode 100644 crates/ghostty-lib/tests/common/mod.rs create mode 100644 crates/ghostty-lib/tests/surface.rs create mode 100644 docs/rewrite/notes/libghostty-L1.md diff --git a/Cargo.lock b/Cargo.lock index 7eb4c2f..c822c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1345,6 +1345,7 @@ dependencies = [ "ghostty-config", "ghostty-crash", "ghostty-font", + "ghostty-helpgen", "ghostty-input", "ghostty-os", "ghostty-vt", @@ -1390,6 +1391,7 @@ dependencies = [ "ghostty-unicode", "ghostty-vt", "log", + "objc2-core-foundation", "onig", "oslog", "png", @@ -1441,10 +1443,15 @@ dependencies = [ "yeslogic-fontconfig-sys", ] +[[package]] +name = "ghostty-helpgen" +version = "0.1.0" + [[package]] name = "ghostty-input" version = "0.1.0" dependencies = [ + "ghostty-helpgen", "ghostty-unicode", "ghostty-vt", "indexmap", @@ -1466,6 +1473,31 @@ dependencies = [ "log", ] +[[package]] +name = "ghostty-lib" +version = "0.1.0" +dependencies = [ + "block2", + "dispatch2", + "ghostty-apprt", + "ghostty-bench", + "ghostty-cli", + "ghostty-config", + "ghostty-core", + "ghostty-input", + "ghostty-inspector", + "ghostty-os", + "ghostty-renderer", + "ghostty-terminal", + "ghostty-vt", + "log", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", + "tempfile", +] + [[package]] name = "ghostty-onig" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f15f242..0910efc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ ghostty-vt = { path = "crates/ghostty-vt" } ghostty-unicode = { path = "crates/ghostty-unicode" } ghostty-terminal = { path = "crates/ghostty-terminal" } ghostty-input = { path = "crates/ghostty-input" } +ghostty-helpgen = { path = "crates/ghostty-helpgen" } ghostty-config = { path = "crates/ghostty-config" } ghostty-font = { path = "crates/ghostty-font" } ghostty-renderer = { path = "crates/ghostty-renderer" } @@ -66,6 +67,7 @@ ghostty-bench = { path = "crates/ghostty-bench" } ghostty-inspector = { path = "crates/ghostty-inspector" } ghostty-vt-capi = { path = "crates/ghostty-vt-capi" } ghostty-wasm = { path = "crates/ghostty-wasm" } +ghostty-lib = { path = "crates/ghostty-lib" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/ghostty-apprt/src/runtime_api.rs b/crates/ghostty-apprt/src/runtime_api.rs index 4f1409f..751455b 100644 --- a/crates/ghostty-apprt/src/runtime_api.rs +++ b/crates/ghostty-apprt/src/runtime_api.rs @@ -310,6 +310,12 @@ pub trait RtSurface { let _ = name; std::ptr::null() } + /// The native view the renderer attaches to (embedded runtime: the + /// `NSView` / `UIView` the host provided; Metal renderer only). Null if + /// the runtime has no such view. + fn platform_view(&self) -> *mut std::ffi::c_void { + std::ptr::null_mut() + } /// Callback to create a transient systemd scope for the child process /// (Zig: `apprt/gtk/cgroup.zig` + `post_fork`). Called on the main /// thread when the surface is created; the returned closure is called diff --git a/crates/ghostty-config/src/c_value.rs b/crates/ghostty-config/src/c_value.rs new file mode 100644 index 0000000..87380cb --- /dev/null +++ b/crates/ghostty-config/src/c_value.rs @@ -0,0 +1,64 @@ +//! The C representation of config values (Zig: `config/c_get.zig` together +//! with the `cval` functions of the config types). +//! +//! `ghostty_config_get` writes a value of a key-dependent C type into a +//! caller supplied pointer. This crate cannot use `unsafe`, so the typed +//! value is described by [`CValue`] and the C API crate (`ghostty-lib`) does +//! the actual write. + +use ghostty_input::command::CommandC; + +/// A size of the quick terminal along one axis in C form +/// (`ghostty_quick_terminal_size_s`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum QuickTerminalSizeC { + None, + Percentage(f32), + Pixels(u32), +} + +/// The value of a config field in the form the C API exposes it. +#[derive(Debug, Clone, PartialEq)] +pub enum CValue { + /// The type of this field is not supported by the C API (Zig `getValue` + /// returns false). + Unsupported, + /// An optional field without a value (returns false). + Null, + /// An optional string without a value: `ghostty_config_get` returns true + /// and stores a null pointer. + NullString, + /// A string (`?[:0]const u8`): stored as a NUL terminated `const char*`. + String(String), + /// A boolean (`bool`). + Bool(bool), + /// `u8` / `u32`, stored as `c_uint`. + Uint(u32), + /// `i16`, stored as `c_short`. + Short(i16), + /// `f32`. + F32(f32), + /// `f64`. + F64(f64), + /// `usize` (`Duration.cval`). + Usize(usize), + /// An enum: the tag name, stored as a NUL terminated `const char*`. + EnumName(&'static str), + /// A packed struct of at most 32 bits: its bits stored as `c_uint`. + Packed(u32), + /// `ghostty_config_color_s`. + Color { r: u8, g: u8, b: u8 }, + /// `ghostty_config_color_list_s`. + ColorList(Vec<[u8; 3]>), + /// `ghostty_config_palette_s`. + Palette(Box<[[u8; 3]; 256]>), + /// `ghostty_config_command_list_s`. + Commands(Vec), + /// `ghostty_config_quick_terminal_size_s`. + QuickTerminalSize { + primary: QuickTerminalSizeC, + secondary: QuickTerminalSizeC, + }, + /// `ghostty_config_path_s`. + Path { path: String, optional: bool }, +} diff --git a/crates/ghostty-config/src/cli_args.rs b/crates/ghostty-config/src/cli_args.rs index 824752d..bae91a3 100644 --- a/crates/ghostty-config/src/cli_args.rs +++ b/crates/ghostty-config/src/cli_args.rs @@ -15,6 +15,7 @@ use std::{fmt, io::BufRead}; use crate::{ + c_value::CValue, comma_splitter::CommaSplitter, diagnostics::{Diagnostic, DiagnosticList, Location}, formatter::EntryFormatter, @@ -113,6 +114,28 @@ pub trait ConfigField: Clone + PartialEq + Default + fmt::Debug { /// Write this value in the `+show-config` format. fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result; + + /// The value in the form the C API exposes (Zig: `c_get.getValue`). + /// Types the C API doesn't support yet return [`CValue::Unsupported`]. + fn c_value(&self) -> CValue { + CValue::Unsupported + } + + /// [`ConfigField::c_value`] when the value is wrapped in an `Option` + /// (only `String` differs: `?[:0]const u8` is supported, `[]const u8` + /// is not). + fn c_value_optional(&self) -> CValue { + self.c_value() + } + + /// The C value of an `Option` that is `None`. + fn c_value_none() -> CValue { + if matches!(Self::default().c_value(), CValue::Unsupported) { + CValue::Unsupported + } else { + CValue::Null + } + } } /// The destination of CLI parsing. @@ -456,6 +479,10 @@ pub fn parse_int_base0(s: &str) -> Option { impl ConfigField for bool { const KIND: FieldKind = FieldKind::Bool; + fn c_value(&self) -> CValue { + CValue::Bool(*self) + } + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { *self = parse_bool(value.unwrap_or("t"))?; Ok(()) @@ -467,8 +494,13 @@ impl ConfigField for bool { } macro_rules! impl_int_field { - ($($t:ty),*) => {$( + ($($t:ty => $cv:expr),*) => {$( impl ConfigField for $t { + fn c_value(&self) -> CValue { + let f: fn($t) -> CValue = $cv; + f(*self) + } + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { let v = value.ok_or(ArgError::ValueRequired)?; let n = parse_int_base0(v).ok_or(ArgError::InvalidValue)?; @@ -482,11 +514,27 @@ macro_rules! impl_int_field { } )*}; } -impl_int_field!(u8, u16, u32, u64, usize, i8, i16, i32, i64, isize); +impl_int_field!( + u8 => |v| CValue::Uint(u32::from(v)), + u16 => |_| CValue::Unsupported, + u32 => CValue::Uint, + u64 => |_| CValue::Unsupported, + usize => |_| CValue::Unsupported, + i8 => |_| CValue::Unsupported, + i16 => CValue::Short, + i32 => |_| CValue::Unsupported, + i64 => |_| CValue::Unsupported, + isize => |_| CValue::Unsupported +); macro_rules! impl_float_field { - ($($t:ty),*) => {$( + ($($t:ty => $cv:expr),*) => {$( impl ConfigField for $t { + fn c_value(&self) -> CValue { + let f: fn($t) -> CValue = $cv; + f(*self) + } + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { let v = value.ok_or(ArgError::ValueRequired)?; *self = v.parse::<$t>().map_err(|_| ArgError::InvalidValue)?; @@ -499,9 +547,17 @@ macro_rules! impl_float_field { } )*}; } -impl_float_field!(f32, f64); +impl_float_field!(f32 => CValue::F32, f64 => CValue::F64); impl ConfigField for String { + fn c_value_optional(&self) -> CValue { + CValue::String(self.clone()) + } + + fn c_value_none() -> CValue { + CValue::NullString + } + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { *self = value.ok_or(ArgError::ValueRequired)?.to_string(); Ok(()) @@ -519,6 +575,13 @@ impl ConfigField for Option { _ => FieldKind::OptionalOther, }; + fn c_value(&self) -> CValue { + match self { + Some(v) => v.c_value_optional(), + None => T::c_value_none(), + } + } + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { // For optional fields, we just treat it as the child type. if let Some(v) = self { diff --git a/crates/ghostty-config/src/config.rs b/crates/ghostty-config/src/config.rs index 451368b..a41cbab 100644 --- a/crates/ghostty-config/src/config.rs +++ b/crates/ghostty-config/src/config.rs @@ -3810,6 +3810,16 @@ impl Default for Config { } impl Config { + /// The value of the config key in the form the C API exposes it (Zig: + /// `c_get.get`). `None` if there is no such key. + #[must_use] + pub fn c_value(&self, key: &str) -> Option { + ::FIELDS + .iter() + .find(|f| f.name == key) + .map(|f| (f.c_value)(self)) + } + /// Build the default configuration (Zig: `Config.default`). #[must_use] pub fn new_default() -> Config { @@ -5412,4 +5422,85 @@ mod tests { assert!(out.contains("\nfont-size = 12\n")); assert!(out.contains("\nbackground = #282c34\n")); } + + /// The keys `ghostty_config_get` supports, derived from the Zig field + /// types and `c_get.getValue` (see `testdata/c_get_supported.txt`). + #[test] + fn c_value_supported_keys_match_zig() { + use crate::{c_value::CValue, macros::ConfigStruct}; + let expected: std::collections::BTreeSet<&str> = + include_str!("testdata/c_get_supported.txt") + .lines() + .filter(|l| !l.is_empty()) + .collect(); + let cfg = Config::default(); + let mut actual = std::collections::BTreeSet::new(); + for f in ::FIELDS { + if !matches!((f.c_value)(&cfg), CValue::Unsupported) { + actual.insert(f.name); + } + } + let missing: Vec<_> = expected.difference(&actual).collect(); + let extra: Vec<_> = actual.difference(&expected).collect(); + assert!( + missing.is_empty() && extra.is_empty(), + "missing={missing:?} extra={extra:?}" + ); + } + + #[test] + fn c_value_basic_types() { + use crate::c_value::CValue; + let mut cfg = Config::default(); + cfg.maximize = true; + assert_eq!(cfg.c_value("maximize"), Some(CValue::Bool(true))); + cfg.window_theme = WindowTheme::Dark; + assert_eq!(cfg.c_value("window-theme"), Some(CValue::EnumName("dark"))); + cfg.title = None; + assert_eq!(cfg.c_value("title"), Some(CValue::NullString)); + cfg.unfocused_split_fill = None; + assert_eq!(cfg.c_value("unfocused-split-fill"), Some(CValue::Null)); + cfg.background_opacity = 0.42; + assert_eq!(cfg.c_value("background-opacity"), Some(CValue::F64(0.42))); + cfg.background = crate::types_a::Color { + r: 12, + g: 34, + b: 56, + }; + assert_eq!( + cfg.c_value("background"), + Some(CValue::Color { + r: 12, + g: 34, + b: 56 + }) + ); + assert_eq!(cfg.c_value("not-a-real-key"), None); + assert_eq!(cfg.c_value("font-family"), Some(CValue::Unsupported)); + } + + #[test] + fn c_value_background_blur() { + use crate::{c_value::CValue, types_b::BackgroundBlur}; + let mut cfg = Config::default(); + for (v, expect) in [ + (BackgroundBlur::False, 0), + (BackgroundBlur::True, 20), + (BackgroundBlur::Radius(42), 42), + (BackgroundBlur::MacosGlassRegular, -1), + (BackgroundBlur::MacosGlassClear, -2), + ] { + cfg.background_blur = v; + assert_eq!(cfg.c_value("background-blur"), Some(CValue::Short(expect))); + } + } + + #[test] + fn c_value_split_preserve_zoom() { + use crate::c_value::CValue; + let mut cfg = Config::default(); + assert_eq!(cfg.c_value("split-preserve-zoom"), Some(CValue::Packed(0))); + cfg.split_preserve_zoom.navigation = true; + assert_eq!(cfg.c_value("split-preserve-zoom"), Some(CValue::Packed(1))); + } } diff --git a/crates/ghostty-config/src/input_fields.rs b/crates/ghostty-config/src/input_fields.rs index 2c8e0af..89b9284 100644 --- a/crates/ghostty-config/src/input_fields.rs +++ b/crates/ghostty-config/src/input_fields.rs @@ -6,6 +6,7 @@ use std::fmt; use ghostty_input::{KeyRemapSet, OptionAsAlt}; use crate::{ + c_value::CValue, cli_args::{ArgError, ConfigField}, formatter::EntryFormatter, }; @@ -30,6 +31,15 @@ impl ConfigField for KeyRemapSet { impl ConfigField for OptionAsAlt { const VALID_VALUES: Option<&'static [&'static str]> = None; + fn c_value(&self) -> CValue { + CValue::EnumName(match self { + OptionAsAlt::False => "false", + OptionAsAlt::True => "true", + OptionAsAlt::Left => "left", + OptionAsAlt::Right => "right", + }) + } + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { let v = value.ok_or(ArgError::ValueRequired)?; *self = match v { diff --git a/crates/ghostty-config/src/lib.rs b/crates/ghostty-config/src/lib.rs index 8cef046..cea66d2 100644 --- a/crates/ghostty-config/src/lib.rs +++ b/crates/ghostty-config/src/lib.rs @@ -13,6 +13,7 @@ )] // Generic CLI parsing infrastructure. +pub mod c_value; pub mod cli_args; pub mod comma_splitter; pub mod diagnostics; diff --git a/crates/ghostty-config/src/macros.rs b/crates/ghostty-config/src/macros.rs index d6b1a98..e2365d5 100644 --- a/crates/ghostty-config/src/macros.rs +++ b/crates/ghostty-config/src/macros.rs @@ -37,6 +37,8 @@ pub struct FieldInfo { pub eq: fn(&T, &T) -> bool, /// Format this field as in `+show-config`. pub format: fn(&T, &str, &mut dyn fmt::Write) -> fmt::Result, + /// The value in the form the C API exposes it. + pub c_value: fn(&T) -> crate::c_value::CValue, } impl FieldInfo { @@ -147,6 +149,7 @@ macro_rules! config_struct { let mut f = $crate::formatter::EntryFormatter::new(name, w); $crate::cli_args::ConfigField::format_entry(&a.$field, &mut f) }, + c_value: |a: &$name| $crate::cli_args::ConfigField::c_value(&a.$field), }, )* ]; @@ -221,6 +224,10 @@ macro_rules! config_enum { ::core::result::Result::Ok(()) } + fn c_value(&self) -> $crate::c_value::CValue { + $crate::c_value::CValue::EnumName(self.as_str()) + } + fn format_entry( &self, f: &mut $crate::formatter::EntryFormatter<'_>, @@ -274,6 +281,20 @@ macro_rules! config_packed { const KIND: $crate::cli_args::FieldKind = $crate::cli_args::FieldKind::Packed(Self::NAMES); + fn c_value(&self) -> $crate::c_value::CValue { + // Packed bools: the first field is bit 0. + let mut bits = 0u32; + let mut _i = 0u32; + $( + if self.$field && _i < 32 { bits |= 1 << _i; } + _i += 1; + )* + if _i > 32 { + return $crate::c_value::CValue::Unsupported; + } + $crate::c_value::CValue::Packed(bits) + } + fn parse_cli( &mut self, value: ::core::option::Option<&str>, diff --git a/crates/ghostty-config/src/path.rs b/crates/ghostty-config/src/path.rs index 7e1ce2d..614d604 100644 --- a/crates/ghostty-config/src/path.rs +++ b/crates/ghostty-config/src/path.rs @@ -6,6 +6,7 @@ use std::{ }; use crate::{ + c_value::CValue, cli_args::{ArgError, ConfigField}, diagnostics::{Diagnostic, DiagnosticList}, formatter::EntryFormatter, @@ -171,6 +172,19 @@ impl Path { } impl ConfigField for Path { + fn c_value(&self) -> CValue { + match self { + Path::Optional(p) => CValue::Path { + path: p.clone(), + optional: true, + }, + Path::Required(p) => CValue::Path { + path: p.clone(), + optional: false, + }, + } + } + /// Parse CLI option. The value will be overwritten. fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { let Some(item) = Path::parse(input)? else { diff --git a/crates/ghostty-config/src/repeatable_string_map.rs b/crates/ghostty-config/src/repeatable_string_map.rs index 97d4dab..afb6168 100644 --- a/crates/ghostty-config/src/repeatable_string_map.rs +++ b/crates/ghostty-config/src/repeatable_string_map.rs @@ -46,6 +46,14 @@ impl RepeatableStringMap { .map(|(_, v)| v.as_str()) } + /// Insert or replace an entry (Zig: `map.put`). + pub fn put(&mut self, key: &str, value: &str) { + match self.map.iter_mut().find(|(k, _)| k == key) { + Some((_, v)) => value.clone_into(v), + None => self.map.push((key.to_owned(), value.to_owned())), + } + } + /// Iterator over the entries in the map (insertion order). pub fn iter(&self) -> impl Iterator { self.map.iter().map(|(k, v)| (k.as_str(), v.as_str())) diff --git a/crates/ghostty-config/src/testdata/c_get_supported.txt b/crates/ghostty-config/src/testdata/c_get_supported.txt new file mode 100644 index 0000000..1b3f95c --- /dev/null +++ b/crates/ghostty-config/src/testdata/c_get_supported.txt @@ -0,0 +1,143 @@ +language +font-synthetic-style +font-size +font-thicken +font-thicken-strength +font-shaping-break +alpha-blending +grapheme-width-method +freetype-load-flags +background +foreground +background-image +background-image-opacity +background-image-position +background-image-fit +background-image-repeat +selection-clear-on-typing +selection-clear-on-copy +minimum-contrast +palette +palette-generate +palette-harmonious +cursor-opacity +cursor-style +cursor-style-blink +cursor-click-to-move +mouse-hide-while-typing +scroll-to-bottom +mouse-shift-capture +mouse-reporting +background-opacity +background-opacity-cells +background-blur +unfocused-split-opacity +unfocused-split-fill +split-divider-color +split-preserve-zoom +notify-on-command-finish +notify-on-command-finish-action +notify-on-command-finish-after +wait-after-command +abnormal-command-exit-runtime +scrollbar +link-url +link-previews +maximize +fullscreen +title +class +x11-instance-name +window-padding-balance +window-padding-color +window-vsync +window-inherit-working-directory +tab-inherit-working-directory +split-inherit-working-directory +window-inherit-font-size +window-decoration +window-title-font-family +window-subtitle +window-theme +window-colorspace +window-height +window-width +window-position-x +window-position-y +window-save-state +window-step-resize +window-new-tab-position +window-show-tab-bar +window-titlebar-background +window-titlebar-foreground +resize-overlay +resize-overlay-position +resize-overlay-duration +focus-follows-mouse +clipboard-read +clipboard-write +clipboard-trim-trailing-spaces +clipboard-paste-protection +clipboard-paste-bracketed-safe +title-report +image-storage-limit +copy-on-select +right-click-action +click-repeat-interval +config-default-files +confirm-close-surface +quit-after-last-window-closed +quit-after-last-window-closed-delay +initial-window +undo-timeout +quick-terminal-position +quick-terminal-size +gtk-quick-terminal-layer +quick-terminal-screen +quick-terminal-animation-duration +quick-terminal-autohide +quick-terminal-space-behavior +quick-terminal-keyboard-interactivity +shell-integration +shell-integration-features +command-palette-entry +osc-color-report-format +vt-kam-allowed +custom-shader-animation +bell-features +bell-audio-path +bell-audio-volume +app-notifications +macos-non-native-fullscreen +macos-window-buttons +macos-titlebar-style +macos-titlebar-proxy-icon +macos-dock-drop-behavior +macos-option-as-alt +macos-window-shadow +macos-hidden +macos-auto-secure-input +macos-secure-input-indication +macos-applescript +macos-icon +macos-custom-icon +macos-icon-frame +macos-icon-ghost-color +macos-icon-screen-color +macos-shortcuts +linux-cgroup +linux-cgroup-hard-fail +gtk-opengl-debug +gtk-single-instance +gtk-titlebar +gtk-tabs-location +gtk-titlebar-hide-when-maximized +gtk-toolbar-style +gtk-titlebar-style +gtk-wide-tabs +desktop-notifications +progress-style +faint-opacity +async-backend +auto-update +auto-update-channel diff --git a/crates/ghostty-config/src/types_a.rs b/crates/ghostty-config/src/types_a.rs index bbde9c6..d29ce83 100644 --- a/crates/ghostty-config/src/types_a.rs +++ b/crates/ghostty-config/src/types_a.rs @@ -7,6 +7,7 @@ use std::fmt; use ghostty_vt::color::{DEFAULT as DEFAULT_PALETTE, Palette as TermPalette, PaletteMask, Rgb}; use crate::{ + c_value::CValue, cli_args::{ArgError, ConfigField, parse_int_base0, trim_ws}, clipboard_codepoint_map::{ClipboardCodepointMap, Entry as ClipboardEntry, Replacement}, config_enum, @@ -35,6 +36,15 @@ impl ConfigField for CursorStyle { const VALID_VALUES: Option<&'static [&'static str]> = Some(&["bar", "block", "underline", "block_hollow"]); + fn c_value(&self) -> CValue { + CValue::EnumName(match self { + CursorStyle::Bar => "bar", + CursorStyle::Block => "block", + CursorStyle::Underline => "underline", + CursorStyle::BlockHollow => "block_hollow", + }) + } + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { let v = value.ok_or(ArgError::ValueRequired)?; *self = match v { @@ -290,6 +300,14 @@ impl Color { } impl ConfigField for Color { + fn c_value(&self) -> CValue { + CValue::Color { + r: self.r, + g: self.g, + b: self.b, + } + } + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { *self = Color::from_cli(input)?; Ok(()) @@ -394,6 +412,10 @@ pub struct ColorList { } impl ConfigField for ColorList { + fn c_value(&self) -> CValue { + CValue::ColorList(self.colors.iter().map(|c| [c.r, c.g, c.b]).collect()) + } + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { let input = input.ok_or(ArgError::ValueRequired)?; if input.is_empty() { @@ -454,6 +476,14 @@ impl Default for Palette { } impl ConfigField for Palette { + fn c_value(&self) -> CValue { + let mut out = Box::new([[0u8; 3]; 256]); + for (dst, c) in out.iter_mut().zip(self.value.iter()) { + *dst = [c.r, c.g, c.b]; + } + CValue::Palette(out) + } + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { let value = input.ok_or(ArgError::ValueRequired)?; let eql_idx = value.find('=').ok_or(ArgError::InvalidValue)?; diff --git a/crates/ghostty-config/src/types_b.rs b/crates/ghostty-config/src/types_b.rs index ae93ac3..4252499 100644 --- a/crates/ghostty-config/src/types_b.rs +++ b/crates/ghostty-config/src/types_b.rs @@ -8,6 +8,7 @@ use std::fmt; use ghostty_input::binding::Action; use crate::{ + c_value::{CValue, QuickTerminalSizeC}, cli_args::{ArgError, ConfigField, parse_auto_struct_with, parse_bool, trim_ws}, config_enum, config_packed, @@ -152,6 +153,15 @@ impl RepeatableCommand { } impl ConfigField for RepeatableCommand { + fn c_value(&self) -> CValue { + CValue::Commands( + self.value + .iter() + .map(ghostty_input::command::Command::cval) + .collect(), + ) + } + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { // Unset or empty input clears the list let input = input.unwrap_or(""); @@ -604,6 +614,18 @@ impl QuickTerminalSize { } impl ConfigField for QuickTerminalSize { + fn c_value(&self) -> CValue { + let conv = |v: Option| match v { + None => QuickTerminalSizeC::None, + Some(QuickTerminalSizeValue::Percentage(p)) => QuickTerminalSizeC::Percentage(p), + Some(QuickTerminalSizeValue::Pixels(p)) => QuickTerminalSizeC::Pixels(p), + }; + CValue::QuickTerminalSize { + primary: conv(self.primary), + secondary: conv(self.secondary), + } + } + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { let input = input.ok_or(ArgError::ValueRequired)?; let mut it = input.split(','); @@ -786,6 +808,17 @@ impl BackgroundBlur { } impl ConfigField for BackgroundBlur { + fn c_value(&self) -> CValue { + // Sentinel values for the macOS glass styles, like Zig's `cval`. + CValue::Short(match self { + BackgroundBlur::False => 0, + BackgroundBlur::True => 20, + BackgroundBlur::Radius(v) => i16::from(*v), + BackgroundBlur::MacosGlassRegular => -1, + BackgroundBlur::MacosGlassClear => -2, + }) + } + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { let Some(input) = input else { // Emulate behavior for bools @@ -882,6 +915,10 @@ impl WindowDecoration { impl ConfigField for WindowDecoration { const VALID_VALUES: Option<&'static [&'static str]> = Some(Self::NAMES); + fn c_value(&self) -> CValue { + CValue::EnumName(self.as_str()) + } + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { *self = Self::parse(input)?; Ok(()) @@ -1127,6 +1164,10 @@ impl fmt::Display for Duration { } impl ConfigField for Duration { + fn c_value(&self) -> CValue { + CValue::Usize(self.as_milliseconds() as usize) + } + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { *self = Duration::parse(input)?; Ok(()) @@ -1267,6 +1308,47 @@ mod tests { assert_eq!(list.value.len(), 0); } + #[test] + fn repeatable_command_cval() { + let mut list = RepeatableCommand::default(); + list.parse_cli(Some("title:Foo,action:ignore")).unwrap(); + list.parse_cli(Some("title:Bar,description:bobr,action:text:ale bydle")) + .unwrap(); + assert_eq!(list.value.len(), 2); + + let CValue::Commands(cv) = list.c_value() else { + panic!("expected commands"); + }; + assert_eq!(cv.len(), 2); + + // First entry + assert_eq!(cv[0].title, "Foo"); + assert_eq!(cv[0].action_key, "ignore"); + assert_eq!(cv[0].action, "ignore"); + + // Second entry + assert_eq!(cv[1].title, "Bar"); + assert_eq!(cv[1].description, "bobr"); + assert_eq!(cv[1].action_key, "text"); + assert_eq!(cv[1].action, "text:ale bydle"); + } + + #[test] + fn repeatable_command_cval_cleared() { + let mut list = RepeatableCommand::default(); + list.parse_cli(Some("title:Foo,action:ignore")).unwrap(); + let CValue::Commands(cv) = list.c_value() else { + panic!("expected commands"); + }; + assert_eq!(cv.len(), 1); + + list.parse_cli(Some("")).unwrap(); + let CValue::Commands(cv) = list.c_value() else { + panic!("expected commands"); + }; + assert_eq!(cv.len(), 0); + } + #[test] fn repeatable_command_format_config_empty() { let list = RepeatableCommand::default(); diff --git a/crates/ghostty-core/Cargo.toml b/crates/ghostty-core/Cargo.toml index 098637c..89a2cea 100644 --- a/crates/ghostty-core/Cargo.toml +++ b/crates/ghostty-core/Cargo.toml @@ -30,3 +30,4 @@ workspace = true [target.'cfg(target_vendor = "apple")'.dependencies] oslog = { version = "0.2.0", default-features = false } +objc2-core-foundation = "0.3.2" diff --git a/crates/ghostty-core/src/app.rs b/crates/ghostty-core/src/app.rs index 2737244..cd38ed6 100644 --- a/crates/ghostty-core/src/app.rs +++ b/crates/ghostty-core/src/app.rs @@ -488,6 +488,27 @@ impl App { } } + /// Create a surface and add it to the app (`CoreApp::new_surface` with + /// the concrete surface type, for runtimes that need more than the + /// [`CoreSurface`] trait, e.g. the embedded C API). + pub fn create_surface( + &self, + id: SurfaceId, + config: Config, + rt_app: Rc, + rt_surface: Rc, + ) -> Result, CoreError> { + let surface = Surface::new( + id, + Rc::clone(&self.state), + Arc::new(config), + Rc::clone(&rt_app), + rt_surface, + )?; + self.add_surface(id, &surface, &rt_app); + Ok(surface) + } + /// Add an initialized surface. Since we have non-zero surfaces, the quit /// timer is cancelled. pub(crate) fn add_surface(&self, id: SurfaceId, surface: &Rc, rt: &Rc) { @@ -589,15 +610,7 @@ impl CoreApp for App { rt_app: Rc, rt_surface: Rc, ) -> Result, CoreError> { - let surface = Surface::new( - id, - Rc::clone(&self.state), - Arc::new(config), - Rc::clone(&rt_app), - rt_surface, - )?; - self.add_surface(id, &surface, &rt_app); - Ok(surface) + Ok(self.create_surface(id, config, rt_app, rt_surface)?) } fn delete_surface(&self, id: SurfaceId) { diff --git a/crates/ghostty-core/src/global.rs b/crates/ghostty-core/src/global.rs index 596fa81..4b55066 100644 --- a/crates/ghostty-core/src/global.rs +++ b/crates/ghostty-core/src/global.rs @@ -38,6 +38,13 @@ impl Logging { /// invalid value (including an empty part) yields the default. #[must_use] pub fn parse(v: &str) -> Logging { + Logging::parse_with(v, Logging::default()) + } + + /// [`Logging::parse`] with an explicit default (lib mode defaults to no + /// stderr logging). + #[must_use] + pub fn parse_with(v: &str, default: Logging) -> Logging { match v { "1" | "t" | "T" | "true" => { return Logging { @@ -53,7 +60,7 @@ impl Logging { } _ => {} } - let mut out = Logging::default(); + let mut out = default; for part in v.split(',') { let part = part.trim_matches([' ', '\t']); let (name, value) = match part.strip_prefix("no-") { @@ -63,7 +70,7 @@ impl Logging { match name { "stderr" => out.stderr = value, "macos" => out.macos = value, - _ => return Logging::default(), + _ => return default, } } out @@ -134,8 +141,25 @@ pub fn state() -> &'static GlobalState { /// Initialize the global state: logging, rlimits, resources dir and i18n. /// Call once, early (Zig: `GlobalState.init`). pub fn init(opts: InitOptions) -> &'static GlobalState { + init_with(opts, Logging::default()) +} + +/// [`init`] for the embedded library (`ghostty_init`): Zig's +/// `GlobalState.Logging.stderr` defaults to `build_config.app_runtime != +/// .none`, i.e. off for the lib artifact. +pub fn init_lib(opts: InitOptions) -> &'static GlobalState { + init_with( + opts, + Logging { + stderr: false, + ..Logging::default() + }, + ) +} + +fn init_with(opts: InitOptions, default_logging: Logging) -> &'static GlobalState { STATE.get_or_init(|| { - let mut logging = Logging::default(); + let mut logging = default_logging; // If we have an action executing, we disable logging by default // since we write to stderr we don't want logs messing up our @@ -146,7 +170,7 @@ pub fn init(opts: InitOptions) -> &'static GlobalState { // Env vars are useful for logging too because they are easy to set. if let Ok(v) = std::env::var("GHOSTTY_LOG") { - logging = Logging::parse(&v); + logging = Logging::parse_with(&v, default_logging); } init_logger(logging); diff --git a/crates/ghostty-core/src/surface/mod.rs b/crates/ghostty-core/src/surface/mod.rs index 2f75a07..1d521ed 100644 --- a/crates/ghostty-core/src/surface/mod.rs +++ b/crates/ghostty-core/src/surface/mod.rs @@ -89,14 +89,13 @@ use ghostty_font::{ }; use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods}; use ghostty_renderer::{ + Api as GraphicsApi, Health, - // The generic renderer over the OpenGL API (the GTK runtime's graphics - // API). `ghostty_renderer::Renderer` is the platform default, which is - // Metal on Apple targets; the embedded (macOS) runtime provides its own - // Metal-based surface setup (TODO(integration): L1). - generic::{Host as RendererHost, Renderer}, + // The renderer over the graphics API selected for the target (Zig: + // `renderer.zig`): Metal on Apple targets, OpenGL elsewhere. + Renderer, + generic::Host as RendererHost, message::{FontGridChange, Message as RendererMessage}, - opengl::OpenGL, options::Options as RendererOptions, size::{CellSize, Coordinate, Padding, ScreenSize, Size}, state::{Preedit, PreeditCodepoint, State as RenderStateData}, @@ -197,6 +196,31 @@ impl RendererHost for HostImpl { } } +/// Create the graphics API for a new surface (Zig: `Renderer.surfaceInit`). +#[cfg(not(target_vendor = "apple"))] +fn init_graphics_api( + config: &Config, + rt_surface: &dyn RtSurface, +) -> Result { + let mut api = GraphicsApi::init(config.alpha_blending); + api.prepare_context(|name| rt_surface.gl_proc_address(name)) + .map_err(|e| format!("failed to prepare the OpenGL context: {e}"))?; + Ok(api) +} + +/// Create the graphics API for a new surface (Zig: `Renderer.surfaceInit`): +/// the Metal layer is attached to the NSView/UIView the host provided. +#[cfg(target_vendor = "apple")] +fn init_graphics_api( + config: &Config, + rt_surface: &dyn RtSurface, +) -> Result { + let view = rt_surface.platform_view(); + let scale = f64::from(rt_surface.get_content_scale().x); + GraphicsApi::init_with_view(config.alpha_blending, view, scale) + .map_err(|e| format!("failed to initialize the Metal renderer: {e}").into()) +} + /// Font state of the surface. pub struct FontState { /// The key of the font grid we hold a reference on. @@ -333,13 +357,8 @@ impl Surface { // Initialize our renderer with our initialized surface (Zig: // `Renderer.surfaceInit`): load the GL functions for the current - // context. - let mut api = OpenGL::init(config.alpha_blending); - { - let rt = Rc::clone(&rt_surface); - api.prepare_context(|name| rt.gl_proc_address(name)) - .map_err(|e| format!("failed to prepare the OpenGL context: {e}"))?; - } + // context (OpenGL) or attach the Metal layer to the host's view. + let api = init_graphics_api(&config, rt_surface.as_ref())?; // Determine our DPI configurations so we can properly configure // font points to pixels and handle other high-DPI scaling factors. @@ -708,6 +727,34 @@ impl Surface { self.renderer_handle.send(msg); } + /// Tell the renderer which display the surface is on (the embedded + /// `ghostty_surface_set_display_id`). + #[cfg(target_vendor = "apple")] + pub fn set_macos_display_id(&self, id: u32) { + self.send_renderer(RendererMessage::MacosDisplayId(id)); + } + + /// A retained `CTFontRef` for the primary font, for macOS quicklook + /// (the embedded `ghostty_surface_quicklook_font`). `scale` is the + /// content scale. The caller owns the reference. + #[cfg(target_vendor = "apple")] + #[must_use] + pub fn quicklook_font(&self, scale: f32) -> Option<*mut std::ffi::c_void> { + let grid = self.renderer.font_grid(); + let mut state = grid.write(); + let face = state + .resolver + .collection + .get_face(ghostty_font::collection::Index::default()) + .ok()?; + let font = face.quicklook_font(f64::from(scale)); + Some( + objc2_core_foundation::CFRetained::into_raw(font) + .as_ptr() + .cast(), + ) + } + /// Current sizes. #[must_use] pub fn size(&self) -> Size { @@ -1810,6 +1857,14 @@ impl Surface { /// Write the last presented frame as a PNG. Requires the GL context to be /// current. + #[cfg(target_vendor = "apple")] + pub fn write_screenshot_png(&self, _path: &std::path::Path) -> Result<(), CoreError> { + Err("screenshots are not supported by the Metal renderer".into()) + } + + /// Write the last presented frame as a PNG. Requires the GL context to be + /// current. + #[cfg(not(target_vendor = "apple"))] pub fn write_screenshot_png(&self, path: &std::path::Path) -> Result<(), CoreError> { let Some((w, h, px)) = self.renderer.last_frame_pixels() else { return Err("no frame has been presented yet".into()); @@ -1832,11 +1887,13 @@ impl Surface { /// The pixels of the last presented frame (`width`, `height`, RGBA8, /// bottom-up). Test/debug hook for headless integration tests; requires /// the GL context to be current on the calling thread. + #[cfg(not(target_vendor = "apple"))] pub fn frame_pixels(&self) -> Option<(usize, usize, Vec)> { self.renderer.last_frame_pixels() } /// The GLArea was (re)realized; the GL context is current. + #[cfg(not(target_vendor = "apple"))] pub fn renderer_realized(&self) -> Result<(), CoreError> { let rt = Rc::clone(&self.rt_surface); self.renderer @@ -1844,6 +1901,13 @@ impl Surface { Ok(()) } + /// The display was (re)realized (Metal has nothing to reload). + #[cfg(target_vendor = "apple")] + pub fn renderer_realized(&self) -> Result<(), CoreError> { + self.renderer.display_realized_with(|_| {})?; + Ok(()) + } + /// The GLArea is being unrealized; the GL context is current. pub fn renderer_unrealized(&self) { self.renderer.display_unrealized(); diff --git a/crates/ghostty-core/tests/e2e_bindings.rs b/crates/ghostty-core/tests/e2e_bindings.rs index a18a707..b0ab596 100644 --- a/crates/ghostty-core/tests/e2e_bindings.rs +++ b/crates/ghostty-core/tests/e2e_bindings.rs @@ -1,5 +1,8 @@ //! Keybinding actions end to end (default and configured bindings). +// These drive the real surface over a headless OpenGL context; the Apple targets render +// with Metal. +#![cfg(not(target_vendor = "apple"))] #![allow(clippy::unwrap_used, clippy::expect_used)] use std::fmt::Write as _; diff --git a/crates/ghostty-core/tests/e2e_ime.rs b/crates/ghostty-core/tests/e2e_ime.rs index de82290..bf867b2 100644 --- a/crates/ghostty-core/tests/e2e_ime.rs +++ b/crates/ghostty-core/tests/e2e_ime.rs @@ -1,5 +1,8 @@ //! IME: preedit state and rendering, commit, composing key events. +// These drive the real surface over a headless OpenGL context; the Apple targets render +// with Metal. +#![cfg(not(target_vendor = "apple"))] #![allow(clippy::unwrap_used, clippy::expect_used)] mod common; diff --git a/crates/ghostty-core/tests/e2e_inspector.rs b/crates/ghostty-core/tests/e2e_inspector.rs index 5bd283d..386d6d4 100644 --- a/crates/ghostty-core/tests/e2e_inspector.rs +++ b/crates/ghostty-core/tests/e2e_inspector.rs @@ -2,6 +2,9 @@ //! recording (with the pty encoding and triggered bindings) and VT event //! recording while data is read from the pty. +// These drive the real surface over a headless OpenGL context; the Apple targets render +// with Metal. +#![cfg(not(target_vendor = "apple"))] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::assert_is_empty)] mod common; diff --git a/crates/ghostty-core/tests/e2e_keys.rs b/crates/ghostty-core/tests/e2e_keys.rs index a103340..a6bd25f 100644 --- a/crates/ghostty-core/tests/e2e_keys.rs +++ b/crates/ghostty-core/tests/e2e_keys.rs @@ -1,6 +1,9 @@ //! Keyboard input end to end: key events -> core Surface -> encoder -> pty -> //! a real `cat` child that records the exact bytes it received. +// These drive the real surface over a headless OpenGL context; the Apple targets render +// with Metal. +#![cfg(not(target_vendor = "apple"))] #![allow(clippy::unwrap_used, clippy::expect_used)] mod common; diff --git a/crates/ghostty-core/tests/e2e_mouse.rs b/crates/ghostty-core/tests/e2e_mouse.rs index 6c99144..2342b5f 100644 --- a/crates/ghostty-core/tests/e2e_mouse.rs +++ b/crates/ghostty-core/tests/e2e_mouse.rs @@ -1,6 +1,9 @@ //! Mouse end to end: selection, copy-on-select, multi-click, mouse reporting //! formats, alternate scroll, shift bypass and links. +// These drive the real surface over a headless OpenGL context; the Apple targets render +// with Metal. +#![cfg(not(target_vendor = "apple"))] #![allow(clippy::unwrap_used, clippy::expect_used)] mod common; diff --git a/crates/ghostty-core/tests/e2e_term.rs b/crates/ghostty-core/tests/e2e_term.rs index 3c948a9..0c8e83e 100644 --- a/crates/ghostty-core/tests/e2e_term.rs +++ b/crates/ghostty-core/tests/e2e_term.rs @@ -1,6 +1,9 @@ //! Terminal <-> runtime events: resize, focus, title, pwd, bell, OSC 52, //! notifications, progress, child exit. +// These drive the real surface over a headless OpenGL context; the Apple targets render +// with Metal. +#![cfg(not(target_vendor = "apple"))] #![allow(clippy::unwrap_used, clippy::expect_used)] mod common; diff --git a/crates/ghostty-font/src/face/coretext.rs b/crates/ghostty-font/src/face/coretext.rs index 6c733b4..81f492d 100644 --- a/crates/ghostty-font/src/face/coretext.rs +++ b/crates/ghostty-font/src/face/coretext.rs @@ -697,6 +697,20 @@ impl Face { Ok(util::font_table_bytes(&self.font, tag)) } + /// A copy of this face's font at `size / scale` points: the font to use + /// for macOS quicklook (`ghostty_surface_quicklook_font`). The content + /// scale is applied to our font stack because we render at 1x, but the + /// callers of the quicklook font apply the scale themselves. + pub fn quicklook_font(&self, scale: f64) -> CFRetained { + // SAFETY: plain CoreText getter; null matrix and no descriptor are + // allowed for the copy. + unsafe { + let size = self.font.size(); + self.font + .copy_with_attributes(size / scale, ptr::null(), None) + } + } + /// The underlying CTFont (for shapers). pub(crate) fn ct_font(&self) -> &CTFont { &self.font diff --git a/crates/ghostty-lib/Cargo.toml b/crates/ghostty-lib/Cargo.toml new file mode 100644 index 0000000..5f86f1d --- /dev/null +++ b/crates/ghostty-lib/Cargo.toml @@ -0,0 +1,85 @@ +[package] +name = "ghostty-lib" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "libghostty: the embedded C API (include/ghostty.h) used by the macOS/iOS app, ported from apprt/embedded.zig and main_c.zig" + +# Produces `libghostty.{so,a,dylib}`, the names the upstream build ships. +[lib] +name = "ghostty" +crate-type = ["cdylib", "staticlib", "rlib"] + +[features] +# Test seam (off by default): accepts a fake platform for `ghostty_surface_new` +# on non-Apple targets whose OpenGL functions are supplied by the test (via a +# headless EGL context), so the embedded surface flow can be tested on Linux. +# Zig rejects every platform off Darwin; production builds are unaffected. +test-platform = [] + +[dependencies] +ghostty-apprt.workspace = true +ghostty-bench.workspace = true +ghostty-cli.workspace = true +ghostty-config.workspace = true +ghostty-core.workspace = true +ghostty-input.workspace = true +ghostty-inspector.workspace = true +ghostty-os.workspace = true +ghostty-renderer.workspace = true +ghostty-terminal.workspace = true +ghostty-vt.workspace = true +log = "0.4.34" + +[target.'cfg(target_vendor = "apple")'.dependencies] +block2 = "0.6.2" +dispatch2 = "0.3.1" +objc2 = "0.6.5" +objc2-core-foundation = "0.3.2" +objc2-foundation = "0.3.2" +objc2-metal = { version = "0.3.2", features = ["block2"] } + +[dev-dependencies] +tempfile = "3" + +# The workspace forbids `unsafe`; this crate is the C ABI boundary, so it +# denies it instead and allows it (with SAFETY comments) in the FFI modules. +# Everything else is copied from `[workspace.lints]`. +[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" +# The exported functions are `unsafe extern "C"` mirroring the header contracts. +missing_safety_doc = "allow" +# `&mut x` coerced to `*mut T` is the idiomatic way to pass out-params in FFI tests. +borrow_as_ptr = "allow" +# Allocations are made with the layout of the pointee, so the cast is aligned. +cast_ptr_alignment = "allow" + +[[test]] +name = "surface" +required-features = ["test-platform"] diff --git a/crates/ghostty-lib/include/ghostty.h b/crates/ghostty-lib/include/ghostty.h new file mode 100644 index 0000000..40ff55c --- /dev/null +++ b/crates/ghostty-lib/include/ghostty.h @@ -0,0 +1,1178 @@ +// Ghostty embedding API. The documentation for the embedding API is +// only within the Zig source files that define the implementations. This +// isn't meant to be a general purpose embedding API (yet) so there hasn't +// been documentation or example work beyond that. +// +// The only consumer of this API is the macOS app, but the API is built to +// be more general purpose. +#ifndef GHOSTTY_H +#define GHOSTTY_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include +#include + +//------------------------------------------------------------------- +// Macros + +#define GHOSTTY_SUCCESS 0 + +//------------------------------------------------------------------- +// Types + +// Opaque types +typedef void* ghostty_app_t; +typedef void* ghostty_config_t; +typedef void* ghostty_surface_t; +typedef void* ghostty_inspector_t; + +// All the types below are fully defined and must be kept in sync with +// their Zig counterparts. Any changes to these types MUST have an associated +// Zig change. +typedef enum { + GHOSTTY_PLATFORM_INVALID, + GHOSTTY_PLATFORM_MACOS, + GHOSTTY_PLATFORM_IOS, +} ghostty_platform_e; + +typedef enum { + GHOSTTY_CLIPBOARD_STANDARD, + GHOSTTY_CLIPBOARD_SELECTION, +} ghostty_clipboard_e; + +typedef struct { + const char *mime; + const char *data; +} ghostty_clipboard_content_s; + +typedef enum { + GHOSTTY_CLIPBOARD_REQUEST_PASTE, + GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, + GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, +} ghostty_clipboard_request_e; + +typedef enum { + GHOSTTY_MOUSE_RELEASE, + GHOSTTY_MOUSE_PRESS, +} ghostty_input_mouse_state_e; + +typedef enum { + GHOSTTY_MOUSE_UNKNOWN, + GHOSTTY_MOUSE_LEFT, + GHOSTTY_MOUSE_RIGHT, + GHOSTTY_MOUSE_MIDDLE, + GHOSTTY_MOUSE_FOUR, + GHOSTTY_MOUSE_FIVE, + GHOSTTY_MOUSE_SIX, + GHOSTTY_MOUSE_SEVEN, + GHOSTTY_MOUSE_EIGHT, + GHOSTTY_MOUSE_NINE, + GHOSTTY_MOUSE_TEN, + GHOSTTY_MOUSE_ELEVEN, +} ghostty_input_mouse_button_e; + +typedef enum { + GHOSTTY_MOUSE_MOMENTUM_NONE, + GHOSTTY_MOUSE_MOMENTUM_BEGAN, + GHOSTTY_MOUSE_MOMENTUM_STATIONARY, + GHOSTTY_MOUSE_MOMENTUM_CHANGED, + GHOSTTY_MOUSE_MOMENTUM_ENDED, + GHOSTTY_MOUSE_MOMENTUM_CANCELLED, + GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN, +} ghostty_input_mouse_momentum_e; + +typedef enum { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, +} ghostty_color_scheme_e; + +// This is a packed struct (see src/input/mouse.zig) but the C standard +// afaik doesn't let us reliably define packed structs so we build it up +// from scratch. +typedef int ghostty_input_scroll_mods_t; + +typedef enum { + GHOSTTY_MODS_NONE = 0, + GHOSTTY_MODS_SHIFT = 1 << 0, + GHOSTTY_MODS_CTRL = 1 << 1, + GHOSTTY_MODS_ALT = 1 << 2, + GHOSTTY_MODS_SUPER = 1 << 3, + GHOSTTY_MODS_CAPS = 1 << 4, + GHOSTTY_MODS_NUM = 1 << 5, + GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6, + GHOSTTY_MODS_CTRL_RIGHT = 1 << 7, + GHOSTTY_MODS_ALT_RIGHT = 1 << 8, + GHOSTTY_MODS_SUPER_RIGHT = 1 << 9, +} ghostty_input_mods_e; + +typedef enum { + GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0, + GHOSTTY_BINDING_FLAGS_ALL = 1 << 1, + GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2, + GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3, +} ghostty_binding_flags_e; + +typedef enum { + GHOSTTY_ACTION_RELEASE, + GHOSTTY_ACTION_PRESS, + GHOSTTY_ACTION_REPEAT, +} ghostty_input_action_e; + +// Based on: https://www.w3.org/TR/uievents-code/ +typedef enum { + GHOSTTY_KEY_UNIDENTIFIED, + + // "Writing System Keys" § 3.1.1 + GHOSTTY_KEY_BACKQUOTE, + GHOSTTY_KEY_BACKSLASH, + GHOSTTY_KEY_BRACKET_LEFT, + GHOSTTY_KEY_BRACKET_RIGHT, + GHOSTTY_KEY_COMMA, + GHOSTTY_KEY_DIGIT_0, + GHOSTTY_KEY_DIGIT_1, + GHOSTTY_KEY_DIGIT_2, + GHOSTTY_KEY_DIGIT_3, + GHOSTTY_KEY_DIGIT_4, + GHOSTTY_KEY_DIGIT_5, + GHOSTTY_KEY_DIGIT_6, + GHOSTTY_KEY_DIGIT_7, + GHOSTTY_KEY_DIGIT_8, + GHOSTTY_KEY_DIGIT_9, + GHOSTTY_KEY_EQUAL, + GHOSTTY_KEY_INTL_BACKSLASH, + GHOSTTY_KEY_INTL_RO, + GHOSTTY_KEY_INTL_YEN, + GHOSTTY_KEY_A, + GHOSTTY_KEY_B, + GHOSTTY_KEY_C, + GHOSTTY_KEY_D, + GHOSTTY_KEY_E, + GHOSTTY_KEY_F, + GHOSTTY_KEY_G, + GHOSTTY_KEY_H, + GHOSTTY_KEY_I, + GHOSTTY_KEY_J, + GHOSTTY_KEY_K, + GHOSTTY_KEY_L, + GHOSTTY_KEY_M, + GHOSTTY_KEY_N, + GHOSTTY_KEY_O, + GHOSTTY_KEY_P, + GHOSTTY_KEY_Q, + GHOSTTY_KEY_R, + GHOSTTY_KEY_S, + GHOSTTY_KEY_T, + GHOSTTY_KEY_U, + GHOSTTY_KEY_V, + GHOSTTY_KEY_W, + GHOSTTY_KEY_X, + GHOSTTY_KEY_Y, + GHOSTTY_KEY_Z, + GHOSTTY_KEY_MINUS, + GHOSTTY_KEY_PERIOD, + GHOSTTY_KEY_QUOTE, + GHOSTTY_KEY_SEMICOLON, + GHOSTTY_KEY_SLASH, + + // "Functional Keys" § 3.1.2 + GHOSTTY_KEY_ALT_LEFT, + GHOSTTY_KEY_ALT_RIGHT, + GHOSTTY_KEY_BACKSPACE, + GHOSTTY_KEY_CAPS_LOCK, + GHOSTTY_KEY_CONTEXT_MENU, + GHOSTTY_KEY_CONTROL_LEFT, + GHOSTTY_KEY_CONTROL_RIGHT, + GHOSTTY_KEY_ENTER, + GHOSTTY_KEY_META_LEFT, + GHOSTTY_KEY_META_RIGHT, + GHOSTTY_KEY_SHIFT_LEFT, + GHOSTTY_KEY_SHIFT_RIGHT, + GHOSTTY_KEY_SPACE, + GHOSTTY_KEY_TAB, + GHOSTTY_KEY_CONVERT, + GHOSTTY_KEY_KANA_MODE, + GHOSTTY_KEY_NON_CONVERT, + + // "Control Pad Section" § 3.2 + GHOSTTY_KEY_DELETE, + GHOSTTY_KEY_END, + GHOSTTY_KEY_HELP, + GHOSTTY_KEY_HOME, + GHOSTTY_KEY_INSERT, + GHOSTTY_KEY_PAGE_DOWN, + GHOSTTY_KEY_PAGE_UP, + + // "Arrow Pad Section" § 3.3 + GHOSTTY_KEY_ARROW_DOWN, + GHOSTTY_KEY_ARROW_LEFT, + GHOSTTY_KEY_ARROW_RIGHT, + GHOSTTY_KEY_ARROW_UP, + + // "Numpad Section" § 3.4 + GHOSTTY_KEY_NUM_LOCK, + GHOSTTY_KEY_NUMPAD_0, + GHOSTTY_KEY_NUMPAD_1, + GHOSTTY_KEY_NUMPAD_2, + GHOSTTY_KEY_NUMPAD_3, + GHOSTTY_KEY_NUMPAD_4, + GHOSTTY_KEY_NUMPAD_5, + GHOSTTY_KEY_NUMPAD_6, + GHOSTTY_KEY_NUMPAD_7, + GHOSTTY_KEY_NUMPAD_8, + GHOSTTY_KEY_NUMPAD_9, + GHOSTTY_KEY_NUMPAD_ADD, + GHOSTTY_KEY_NUMPAD_BACKSPACE, + GHOSTTY_KEY_NUMPAD_CLEAR, + GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, + GHOSTTY_KEY_NUMPAD_COMMA, + GHOSTTY_KEY_NUMPAD_DECIMAL, + GHOSTTY_KEY_NUMPAD_DIVIDE, + GHOSTTY_KEY_NUMPAD_ENTER, + GHOSTTY_KEY_NUMPAD_EQUAL, + GHOSTTY_KEY_NUMPAD_MEMORY_ADD, + GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, + GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, + GHOSTTY_KEY_NUMPAD_MEMORY_STORE, + GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, + GHOSTTY_KEY_NUMPAD_MULTIPLY, + GHOSTTY_KEY_NUMPAD_PAREN_LEFT, + GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, + GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_SEPARATOR, + GHOSTTY_KEY_NUMPAD_UP, + GHOSTTY_KEY_NUMPAD_DOWN, + GHOSTTY_KEY_NUMPAD_RIGHT, + GHOSTTY_KEY_NUMPAD_LEFT, + GHOSTTY_KEY_NUMPAD_BEGIN, + GHOSTTY_KEY_NUMPAD_HOME, + GHOSTTY_KEY_NUMPAD_END, + GHOSTTY_KEY_NUMPAD_INSERT, + GHOSTTY_KEY_NUMPAD_DELETE, + GHOSTTY_KEY_NUMPAD_PAGE_UP, + GHOSTTY_KEY_NUMPAD_PAGE_DOWN, + + // "Function Section" § 3.5 + GHOSTTY_KEY_ESCAPE, + GHOSTTY_KEY_F1, + GHOSTTY_KEY_F2, + GHOSTTY_KEY_F3, + GHOSTTY_KEY_F4, + GHOSTTY_KEY_F5, + GHOSTTY_KEY_F6, + GHOSTTY_KEY_F7, + GHOSTTY_KEY_F8, + GHOSTTY_KEY_F9, + GHOSTTY_KEY_F10, + GHOSTTY_KEY_F11, + GHOSTTY_KEY_F12, + GHOSTTY_KEY_F13, + GHOSTTY_KEY_F14, + GHOSTTY_KEY_F15, + GHOSTTY_KEY_F16, + GHOSTTY_KEY_F17, + GHOSTTY_KEY_F18, + GHOSTTY_KEY_F19, + GHOSTTY_KEY_F20, + GHOSTTY_KEY_F21, + GHOSTTY_KEY_F22, + GHOSTTY_KEY_F23, + GHOSTTY_KEY_F24, + GHOSTTY_KEY_F25, + GHOSTTY_KEY_FN, + GHOSTTY_KEY_FN_LOCK, + GHOSTTY_KEY_PRINT_SCREEN, + GHOSTTY_KEY_SCROLL_LOCK, + GHOSTTY_KEY_PAUSE, + + // "Media Keys" § 3.6 + GHOSTTY_KEY_BROWSER_BACK, + GHOSTTY_KEY_BROWSER_FAVORITES, + GHOSTTY_KEY_BROWSER_FORWARD, + GHOSTTY_KEY_BROWSER_HOME, + GHOSTTY_KEY_BROWSER_REFRESH, + GHOSTTY_KEY_BROWSER_SEARCH, + GHOSTTY_KEY_BROWSER_STOP, + GHOSTTY_KEY_EJECT, + GHOSTTY_KEY_LAUNCH_APP_1, + GHOSTTY_KEY_LAUNCH_APP_2, + GHOSTTY_KEY_LAUNCH_MAIL, + GHOSTTY_KEY_MEDIA_PLAY_PAUSE, + GHOSTTY_KEY_MEDIA_SELECT, + GHOSTTY_KEY_MEDIA_STOP, + GHOSTTY_KEY_MEDIA_TRACK_NEXT, + GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, + GHOSTTY_KEY_POWER, + GHOSTTY_KEY_SLEEP, + GHOSTTY_KEY_AUDIO_VOLUME_DOWN, + GHOSTTY_KEY_AUDIO_VOLUME_MUTE, + GHOSTTY_KEY_AUDIO_VOLUME_UP, + GHOSTTY_KEY_WAKE_UP, + + // "Legacy, Non-standard, and Special Keys" § 3.7 + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, +} ghostty_input_key_e; + +typedef struct { + ghostty_input_action_e action; + ghostty_input_mods_e mods; + ghostty_input_mods_e consumed_mods; + uint32_t keycode; + const char* text; + uint32_t unshifted_codepoint; + bool composing; +} ghostty_input_key_s; + +typedef enum { + GHOSTTY_TRIGGER_PHYSICAL, + GHOSTTY_TRIGGER_UNICODE, + GHOSTTY_TRIGGER_CATCH_ALL, +} ghostty_input_trigger_tag_e; + +typedef union { + ghostty_input_key_e translated; + ghostty_input_key_e physical; + uint32_t unicode; + // catch_all has no payload +} ghostty_input_trigger_key_u; + +typedef struct { + ghostty_input_trigger_tag_e tag; + ghostty_input_trigger_key_u key; + ghostty_input_mods_e mods; +} ghostty_input_trigger_s; + +typedef struct { + const char* action_key; + const char* action; + const char* title; + const char* description; +} ghostty_command_s; + +typedef enum { + GHOSTTY_BUILD_MODE_DEBUG, + GHOSTTY_BUILD_MODE_RELEASE_SAFE, + GHOSTTY_BUILD_MODE_RELEASE_FAST, + GHOSTTY_BUILD_MODE_RELEASE_SMALL, +} ghostty_build_mode_e; + +typedef struct { + ghostty_build_mode_e build_mode; + const char* version; + uintptr_t version_len; +} ghostty_info_s; + +typedef struct { + const char* message; +} ghostty_diagnostic_s; + +typedef struct { + const char* ptr; + uintptr_t len; + bool sentinel; +} ghostty_string_s; + +typedef struct { + double tl_px_x; + double tl_px_y; + uint32_t offset_start; + uint32_t offset_len; + const char* text; + uintptr_t text_len; +} ghostty_text_s; + +typedef enum { + GHOSTTY_POINT_ACTIVE, + GHOSTTY_POINT_VIEWPORT, + GHOSTTY_POINT_SCREEN, + GHOSTTY_POINT_SURFACE, +} ghostty_point_tag_e; + +typedef enum { + GHOSTTY_POINT_COORD_EXACT, + GHOSTTY_POINT_COORD_TOP_LEFT, + GHOSTTY_POINT_COORD_BOTTOM_RIGHT, +} ghostty_point_coord_e; + +typedef struct { + ghostty_point_tag_e tag; + ghostty_point_coord_e coord; + uint32_t x; + uint32_t y; +} ghostty_point_s; + +typedef struct { + ghostty_point_s top_left; + ghostty_point_s bottom_right; + bool rectangle; +} ghostty_selection_s; + +typedef struct { + const char* key; + const char* value; +} ghostty_env_var_s; + +typedef struct { + void* nsview; +} ghostty_platform_macos_s; + +typedef struct { + void* uiview; +} ghostty_platform_ios_s; + +typedef union { + ghostty_platform_macos_s macos; + ghostty_platform_ios_s ios; +} ghostty_platform_u; + +typedef enum { + GHOSTTY_SURFACE_CONTEXT_WINDOW = 0, + GHOSTTY_SURFACE_CONTEXT_TAB = 1, + GHOSTTY_SURFACE_CONTEXT_SPLIT = 2, +} ghostty_surface_context_e; + +typedef struct { + ghostty_platform_e platform_tag; + ghostty_platform_u platform; + void* userdata; + double scale_factor; + float font_size; + const char* working_directory; + const char* command; + ghostty_env_var_s* env_vars; + size_t env_var_count; + const char* initial_input; + bool wait_after_command; + ghostty_surface_context_e context; +} ghostty_surface_config_s; + +typedef struct { + uint16_t columns; + uint16_t rows; + uint32_t width_px; + uint32_t height_px; + uint32_t cell_width_px; + uint32_t cell_height_px; +} ghostty_surface_size_s; + +// Config types + +// config.Path +typedef struct { + const char* path; + bool optional; +} ghostty_config_path_s; + +// config.Color +typedef struct { + uint8_t r; + uint8_t g; + uint8_t b; +} ghostty_config_color_s; + +// config.ColorList +typedef struct { + const ghostty_config_color_s* colors; + size_t len; +} ghostty_config_color_list_s; + +// config.RepeatableCommand +typedef struct { + const ghostty_command_s* commands; + size_t len; +} ghostty_config_command_list_s; + +// config.Palette +typedef struct { + ghostty_config_color_s colors[256]; +} ghostty_config_palette_s; + +// config.QuickTerminalSize +typedef enum { + GHOSTTY_QUICK_TERMINAL_SIZE_NONE, + GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE, + GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS, +} ghostty_quick_terminal_size_tag_e; + +typedef union { + float percentage; + uint32_t pixels; +} ghostty_quick_terminal_size_value_u; + +typedef struct { + ghostty_quick_terminal_size_tag_e tag; + ghostty_quick_terminal_size_value_u value; +} ghostty_quick_terminal_size_s; + +typedef struct { + ghostty_quick_terminal_size_s primary; + ghostty_quick_terminal_size_s secondary; +} ghostty_config_quick_terminal_size_s; + +// config.Fullscreen +typedef enum { + GHOSTTY_CONFIG_FULLSCREEN_FALSE, + GHOSTTY_CONFIG_FULLSCREEN_TRUE, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, +} ghostty_config_fullscreen_e; + +// apprt.Target.Key +typedef enum { + GHOSTTY_TARGET_APP, + GHOSTTY_TARGET_SURFACE, +} ghostty_target_tag_e; + +typedef union { + ghostty_surface_t surface; +} ghostty_target_u; + +typedef struct { + ghostty_target_tag_e tag; + ghostty_target_u target; +} ghostty_target_s; + +// apprt.action.SplitDirection +typedef enum { + GHOSTTY_SPLIT_DIRECTION_RIGHT, + GHOSTTY_SPLIT_DIRECTION_DOWN, + GHOSTTY_SPLIT_DIRECTION_LEFT, + GHOSTTY_SPLIT_DIRECTION_UP, +} ghostty_action_split_direction_e; + +// apprt.action.GotoSplit +typedef enum { + GHOSTTY_GOTO_SPLIT_PREVIOUS, + GHOSTTY_GOTO_SPLIT_NEXT, + GHOSTTY_GOTO_SPLIT_UP, + GHOSTTY_GOTO_SPLIT_LEFT, + GHOSTTY_GOTO_SPLIT_DOWN, + GHOSTTY_GOTO_SPLIT_RIGHT, +} ghostty_action_goto_split_e; + +// apprt.action.GotoWindow +typedef enum { + GHOSTTY_GOTO_WINDOW_PREVIOUS, + GHOSTTY_GOTO_WINDOW_NEXT, +} ghostty_action_goto_window_e; + +// apprt.action.ResizeSplit.Direction +typedef enum { + GHOSTTY_RESIZE_SPLIT_UP, + GHOSTTY_RESIZE_SPLIT_DOWN, + GHOSTTY_RESIZE_SPLIT_LEFT, + GHOSTTY_RESIZE_SPLIT_RIGHT, +} ghostty_action_resize_split_direction_e; + +// apprt.action.ResizeSplit +typedef struct { + uint16_t amount; + ghostty_action_resize_split_direction_e direction; +} ghostty_action_resize_split_s; + +// apprt.action.MoveTab +typedef struct { + ssize_t amount; +} ghostty_action_move_tab_s; + +// apprt.action.GotoTab +typedef enum { + GHOSTTY_GOTO_TAB_PREVIOUS = -1, + GHOSTTY_GOTO_TAB_NEXT = -2, + GHOSTTY_GOTO_TAB_LAST = -3, +} ghostty_action_goto_tab_e; + +// apprt.action.Fullscreen +typedef enum { + GHOSTTY_FULLSCREEN_NATIVE, + GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE, + GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU, + GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH, +} ghostty_action_fullscreen_e; + +// apprt.action.FloatWindow +typedef enum { + GHOSTTY_FLOAT_WINDOW_ON, + GHOSTTY_FLOAT_WINDOW_OFF, + GHOSTTY_FLOAT_WINDOW_TOGGLE, +} ghostty_action_float_window_e; + +// apprt.action.SecureInput +typedef enum { + GHOSTTY_SECURE_INPUT_ON, + GHOSTTY_SECURE_INPUT_OFF, + GHOSTTY_SECURE_INPUT_TOGGLE, +} ghostty_action_secure_input_e; + +// apprt.action.Inspector +typedef enum { + GHOSTTY_INSPECTOR_TOGGLE, + GHOSTTY_INSPECTOR_SHOW, + GHOSTTY_INSPECTOR_HIDE, +} ghostty_action_inspector_e; + +// apprt.action.QuitTimer +typedef enum { + GHOSTTY_QUIT_TIMER_START, + GHOSTTY_QUIT_TIMER_STOP, +} ghostty_action_quit_timer_e; + +// apprt.action.Readonly +typedef enum { + GHOSTTY_READONLY_OFF, + GHOSTTY_READONLY_ON, +} ghostty_action_readonly_e; + +// apprt.action.DesktopNotification.C +typedef struct { + const char* title; + const char* body; +} ghostty_action_desktop_notification_s; + +// apprt.action.SetTitle.C +typedef struct { + const char* title; +} ghostty_action_set_title_s; + +// apprt.action.PromptTitle +typedef enum { + GHOSTTY_PROMPT_TITLE_SURFACE, + GHOSTTY_PROMPT_TITLE_TAB, +} ghostty_action_prompt_title_e; + +// apprt.action.Pwd.C +typedef struct { + const char* pwd; +} ghostty_action_pwd_s; + +// terminal.MouseShape +typedef enum { + GHOSTTY_MOUSE_SHAPE_DEFAULT, + GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU, + GHOSTTY_MOUSE_SHAPE_HELP, + GHOSTTY_MOUSE_SHAPE_POINTER, + GHOSTTY_MOUSE_SHAPE_PROGRESS, + GHOSTTY_MOUSE_SHAPE_WAIT, + GHOSTTY_MOUSE_SHAPE_CELL, + GHOSTTY_MOUSE_SHAPE_CROSSHAIR, + GHOSTTY_MOUSE_SHAPE_TEXT, + GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT, + GHOSTTY_MOUSE_SHAPE_ALIAS, + GHOSTTY_MOUSE_SHAPE_COPY, + GHOSTTY_MOUSE_SHAPE_MOVE, + GHOSTTY_MOUSE_SHAPE_NO_DROP, + GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED, + GHOSTTY_MOUSE_SHAPE_GRAB, + GHOSTTY_MOUSE_SHAPE_GRABBING, + GHOSTTY_MOUSE_SHAPE_ALL_SCROLL, + GHOSTTY_MOUSE_SHAPE_COL_RESIZE, + GHOSTTY_MOUSE_SHAPE_ROW_RESIZE, + GHOSTTY_MOUSE_SHAPE_N_RESIZE, + GHOSTTY_MOUSE_SHAPE_E_RESIZE, + GHOSTTY_MOUSE_SHAPE_S_RESIZE, + GHOSTTY_MOUSE_SHAPE_W_RESIZE, + GHOSTTY_MOUSE_SHAPE_NE_RESIZE, + GHOSTTY_MOUSE_SHAPE_NW_RESIZE, + GHOSTTY_MOUSE_SHAPE_SE_RESIZE, + GHOSTTY_MOUSE_SHAPE_SW_RESIZE, + GHOSTTY_MOUSE_SHAPE_EW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NS_RESIZE, + GHOSTTY_MOUSE_SHAPE_NESW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE, + GHOSTTY_MOUSE_SHAPE_ZOOM_IN, + GHOSTTY_MOUSE_SHAPE_ZOOM_OUT, +} ghostty_action_mouse_shape_e; + +// apprt.action.MouseVisibility +typedef enum { + GHOSTTY_MOUSE_VISIBLE, + GHOSTTY_MOUSE_HIDDEN, +} ghostty_action_mouse_visibility_e; + +// apprt.action.MouseOverLink +typedef struct { + const char* url; + size_t len; +} ghostty_action_mouse_over_link_s; + +// apprt.action.SizeLimit +typedef struct { + uint32_t min_width; + uint32_t min_height; + uint32_t max_width; + uint32_t max_height; +} ghostty_action_size_limit_s; + +// apprt.action.InitialSize +typedef struct { + uint32_t width; + uint32_t height; +} ghostty_action_initial_size_s; + +// apprt.action.CellSize +typedef struct { + uint32_t width; + uint32_t height; +} ghostty_action_cell_size_s; + +// renderer.Health +typedef enum { + GHOSTTY_RENDERER_HEALTH_HEALTHY, + GHOSTTY_RENDERER_HEALTH_UNHEALTHY, +} ghostty_action_renderer_health_e; + +// apprt.action.KeySequence +typedef struct { + bool active; + ghostty_input_trigger_s trigger; +} ghostty_action_key_sequence_s; + +// apprt.action.KeyTable.Tag +typedef enum { + GHOSTTY_KEY_TABLE_ACTIVATE, + GHOSTTY_KEY_TABLE_DEACTIVATE, + GHOSTTY_KEY_TABLE_DEACTIVATE_ALL, +} ghostty_action_key_table_tag_e; + +// apprt.action.KeyTable.CValue +typedef union { + struct { + const char *name; + size_t len; + } activate; +} ghostty_action_key_table_u; + +// apprt.action.KeyTable.C +typedef struct { + ghostty_action_key_table_tag_e tag; + ghostty_action_key_table_u value; +} ghostty_action_key_table_s; + +// apprt.action.ColorKind +typedef enum { + GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1, + GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2, + GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3, +} ghostty_action_color_kind_e; + +// apprt.action.ColorChange +typedef struct { + ghostty_action_color_kind_e kind; + uint8_t r; + uint8_t g; + uint8_t b; +} ghostty_action_color_change_s; + +// apprt.action.ConfigChange +typedef struct { + ghostty_config_t config; +} ghostty_action_config_change_s; + +// apprt.action.ReloadConfig +typedef struct { + bool soft; +} ghostty_action_reload_config_s; + +// apprt.action.OpenUrlKind +typedef enum { + GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, + GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, + GHOSTTY_ACTION_OPEN_URL_KIND_HTML, +} ghostty_action_open_url_kind_e; + +// apprt.action.OpenUrl.C +typedef struct { + ghostty_action_open_url_kind_e kind; + const char* url; + uintptr_t len; +} ghostty_action_open_url_s; + +// apprt.action.CloseTabMode +typedef enum { + GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, + GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, + GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, +} ghostty_action_close_tab_mode_e; + +// apprt.surface.Message.ChildExited +typedef struct { + uint32_t exit_code; + uint64_t timetime_ms; +} ghostty_surface_message_childexited_s; + +// terminal.osc.Command.ProgressReport.State +typedef enum { + GHOSTTY_PROGRESS_STATE_REMOVE, + GHOSTTY_PROGRESS_STATE_SET, + GHOSTTY_PROGRESS_STATE_ERROR, + GHOSTTY_PROGRESS_STATE_INDETERMINATE, + GHOSTTY_PROGRESS_STATE_PAUSE, +} ghostty_action_progress_report_state_e; + +// terminal.osc.Command.ProgressReport.C +typedef struct { + ghostty_action_progress_report_state_e state; + // -1 if no progress was reported, otherwise 0-100 indicating percent + // completeness. + int8_t progress; +} ghostty_action_progress_report_s; + +// apprt.action.CommandFinished.C +typedef struct { + // -1 if no exit code was reported, otherwise 0-255 + int16_t exit_code; + // number of nanoseconds that command was running for + uint64_t duration; +} ghostty_action_command_finished_s; + +// apprt.action.StartSearch.C +typedef struct { + const char* needle; +} ghostty_action_start_search_s; + +// apprt.action.SearchTotal +typedef struct { + ssize_t total; +} ghostty_action_search_total_s; + +// apprt.action.SearchSelected +typedef struct { + ssize_t selected; +} ghostty_action_search_selected_s; + +// terminal.Scrollbar +typedef struct { + uint64_t total; + uint64_t offset; + uint64_t len; +} ghostty_action_scrollbar_s; + +// apprt.Action.Key +typedef enum { + GHOSTTY_ACTION_QUIT, + GHOSTTY_ACTION_NEW_WINDOW, + GHOSTTY_ACTION_NEW_TAB, + GHOSTTY_ACTION_CLOSE_TAB, + GHOSTTY_ACTION_NEW_SPLIT, + GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, + GHOSTTY_ACTION_TOGGLE_MAXIMIZE, + GHOSTTY_ACTION_TOGGLE_FULLSCREEN, + GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, + GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, + GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, + GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, + GHOSTTY_ACTION_TOGGLE_VISIBILITY, + GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY, + GHOSTTY_ACTION_MOVE_TAB, + GHOSTTY_ACTION_GOTO_TAB, + GHOSTTY_ACTION_GOTO_SPLIT, + GHOSTTY_ACTION_GOTO_WINDOW, + GHOSTTY_ACTION_RESIZE_SPLIT, + GHOSTTY_ACTION_EQUALIZE_SPLITS, + GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, + GHOSTTY_ACTION_PRESENT_TERMINAL, + GHOSTTY_ACTION_SIZE_LIMIT, + GHOSTTY_ACTION_RESET_WINDOW_SIZE, + GHOSTTY_ACTION_INITIAL_SIZE, + GHOSTTY_ACTION_CELL_SIZE, + GHOSTTY_ACTION_SCROLLBAR, + GHOSTTY_ACTION_RENDER, + GHOSTTY_ACTION_INSPECTOR, + GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, + GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_DESKTOP_NOTIFICATION, + GHOSTTY_ACTION_SET_TITLE, + GHOSTTY_ACTION_SET_TAB_TITLE, + GHOSTTY_ACTION_PROMPT_TITLE, + GHOSTTY_ACTION_PWD, + GHOSTTY_ACTION_MOUSE_SHAPE, + GHOSTTY_ACTION_MOUSE_VISIBILITY, + GHOSTTY_ACTION_MOUSE_OVER_LINK, + GHOSTTY_ACTION_RENDERER_HEALTH, + GHOSTTY_ACTION_OPEN_CONFIG, + GHOSTTY_ACTION_QUIT_TIMER, + GHOSTTY_ACTION_FLOAT_WINDOW, + GHOSTTY_ACTION_SECURE_INPUT, + GHOSTTY_ACTION_KEY_SEQUENCE, + GHOSTTY_ACTION_KEY_TABLE, + GHOSTTY_ACTION_COLOR_CHANGE, + GHOSTTY_ACTION_RELOAD_CONFIG, + GHOSTTY_ACTION_CONFIG_CHANGE, + GHOSTTY_ACTION_CLOSE_WINDOW, + GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_UNDO, + GHOSTTY_ACTION_REDO, + GHOSTTY_ACTION_CHECK_FOR_UPDATES, + GHOSTTY_ACTION_OPEN_URL, + GHOSTTY_ACTION_SHOW_CHILD_EXITED, + GHOSTTY_ACTION_PROGRESS_REPORT, + GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, + GHOSTTY_ACTION_COMMAND_FINISHED, + GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_END_SEARCH, + GHOSTTY_ACTION_SEARCH_TOTAL, + GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, + GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, +} ghostty_action_tag_e; + +typedef union { + ghostty_action_split_direction_e new_split; + ghostty_action_fullscreen_e toggle_fullscreen; + ghostty_action_move_tab_s move_tab; + ghostty_action_goto_tab_e goto_tab; + ghostty_action_goto_split_e goto_split; + ghostty_action_goto_window_e goto_window; + ghostty_action_resize_split_s resize_split; + ghostty_action_size_limit_s size_limit; + ghostty_action_initial_size_s initial_size; + ghostty_action_cell_size_s cell_size; + ghostty_action_scrollbar_s scrollbar; + ghostty_action_inspector_e inspector; + ghostty_action_desktop_notification_s desktop_notification; + ghostty_action_set_title_s set_title; + ghostty_action_set_title_s set_tab_title; + ghostty_action_prompt_title_e prompt_title; + ghostty_action_pwd_s pwd; + ghostty_action_mouse_shape_e mouse_shape; + ghostty_action_mouse_visibility_e mouse_visibility; + ghostty_action_mouse_over_link_s mouse_over_link; + ghostty_action_renderer_health_e renderer_health; + ghostty_action_quit_timer_e quit_timer; + ghostty_action_float_window_e float_window; + ghostty_action_secure_input_e secure_input; + ghostty_action_key_sequence_s key_sequence; + ghostty_action_key_table_s key_table; + ghostty_action_color_change_s color_change; + ghostty_action_reload_config_s reload_config; + ghostty_action_config_change_s config_change; + ghostty_action_open_url_s open_url; + ghostty_action_close_tab_mode_e close_tab_mode; + ghostty_surface_message_childexited_s child_exited; + ghostty_action_progress_report_s progress_report; + ghostty_action_command_finished_s command_finished; + ghostty_action_start_search_s start_search; + ghostty_action_search_total_s search_total; + ghostty_action_search_selected_s search_selected; + ghostty_action_readonly_e readonly; +} ghostty_action_u; + +typedef struct { + ghostty_action_tag_e tag; + ghostty_action_u action; +} ghostty_action_s; + +typedef void (*ghostty_runtime_wakeup_cb)(void*); +typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, + ghostty_clipboard_e, + void*); +typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( + void*, + const char*, + void*, + ghostty_clipboard_request_e); +typedef void (*ghostty_runtime_write_clipboard_cb)(void*, + ghostty_clipboard_e, + const ghostty_clipboard_content_s*, + size_t, + bool); +typedef void (*ghostty_runtime_close_surface_cb)(void*, bool); +typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t, + ghostty_target_s, + ghostty_action_s); + +typedef struct { + void* userdata; + bool supports_selection_clipboard; + ghostty_runtime_wakeup_cb wakeup_cb; + ghostty_runtime_action_cb action_cb; + ghostty_runtime_read_clipboard_cb read_clipboard_cb; + ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb; + ghostty_runtime_write_clipboard_cb write_clipboard_cb; + ghostty_runtime_close_surface_cb close_surface_cb; +} ghostty_runtime_config_s; + +// apprt.ipc.Target.Key +typedef enum { + GHOSTTY_IPC_TARGET_CLASS, + GHOSTTY_IPC_TARGET_DETECT, +} ghostty_ipc_target_tag_e; + +typedef union { + char *klass; +} ghostty_ipc_target_u; + +typedef struct { + ghostty_ipc_target_tag_e tag; + ghostty_ipc_target_u target; +} chostty_ipc_target_s; + +// apprt.ipc.Action.NewWindow +typedef struct { + // This should be a null terminated list of strings. + const char **arguments; +} ghostty_ipc_action_new_window_s; + +typedef union { + ghostty_ipc_action_new_window_s new_window; +} ghostty_ipc_action_u; + +// apprt.ipc.Action.Key +typedef enum { + GHOSTTY_IPC_ACTION_NEW_WINDOW, +} ghostty_ipc_action_tag_e; + +//------------------------------------------------------------------- +// Published API + +int ghostty_init(uintptr_t, char**); +void ghostty_cli_try_action(void); +ghostty_info_s ghostty_info(void); +const char* ghostty_translate(const char*); +void ghostty_string_free(ghostty_string_s); + +ghostty_config_t ghostty_config_new(); +void ghostty_config_free(ghostty_config_t); +ghostty_config_t ghostty_config_clone(ghostty_config_t); +void ghostty_config_load_cli_args(ghostty_config_t); +void ghostty_config_load_file(ghostty_config_t, const char*); +void ghostty_config_load_default_files(ghostty_config_t); +void ghostty_config_load_recursive_files(ghostty_config_t); +void ghostty_config_finalize(ghostty_config_t); +bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t); +ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, + const char*, + uintptr_t); +uint32_t ghostty_config_diagnostics_count(ghostty_config_t); +ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); +ghostty_string_s ghostty_config_open_path(void); + +ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, + ghostty_config_t); +void ghostty_app_free(ghostty_app_t); +void ghostty_app_tick(ghostty_app_t); +void* ghostty_app_userdata(ghostty_app_t); +void ghostty_app_set_focus(ghostty_app_t, bool); +bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); +bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); +void ghostty_app_keyboard_changed(ghostty_app_t); +void ghostty_app_open_config(ghostty_app_t); +void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); +bool ghostty_app_needs_confirm_quit(ghostty_app_t); +bool ghostty_app_has_global_keybinds(ghostty_app_t); +void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e); + +ghostty_surface_config_s ghostty_surface_config_new(); + +ghostty_surface_t ghostty_surface_new(ghostty_app_t, + const ghostty_surface_config_s*); +void ghostty_surface_free(ghostty_surface_t); +void* ghostty_surface_userdata(ghostty_surface_t); +ghostty_app_t ghostty_surface_app(ghostty_surface_t); +ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e); +void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); +bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); +bool ghostty_surface_process_exited(ghostty_surface_t); +void ghostty_surface_refresh(ghostty_surface_t); +void ghostty_surface_draw(ghostty_surface_t); +void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); +void ghostty_surface_set_focus(ghostty_surface_t, bool); +void ghostty_surface_set_occlusion(ghostty_surface_t, bool); +void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); +ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); +void ghostty_surface_set_color_scheme(ghostty_surface_t, + ghostty_color_scheme_e); +ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, + ghostty_input_mods_e); +bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); +bool ghostty_surface_key_is_binding(ghostty_surface_t, + ghostty_input_key_s, + ghostty_binding_flags_e*); +void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); +void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); +bool ghostty_surface_mouse_captured(ghostty_surface_t); +bool ghostty_surface_mouse_button(ghostty_surface_t, + ghostty_input_mouse_state_e, + ghostty_input_mouse_button_e, + ghostty_input_mods_e); +void ghostty_surface_mouse_pos(ghostty_surface_t, + double, + double, + ghostty_input_mods_e); +void ghostty_surface_mouse_scroll(ghostty_surface_t, + double, + double, + ghostty_input_scroll_mods_t); +void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); +void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); +void ghostty_surface_request_close(ghostty_surface_t); +void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); +void ghostty_surface_split_focus(ghostty_surface_t, + ghostty_action_goto_split_e); +void ghostty_surface_split_resize(ghostty_surface_t, + ghostty_action_resize_split_direction_e, + uint16_t); +void ghostty_surface_split_equalize(ghostty_surface_t); +bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); +void ghostty_surface_complete_clipboard_request(ghostty_surface_t, + const char*, + void*, + bool); +bool ghostty_surface_has_selection(ghostty_surface_t); +bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); +bool ghostty_surface_read_text(ghostty_surface_t, + ghostty_selection_s, + ghostty_text_s*); +void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); + +#ifdef __APPLE__ +void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); +void* ghostty_surface_quicklook_font(ghostty_surface_t); +bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*); +#endif + +ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); +void ghostty_inspector_free(ghostty_surface_t); +void ghostty_inspector_set_focus(ghostty_inspector_t, bool); +void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double); +void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t); +void ghostty_inspector_mouse_button(ghostty_inspector_t, + ghostty_input_mouse_state_e, + ghostty_input_mouse_button_e, + ghostty_input_mods_e); +void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double); +void ghostty_inspector_mouse_scroll(ghostty_inspector_t, + double, + double, + ghostty_input_scroll_mods_t); +void ghostty_inspector_key(ghostty_inspector_t, + ghostty_input_action_e, + ghostty_input_key_e, + ghostty_input_mods_e); +void ghostty_inspector_text(ghostty_inspector_t, const char*); + +#ifdef __APPLE__ +bool ghostty_inspector_metal_init(ghostty_inspector_t, void*); +void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*); +bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); +#endif + +// APIs I'd like to get rid of eventually but are still needed for now. +// Don't use these unless you know what you're doing. +void ghostty_set_window_background_blur(ghostty_app_t, void*); + +// Benchmark API, if available. +bool ghostty_benchmark_cli(const char*, const char*); + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_H */ diff --git a/crates/ghostty-lib/include/module.modulemap b/crates/ghostty-lib/include/module.modulemap new file mode 100644 index 0000000..8961f5c --- /dev/null +++ b/crates/ghostty-lib/include/module.modulemap @@ -0,0 +1,7 @@ +// This makes Ghostty available to the XCode build for the macOS app. +// We append "Kit" to it not to be cute, but because targets have to have +// unique names and we use Ghostty for other things. +module GhosttyKit { + umbrella header "ghostty.h" + export * +} diff --git a/crates/ghostty-lib/src/action_c.rs b/crates/ghostty-lib/src/action_c.rs new file mode 100644 index 0000000..204d34a --- /dev/null +++ b/crates/ghostty-lib/src/action_c.rs @@ -0,0 +1,348 @@ +//! `apprt.Target.cval` and `apprt.Action.cval`: the conversion of core +//! actions to the `ghostty_target_s` / `ghostty_action_s` C structs +//! (ZIG/src/apprt/action.zig). +#![allow(unsafe_code)] + +use std::ffi::{CString, c_char, c_int}; + +use ghostty_apprt::{ + action::{Action, KeySequence, KeyTable, Target}, + runtime_api::SurfaceId, +}; +use ghostty_renderer::Health; +use ghostty_vt::osc::ProgressState; + +use crate::{ + config_capi::{CConfig, default_trigger, trigger_to_c}, + types::{ + ghostty_action_command_finished_s, + ghostty_action_s, + ghostty_action_u, + ghostty_config_t, + ghostty_target_s, + ghostty_target_u, + target_tag, + }, +}; + +/// Memory that the pointers inside a converted action point to. It must +/// outlive the callback invocation. +#[derive(Default)] +pub struct Keep { + strings: Vec, + config: Option, +} + +impl Keep { + /// A NUL terminated copy of `s` (interior NULs are dropped). + fn cstr(&mut self, s: &str) -> *const c_char { + let c = CString::new(s.replace('\0', "")).unwrap_or_default(); + let ptr = c.as_ptr(); + // The heap buffer of a CString doesn't move when the Vec grows. + self.strings.push(c); + ptr + } +} + +impl Drop for Keep { + fn drop(&mut self) { + if let Some(h) = self.config.take() { + // SAFETY: created by `CConfig::into_handle` in `action_to_c` + // and not handed out beyond the callback. + unsafe { crate::config_capi::ghostty_config_free(h) }; + } + } +} + +/// `Target.cval`. The surface handle is the id of the surface (the address +/// of the embedded runtime's surface object, see `embedded.rs`). +#[must_use] +pub fn target_to_c(target: &Target) -> ghostty_target_s { + match target { + Target::App => ghostty_target_s { + tag: target_tag::APP, + target: ghostty_target_u { + surface: std::ptr::null_mut(), + }, + }, + Target::Surface(id) => ghostty_target_s { + tag: target_tag::SURFACE, + target: ghostty_target_u { + surface: *id as usize as *mut std::ffi::c_void, + }, + }, + } +} + +fn zeroed_union() -> ghostty_action_u { + // SAFETY: every field of the union is plain old data for which the + // all-zero bit pattern is valid (integers, floats, null pointers, bool). + unsafe { std::mem::zeroed() } +} + +/// `Action.cval`. +#[must_use] +pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s { + let mut v = zeroed_union(); + match action { + Action::CloseTab(m) => v.close_tab_mode = *m as c_int, + Action::NewSplit(d) => v.new_split = *d as c_int, + Action::ToggleFullscreen(f) => v.toggle_fullscreen = *f as c_int, + Action::MoveTab(m) => v.move_tab.amount = m.amount, + Action::GotoTab(t) => v.goto_tab = t.int(), + Action::GotoSplit(s) => v.goto_split = *s as c_int, + Action::GotoWindow(w) => v.goto_window = *w as c_int, + Action::ResizeSplit(r) => { + v.resize_split.amount = r.amount; + v.resize_split.direction = r.direction as c_int; + } + Action::SizeLimit(s) => { + v.size_limit.min_width = s.min_width; + v.size_limit.min_height = s.min_height; + v.size_limit.max_width = s.max_width; + v.size_limit.max_height = s.max_height; + } + Action::InitialSize(s) => { + v.initial_size.width = s.width; + v.initial_size.height = s.height; + } + Action::CellSize(s) => { + v.cell_size.width = s.width; + v.cell_size.height = s.height; + } + Action::Scrollbar(s) => { + v.scrollbar.total = s.total as u64; + v.scrollbar.offset = s.offset as u64; + v.scrollbar.len = s.len as u64; + } + Action::Inspector(i) => v.inspector = *i as c_int, + Action::DesktopNotification(n) => { + v.desktop_notification.title = keep.cstr(&n.title); + v.desktop_notification.body = keep.cstr(&n.body); + } + Action::SetTitle(t) => v.set_title.title = keep.cstr(&t.title), + Action::SetTabTitle(t) => v.set_tab_title.title = keep.cstr(&t.title), + Action::PromptTitle(p) => v.prompt_title = *p as c_int, + Action::Pwd(p) => v.pwd.pwd = keep.cstr(&p.pwd), + Action::MouseShape(s) => v.mouse_shape = *s as c_int, + Action::MouseVisibility(m) => v.mouse_visibility = *m as c_int, + Action::MouseOverLink(l) => { + v.mouse_over_link.url = keep.cstr(&l.url); + v.mouse_over_link.len = l.url.len(); + } + Action::RendererHealth(h) => { + v.renderer_health = match h { + Health::Healthy => 0, + Health::Unhealthy => 1, + }; + } + Action::QuitTimer(q) => v.quit_timer = *q as c_int, + Action::FloatWindow(f) => v.float_window = *f as c_int, + Action::SecureInput(s) => v.secure_input = *s as c_int, + Action::KeySequence(KeySequence::Trigger(t)) => { + v.key_sequence.active = true; + v.key_sequence.trigger = trigger_to_c(&t.cval()); + } + Action::KeySequence(KeySequence::End) => { + v.key_sequence.active = false; + v.key_sequence.trigger = default_trigger(); + } + Action::KeyTable(KeyTable::Activate(name)) => { + v.key_table.tag = 0; + v.key_table.value.activate.name = keep.cstr(name); + v.key_table.value.activate.len = name.len(); + } + Action::KeyTable(KeyTable::Deactivate) => v.key_table.tag = 1, + Action::KeyTable(KeyTable::DeactivateAll) => v.key_table.tag = 2, + Action::ColorChange(c) => { + v.color_change.kind = c.kind.int(); + v.color_change.r = c.r; + v.color_change.g = c.g; + v.color_change.b = c.b; + } + Action::ReloadConfig(r) => v.reload_config.soft = r.soft, + Action::ConfigChange(c) => { + // The embedder reads (and clones) the config during the + // callback; it is freed again when `keep` drops. + if let Some(old) = keep.config.take() { + // SAFETY: created below by an earlier conversion. + unsafe { crate::config_capi::ghostty_config_free(old) }; + } + let handle = CConfig::new((*c.config).clone()).into_handle(); + keep.config = Some(handle); + v.config_change.config = handle; + } + Action::OpenUrl(u) => { + v.open_url.kind = u.kind as c_int; + v.open_url.url = keep.cstr(&u.url); + v.open_url.len = u.url.len(); + } + Action::ShowChildExited(c) => { + v.child_exited.exit_code = c.exit_code; + v.child_exited.timetime_ms = c.runtime_ms; + } + Action::ProgressReport(p) => { + v.progress_report.state = match p.state { + ProgressState::Remove => 0, + ProgressState::Set => 1, + ProgressState::Error => 2, + ProgressState::Indeterminate => 3, + ProgressState::Pause => 4, + }; + v.progress_report.progress = p.progress.map_or(-1, |p| p as i8); + } + Action::CommandFinished(c) => { + v.command_finished = ghostty_action_command_finished_s { + exit_code: c.exit_code.map_or(-1, i16::from), + duration: c.duration.duration, + }; + } + Action::StartSearch(s) => v.start_search.needle = keep.cstr(&s.needle), + Action::SearchTotal(s) => { + v.search_total.total = s.total.map_or(-1, |t| t as isize); + } + Action::SearchSelected(s) => { + v.search_selected.selected = s.selected.map_or(-1, |t| t as isize); + } + Action::Readonly(r) => v.readonly = *r as c_int, + _ => {} + } + ghostty_action_s { + tag: action.key() as c_int, + action: v, + } +} + +#[cfg(test)] +mod tests { + use ghostty_apprt::action::{ + CellSize, + CommandFinished, + GotoTab, + MouseOverLink, + ResizeSplit, + ResizeSplitDirection, + SearchTotal, + SetTitle, + }; + + use super::*; + + #[test] + fn target_cval() { + let t = target_to_c(&Target::App); + assert_eq!(t.tag, 0); + let t = target_to_c(&Target::Surface(0x1000)); + assert_eq!(t.tag, 1); + // SAFETY: tag says surface. + assert_eq!(unsafe { t.target.surface } as usize, 0x1000); + } + + #[test] + fn simple_payloads() { + let mut keep = Keep::default(); + let a = action_to_c( + &Action::CellSize(CellSize { + width: 7, + height: 9, + }), + &mut keep, + ); + assert_eq!(a.tag, 25); + // SAFETY: tag says cell_size. + assert_eq!( + unsafe { (a.action.cell_size.width, a.action.cell_size.height) }, + (7, 9) + ); + + let a = action_to_c(&Action::GotoTab(GotoTab::Last), &mut keep); + // SAFETY: tag says goto_tab. + assert_eq!(unsafe { a.action.goto_tab }, -3); + + let a = action_to_c( + &Action::ResizeSplit(ResizeSplit { + amount: 5, + direction: ResizeSplitDirection::Left, + }), + &mut keep, + ); + // SAFETY: tag says resize_split. + assert_eq!( + unsafe { + ( + a.action.resize_split.amount, + a.action.resize_split.direction, + ) + }, + (5, 2) + ); + + let a = action_to_c(&Action::SearchTotal(SearchTotal { total: None }), &mut keep); + // SAFETY: tag says search_total. + assert_eq!(unsafe { a.action.search_total.total }, -1); + + let a = action_to_c( + &Action::CommandFinished(CommandFinished { + exit_code: None, + duration: ghostty_config::types_b::Duration { duration: 5 }, + }), + &mut keep, + ); + // SAFETY: tag says command_finished. + assert_eq!( + unsafe { + ( + a.action.command_finished.exit_code, + a.action.command_finished.duration, + ) + }, + (-1, 5) + ); + } + + #[test] + fn strings_stay_valid_while_kept() { + let mut keep = Keep::default(); + let a = action_to_c( + &Action::SetTitle(SetTitle { + title: "hello".into(), + }), + &mut keep, + ); + // SAFETY: tag says set_title; the pointer points into `keep`. + let s = unsafe { std::ffi::CStr::from_ptr(a.action.set_title.title) }; + assert_eq!(s.to_str().unwrap(), "hello"); + + let a = action_to_c( + &Action::MouseOverLink(MouseOverLink { + url: "https://x".into(), + }), + &mut keep, + ); + // SAFETY: tag says mouse_over_link. + assert_eq!(unsafe { a.action.mouse_over_link.len }, 9); + } + + #[test] + fn config_change_hands_out_a_config() { + let mut keep = Keep::default(); + let cfg = std::sync::Arc::new(ghostty_config::config::Config::default()); + let a = action_to_c( + &Action::ConfigChange(ghostty_apprt::action::ConfigChange { config: cfg }), + &mut keep, + ); + // SAFETY: tag says config_change. + let h = unsafe { a.action.config_change.config }; + let mut out = false; + let key = "maximize"; + // SAFETY: valid handle and destination. + assert!(unsafe { + crate::config_capi::ghostty_config_get( + h, + (&raw mut out).cast(), + key.as_ptr().cast(), + key.len(), + ) + }); + } +} diff --git a/crates/ghostty-lib/src/benchmark.rs b/crates/ghostty-lib/src/benchmark.rs new file mode 100644 index 0000000..0b6a027 --- /dev/null +++ b/crates/ghostty-lib/src/benchmark.rs @@ -0,0 +1,98 @@ +//! `ghostty_benchmark_cli` (ZIG/src/benchmark/CApi.zig). +#![allow(unsafe_code)] + +use std::ffi::{CStr, c_char}; + +use ghostty_bench::{args::Args, benches}; + +/// Split the argument string like `std.process.ArgIteratorGeneral(.{})`: +/// whitespace separated, with double quotes grouping and backslash +/// escapes inside quotes. +fn split_args(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut have = false; + let mut in_quote = false; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' => { + in_quote = !in_quote; + have = true; + } + '\\' if in_quote => { + if let Some(n) = chars.next() { + cur.push(n); + } + } + c if c.is_whitespace() && !in_quote => { + if have { + out.push(std::mem::take(&mut cur)); + have = false; + } + } + c => { + cur.push(c); + have = true; + } + } + } + if have { + out.push(cur); + } + out +} + +/// Run the Ghostty benchmark CLI with the given action and arguments. +/// +/// # Safety +/// `action_name` and `args` must be NUL terminated strings. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_benchmark_cli( + action_name: *const c_char, + args: *const c_char, +) -> bool { + if action_name.is_null() || args.is_null() { + return false; + } + // SAFETY: per the function contract. + let (action, args) = unsafe { (CStr::from_ptr(action_name), CStr::from_ptr(args)) }; + let action = action.to_string_lossy(); + if !benches::ACTIONS.contains(&action.as_ref()) { + log::warn!("unknown action={action}"); + return false; + } + let argv = split_args(&args.to_string_lossy()); + let parsed = match Args::parse(argv) { + Ok(a) => a, + Err(err) => { + log::warn!("failed to run action={action} err={err}"); + return false; + } + }; + match benches::main_action(&action, parsed) { + Ok(_) => true, + Err(err) => { + log::warn!("failed to run action={action} err={err}"); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_args_basic() { + assert_eq!(split_args("a b c"), ["a", "b", "c"]); + assert_eq!(split_args("--x=\"a b\" c"), ["--x=a b", "c"]); + assert_eq!(split_args(" ").len(), 0); + } + + #[test] + fn unknown_action_returns_false() { + // SAFETY: valid NUL terminated strings. + assert!(!unsafe { ghostty_benchmark_cli(c"nope".as_ptr(), c"".as_ptr()) }); + } +} diff --git a/crates/ghostty-lib/src/config_capi.rs b/crates/ghostty-lib/src/config_capi.rs new file mode 100644 index 0000000..5d37e9e --- /dev/null +++ b/crates/ghostty-lib/src/config_capi.rs @@ -0,0 +1,727 @@ +//! `ghostty_config_*` (ZIG/src/config/CApi.zig and c_get.zig). +#![allow(unsafe_code)] + +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + ffi::{CStr, CString, c_char, c_int, c_short, c_uint, c_void}, + rc::Rc, + sync::{Mutex, OnceLock}, +}; + +use ghostty_config::{ + c_value::{CValue, QuickTerminalSizeC}, + config::Config, +}; +use ghostty_input::binding::{Action as BindingAction, TriggerC, TriggerCTag}; + +use crate::{ + main_c::{EMPTY_STRING, string_from_owned}, + types::{ + ghostty_command_s, + ghostty_config_color_list_s, + ghostty_config_color_s, + ghostty_config_command_list_s, + ghostty_config_palette_s, + ghostty_config_path_s, + ghostty_config_quick_terminal_size_s, + ghostty_config_t, + ghostty_diagnostic_s, + ghostty_input_trigger_key_u, + ghostty_input_trigger_s, + ghostty_quick_terminal_size_s, + ghostty_quick_terminal_size_value_u, + ghostty_string_s, + quick_terminal_size_tag, + trigger_tag, + }, +}; + +/// The C-owned configuration: the config plus the memory that pointers +/// handed out by `ghostty_config_get` / `ghostty_config_get_diagnostic` point +/// into (Zig: the config's arena). Everything stays valid until the config +/// is freed. +pub struct CConfig { + pub config: Config, + cache: RefCell, + diagnostics: RefCell>, +} + +/// Backing memory of one `ghostty_config_get` result. +#[derive(Default)] +struct Backing { + strings: Vec, + colors: Box<[ghostty_config_color_s]>, + commands: Box<[ghostty_command_s]>, +} + +#[derive(Default)] +struct GetCache { + /// The latest result per key; reused if the value didn't change so + /// repeated gets return the same pointers. + by_key: HashMap)>, + /// Results that were replaced; kept alive until the config is freed + /// since callers may still hold their pointers. + retired: Vec>, +} + +impl CConfig { + pub fn new(config: Config) -> CConfig { + CConfig { + config, + cache: RefCell::default(), + diagnostics: RefCell::default(), + } + } + + /// Box the config and return the `ghostty_config_t` handle. + pub fn into_handle(self) -> ghostty_config_t { + Box::into_raw(Box::new(self)).cast::() + } + + /// The backing memory for `value`, reusing the cached one if equal. + fn backing(&self, key: &str, value: &CValue) -> Rc { + let mut cache = self.cache.borrow_mut(); + if let Some((old, backing)) = cache.by_key.get(key) + && old == value + { + return Rc::clone(backing); + } + let backing = Rc::new(build_backing(value)); + if let Some((_, old)) = cache + .by_key + .insert(key.to_owned(), (value.clone(), Rc::clone(&backing))) + { + cache.retired.push(old); + } + backing + } +} + +/// A C string without interior NULs. +fn cstring(s: &str) -> CString { + CString::new(s.replace('\0', "")).unwrap_or_default() +} + +/// Intern a string for the lifetime of the process (enum tag names). +fn intern(s: &str) -> *const c_char { + static POOL: OnceLock>>> = OnceLock::new(); + let Ok(mut pool) = POOL.get_or_init(Mutex::default).lock() else { + return c"".as_ptr(); + }; + let c = cstring(s); + if let Some(existing) = pool.get(c.as_c_str()) { + return existing.as_ptr(); + } + let boxed: Box = c.into_boxed_c_str(); + let ptr = boxed.as_ptr(); + pool.insert(boxed); + ptr +} + +fn build_backing(value: &CValue) -> Backing { + let mut b = Backing::default(); + match value { + CValue::String(s) => b.strings.push(cstring(s)), + CValue::Path { path, .. } => b.strings.push(cstring(path)), + CValue::ColorList(list) => { + b.colors = list + .iter() + .map(|c| ghostty_config_color_s { + r: c[0], + g: c[1], + b: c[2], + }) + .collect(); + } + CValue::Commands(cmds) => { + for c in cmds { + b.strings.push(cstring(&c.action_key)); + b.strings.push(cstring(&c.action)); + b.strings.push(cstring(&c.title)); + b.strings.push(cstring(&c.description)); + } + // The CStrings' heap buffers don't move when the Vec grows. + b.commands = b + .strings + .as_chunks::<4>() + .0 + .iter() + .map(|s| ghostty_command_s { + action_key: s[0].as_ptr(), + action: s[1].as_ptr(), + title: s[2].as_ptr(), + description: s[3].as_ptr(), + }) + .collect(); + } + _ => {} + } + b +} + +/// Borrow the config behind a handle. +/// +/// # Safety +/// `p` must be null or a handle returned by this library that wasn't freed, +/// and no other reference to it may be live. +pub(crate) unsafe fn cconfig<'a>(p: ghostty_config_t) -> Option<&'a mut CConfig> { + // SAFETY: per the function contract. + unsafe { p.cast::().as_mut() } +} + +/// Create a new configuration filled with the initial default values. +#[unsafe(no_mangle)] +pub extern "C" fn ghostty_config_new() -> ghostty_config_t { + CConfig::new(Config::default()).into_handle() +} + +/// Free a configuration. +/// +/// # Safety +/// `ptr` must be null or a handle from this library, not used afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_free(ptr: ghostty_config_t) { + if ptr.is_null() { + return; + } + // SAFETY: the handle was created by `into_handle` (a leaked Box). + drop(unsafe { Box::from_raw(ptr.cast::()) }); +} + +/// Deep clone the configuration. +/// +/// # Safety +/// `ptr` must be a valid config handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_clone(ptr: ghostty_config_t) -> ghostty_config_t { + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return std::ptr::null_mut(); + }; + CConfig::new(c.config.clone()).into_handle() +} + +/// Load the configuration from the CLI args. +/// +/// # Safety +/// `ptr` must be a valid config handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_load_cli_args(ptr: ghostty_config_t) { + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return; + }; + if let Err(err) = c.config.load_cli_args() { + log::error!("error loading config err={err}"); + } +} + +/// Load the configuration from the default file locations. This is usually +/// done first. The default file locations are locations such as the home +/// directory. +/// +/// # Safety +/// `ptr` must be a valid config handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_load_default_files(ptr: ghostty_config_t) { + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return; + }; + if let Err(err) = c.config.load_default_files() { + log::error!("error loading config err={err}"); + } +} + +/// Load the configuration from a specific file path. The path must be null +/// terminated. +/// +/// # Safety +/// `ptr` must be a valid config handle, `path` a NUL terminated string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_load_file(ptr: ghostty_config_t, path: *const c_char) { + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return; + }; + if path.is_null() { + return; + } + // SAFETY: `path` is NUL terminated. + let path = unsafe { CStr::from_ptr(path) } + .to_string_lossy() + .into_owned(); + if let Err(err) = c.config.load_file(&path) { + log::error!("error loading config from file path={path} err={err}"); + } +} + +/// Load the configuration from the user-specified configuration file +/// locations in the previously loaded configuration. This will recursively +/// continue to load up to a built-in limit. +/// +/// # Safety +/// `ptr` must be a valid config handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_load_recursive_files(ptr: ghostty_config_t) { + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return; + }; + if let Err(err) = c.config.load_recursive_files() { + log::error!("error loading config err={err}"); + } +} + +/// Finalize the configuration (fill in computed defaults). +/// +/// # Safety +/// `ptr` must be a valid config handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_finalize(ptr: ghostty_config_t) { + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return; + }; + if let Err(err) = c.config.finalize() { + log::error!("error finalizing config err={err}"); + } +} + +/// Store `v` at `out` (`T` must be what the C caller expects for the key). +/// +/// # Safety +/// `out` must be valid for a write of `T`. +unsafe fn put(out: *mut c_void, v: T) { + // SAFETY: per the function contract; unaligned writes are tolerated. + unsafe { out.cast::().write_unaligned(v) }; +} + +fn qt_size(v: QuickTerminalSizeC) -> ghostty_quick_terminal_size_s { + match v { + QuickTerminalSizeC::None => ghostty_quick_terminal_size_s { + tag: quick_terminal_size_tag::NONE, + value: ghostty_quick_terminal_size_value_u { pixels: 0 }, + }, + QuickTerminalSizeC::Percentage(p) => ghostty_quick_terminal_size_s { + tag: quick_terminal_size_tag::PERCENTAGE, + value: ghostty_quick_terminal_size_value_u { percentage: p }, + }, + QuickTerminalSizeC::Pixels(p) => ghostty_quick_terminal_size_s { + tag: quick_terminal_size_tag::PIXELS, + value: ghostty_quick_terminal_size_value_u { pixels: p }, + }, + } +} + +impl CConfig { + /// `c_get.get`: write the value of `key` to `out`. Returns false if the + /// key is unknown, its type isn't supported by the C API yet or an + /// optional value is unset. + /// + /// # Safety + /// `out` must point to storage of the C type of the key (see + /// `ghostty.h` and `c_get.zig`). + pub unsafe fn get(&self, key: &str, out: *mut c_void) -> bool { + let Some(value) = self.config.c_value(key) else { + return false; + }; + // SAFETY (all writes below): `out` is valid for the key's C type by + // the function contract. + match &value { + CValue::Unsupported | CValue::Null => return false, + CValue::NullString => unsafe { put(out, std::ptr::null::()) }, + CValue::String(_) => { + let b = self.backing(key, &value); + unsafe { put(out, b.strings[0].as_ptr()) }; + } + CValue::Bool(v) => unsafe { put(out, *v) }, + CValue::Uint(v) => unsafe { put::(out, *v) }, + CValue::Short(v) => unsafe { put::(out, *v) }, + CValue::F32(v) => unsafe { put(out, *v) }, + CValue::F64(v) => unsafe { put(out, *v) }, + CValue::Usize(v) => unsafe { put(out, *v) }, + CValue::EnumName(name) => unsafe { put(out, intern(name)) }, + CValue::Packed(bits) => unsafe { put::(out, *bits) }, + CValue::Color { r, g, b } => unsafe { + put( + out, + ghostty_config_color_s { + r: *r, + g: *g, + b: *b, + }, + ); + }, + CValue::ColorList(list) => { + let b = self.backing(key, &value); + unsafe { + put( + out, + ghostty_config_color_list_s { + colors: b.colors.as_ptr(), + len: list.len(), + }, + ); + } + } + CValue::Palette(colors) => { + let mut p = ghostty_config_palette_s { + colors: [ghostty_config_color_s { r: 0, g: 0, b: 0 }; 256], + }; + for (dst, c) in p.colors.iter_mut().zip(colors.iter()) { + *dst = ghostty_config_color_s { + r: c[0], + g: c[1], + b: c[2], + }; + } + unsafe { put(out, p) }; + } + CValue::Commands(cmds) => { + let b = self.backing(key, &value); + unsafe { + put( + out, + ghostty_config_command_list_s { + commands: b.commands.as_ptr(), + len: cmds.len(), + }, + ); + } + } + CValue::QuickTerminalSize { primary, secondary } => unsafe { + put( + out, + ghostty_config_quick_terminal_size_s { + primary: qt_size(*primary), + secondary: qt_size(*secondary), + }, + ); + }, + CValue::Path { optional, .. } => { + let b = self.backing(key, &value); + unsafe { + put( + out, + ghostty_config_path_s { + path: b.strings[0].as_ptr(), + optional: *optional, + }, + ); + } + } + } + true + } +} + +/// Get the value of a config key (see `c_get.zig` for the C types). +/// +/// # Safety +/// `ptr` must be a valid config handle, `key_str` must point to `len` +/// bytes and `out` to storage of the key's C type. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_get( + ptr: ghostty_config_t, + out: *mut c_void, + key_str: *const c_char, + len: usize, +) -> bool { + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return false; + }; + if out.is_null() || key_str.is_null() { + return false; + } + // SAFETY: `key_str` points to `len` bytes. + let key = unsafe { std::slice::from_raw_parts(key_str.cast::(), len) }; + let Ok(key) = std::str::from_utf8(key) else { + return false; + }; + // SAFETY: `out` is valid for the key's type per the contract. + unsafe { c.get(key, out) } +} + +/// Convert the Rust trigger to the C struct. +pub(crate) fn trigger_to_c(t: &TriggerC) -> ghostty_input_trigger_s { + let (tag, key) = match t.tag { + TriggerCTag::Physical => ( + trigger_tag::PHYSICAL, + ghostty_input_trigger_key_u { + physical: t.key as c_int, + }, + ), + TriggerCTag::Unicode => ( + trigger_tag::UNICODE, + ghostty_input_trigger_key_u { unicode: t.key }, + ), + TriggerCTag::CatchAll => ( + trigger_tag::CATCH_ALL, + ghostty_input_trigger_key_u { unicode: 0 }, + ), + }; + ghostty_input_trigger_s { + tag, + key, + mods: c_int::from(t.mods.int()), + } +} + +/// The default (unset) trigger (`Trigger.C{}`). +pub(crate) fn default_trigger() -> ghostty_input_trigger_s { + ghostty_input_trigger_s { + tag: trigger_tag::PHYSICAL, + key: ghostty_input_trigger_key_u { physical: 0 }, + mods: 0, + } +} + +/// Find the trigger bound to the given binding action string. +/// +/// # Safety +/// `ptr` must be a valid config handle, `str` must point to `len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_trigger( + ptr: ghostty_config_t, + str: *const c_char, + len: usize, +) -> ghostty_input_trigger_s { + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return default_trigger(); + }; + if str.is_null() { + return default_trigger(); + } + // SAFETY: `str` points to `len` bytes. + let s = unsafe { std::slice::from_raw_parts(str.cast::(), len) }; + let Ok(s) = std::str::from_utf8(s) else { + log::error!("error finding trigger err=InvalidFormat"); + return default_trigger(); + }; + match BindingAction::parse(s) { + Ok(action) => { + let trigger = c + .config + .keybind + .set + .get_trigger(&action) + .unwrap_or_default(); + trigger_to_c(&trigger.cval()) + } + Err(err) => { + log::error!("error finding trigger err={err:?}"); + default_trigger() + } + } +} + +/// The number of diagnostics. +/// +/// # Safety +/// `ptr` must be a valid config handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_diagnostics_count(ptr: ghostty_config_t) -> u32 { + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return 0; + }; + u32::try_from(c.config.diagnostics.items().len()).unwrap_or(u32::MAX) +} + +/// The diagnostic at the given index (an empty message if out of range). +/// +/// # Safety +/// `ptr` must be a valid config handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_config_get_diagnostic( + ptr: ghostty_config_t, + idx: u32, +) -> ghostty_diagnostic_s { + let empty = ghostty_diagnostic_s { + message: c"".as_ptr(), + }; + // SAFETY: per the function contract. + let Some(c) = (unsafe { cconfig(ptr) }) else { + return empty; + }; + let items = c.config.diagnostics.items(); + let idx = idx as usize; + if idx >= items.len() { + return empty; + } + + // Precompute (like libghostty's `DiagnosticList.precompute`): the + // messages are formatted once per diagnostic and live as long as the + // config. + let mut msgs = c.diagnostics.borrow_mut(); + while msgs.len() < items.len() { + let mut text = String::new(); + let _ = items[msgs.len()].format(&mut text); + msgs.push(cstring(&text)); + } + ghostty_diagnostic_s { + message: msgs[idx].as_ptr(), + } +} + +/// The path of the config file to open in an editor (created if needed). +/// Free with `ghostty_string_free`. +#[unsafe(no_mangle)] +pub extern "C" fn ghostty_config_open_path() -> ghostty_string_s { + match ghostty_config::edit::open_path() { + Ok(path) => string_from_owned(&path), + Err(err) => { + log::error!("error opening config in editor err={err}"); + EMPTY_STRING + } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::c_uint; + + use super::*; + + fn get(cfg: ghostty_config_t, key: &str, init: T) -> (bool, T) { + let mut out = init; + // SAFETY: `out` is a valid destination of the key's C type. + let ok = unsafe { + ghostty_config_get(cfg, (&raw mut out).cast(), key.as_ptr().cast(), key.len()) + }; + (ok, out) + } + + fn with_config(f: impl FnOnce(&mut Config)) -> ghostty_config_t { + let h = ghostty_config_new(); + // SAFETY: just created. + f(&mut unsafe { cconfig(h) }.unwrap().config); + h + } + + #[test] + fn ghostty_config_get_bool() { + let h = with_config(|c| c.maximize = true); + assert_eq!(get(h, "maximize", false), (true, true)); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } + + #[test] + fn ghostty_config_get_enum() { + let h = with_config(|c| c.window_theme = ghostty_config::types_b::WindowTheme::Dark); + let (ok, p) = get::<*const c_char>(h, "window-theme", std::ptr::null()); + assert!(ok); + // SAFETY: an interned NUL terminated string. + assert_eq!(unsafe { CStr::from_ptr(p) }.to_str().unwrap(), "dark"); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } + + #[test] + fn ghostty_config_get_optional_null_returns_false() { + let h = with_config(|c| c.unfocused_split_fill = None); + let (ok, _) = get( + h, + "unfocused-split-fill", + ghostty_config_color_s { r: 0, g: 0, b: 0 }, + ); + assert!(!ok); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } + + #[test] + fn ghostty_config_get_unknown_key_returns_false() { + let h = ghostty_config_new(); + assert!(!get(h, "not-a-real-key", false).0); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } + + #[test] + fn ghostty_config_get_optional_string_null_returns_true() { + let h = with_config(|c| c.title = None); + let (ok, p) = get::<*const c_char>(h, "title", std::ptr::dangling::()); + assert!(ok); + assert!(p.is_null()); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } + + #[test] + fn ghostty_config_get_float() { + let h = with_config(|c| c.background_opacity = 0.42); + let (ok, v) = get(h, "background-opacity", 0.0f64); + assert!(ok); + assert!((v - 0.42).abs() < 1e-6); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } + + #[test] + fn ghostty_config_get_struct_cval_conversion() { + let h = with_config(|c| { + c.background = ghostty_config::types_a::Color { + r: 12, + g: 34, + b: 56, + }; + }); + let (ok, v) = get(h, "background", ghostty_config_color_s { r: 0, g: 0, b: 0 }); + assert!(ok); + assert_eq!((v.r, v.g, v.b), (12, 34, 56)); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } + + #[test] + fn c_get_u8() { + let h = with_config(|c| c.font_size = 24.0); + assert_eq!(get(h, "font-size", 0.0f32), (true, 24.0)); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } + + #[test] + fn c_get_split_preserve_zoom() { + let h = ghostty_config_new(); + assert_eq!(get(h, "split-preserve-zoom", 9 as c_uint), (true, 0)); + // SAFETY: valid handle. + unsafe { + cconfig(h).unwrap().config.split_preserve_zoom.navigation = true; + } + assert_eq!(get(h, "split-preserve-zoom", 9 as c_uint), (true, 1)); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } + + #[test] + fn c_get_pointers_are_stable_for_equal_values() { + let h = ghostty_config_new(); + let (_, a) = get::( + h, + "command-palette-entry", + ghostty_config_command_list_s { + commands: std::ptr::null(), + len: 0, + }, + ); + let (_, b) = get::( + h, + "command-palette-entry", + ghostty_config_command_list_s { + commands: std::ptr::null(), + len: 0, + }, + ); + assert!(a.len > 0); + assert_eq!(a.commands, b.commands); + // SAFETY: valid handle. + unsafe { ghostty_config_free(h) }; + } +} diff --git a/crates/ghostty-lib/src/embedded.rs b/crates/ghostty-lib/src/embedded.rs new file mode 100644 index 0000000..8dc6632 --- /dev/null +++ b/crates/ghostty-lib/src/embedded.rs @@ -0,0 +1,2157 @@ +//! The embedded application runtime (ZIG/src/apprt/embedded.zig): the +//! runtime side of `ghostty-core` implemented over the C callbacks of +//! `ghostty_runtime_config_s`, plus the `ghostty_app_*` / `ghostty_surface_*` +//! exports. +//! +//! Everything here is main-thread only, like the Zig embedded runtime: the +//! host calls the C API from its UI thread. The only cross-thread entry is +//! the `wakeup` callback, invoked by core threads through [`WakerImpl`]. +//! +//! Handles: `ghostty_app_t` is a `Box` and `ghostty_surface_t` a +//! `Box`. The address of the surface object doubles as the +//! `SurfaceId` core knows the surface by, and as the `ghostty_surface_t` in +//! `ghostty_target_s` of actions (Zig: `target.cval()` gives `rt_surface`). +#![allow(unsafe_code)] + +use std::{ + cell::{Cell, OnceCell, RefCell}, + collections::HashMap, + ffi::{CStr, CString, c_char, c_int, c_void}, + fmt::Write as _, + rc::{Rc, Weak}, + sync::Arc, +}; + +use ghostty_apprt::{ + action::{Action, GotoSplit, ResizeSplit, ResizeSplitDirection, SplitDirection, Target}, + runtime_api::{CoreApp, CoreSurface, RtApp, RtSurface, SurfaceId, Waker}, + structs::{Clipboard, ClipboardRequest, ColorScheme, ContentScale, CursorPos, SurfaceSize}, + surface::{NewSurfaceContext, new_config, should_inherit_working_directory}, +}; +use ghostty_config::{command::Command, config::Config, io::ReadableIO, types_a::WorkingDirectory}; +use ghostty_core::{App as CoreAppImpl, Surface as CoreSurfaceImpl, surface::binding::Text}; +use ghostty_input::{ + Action as KeyAction, + Key, + KeyEvent, + Mods, + MouseButton, + MouseButtonState, + MousePressureStage, + ScrollMods, + binding::Action as BindingAction, + keycodes, +}; +use ghostty_terminal::{ + point::{Coordinate, Point, Tag as PointTag}, + selection::Selection, + terminal::Terminal, +}; + +use crate::{ + action_c::{Keep, action_to_c, target_to_c}, + config_capi::cconfig, + main_c::{alloc_c_text, free_c_text}, + types::{ + ghostty_app_t, + ghostty_binding_flags_e, + ghostty_clipboard_content_s, + ghostty_config_t, + ghostty_input_key_s, + ghostty_point_s, + ghostty_runtime_config_s, + ghostty_selection_s, + ghostty_surface_config_s, + ghostty_surface_size_s, + ghostty_surface_t, + ghostty_text_s, + platform, + }, +}; + +// --------------------------------------------------------------------- +// Waker + +/// `App.wakeup`: invoked from any core thread; calls the host's `wakeup_cb`. +struct WakerImpl { + cb: unsafe extern "C" fn(*mut c_void), + userdata: usize, +} + +impl Waker for WakerImpl { + fn wake(&self) { + // SAFETY: the embedder promises `wakeup_cb` may be called from any + // thread with the `userdata` it supplied (the documented contract of + // `ghostty_runtime_wakeup_cb`). + unsafe { (self.cb)(self.userdata as *mut c_void) }; + } +} + +// --------------------------------------------------------------------- +// Runtime app + +/// The runtime side of the app (Zig: `embedded.App`), handed to core as +/// `Rc`. +pub struct AppRt { + opts: ghostty_runtime_config_s, + /// The `ghostty_app_t` passed to the action callback. + app_ptr: Cell, + /// The configuration for the app (a clone, owned by the app). + config: RefCell>, + surfaces: RefCell>>, + keymap: RefCell, +} + +impl AppRt { + fn surface_rt(&self, id: SurfaceId) -> Option> { + self.surfaces.borrow().get(&id).and_then(Weak::upgrade) + } + + /// Special case certain actions before they are sent to the embedder + /// (Zig: `performPreAction`). + fn pre_action(&self, target: &Target, action: &Action) { + match (action, target) { + (Action::SetTitle(t), Target::Surface(id)) => { + if let Some(s) = self.surface_rt(*id) { + *s.title.borrow_mut() = Some(t.title.clone()); + } + } + + // For app updates, we update our core config. The caller owns + // the param so we clone it. + (Action::ConfigChange(c), Target::App) => { + *self.config.borrow_mut() = Arc::clone(&c.config); + } + + _ => {} + } + } + + /// Reload the keyboard layout (`App.reloadKeymap`). + fn reload_keymap(&self) { + if let Err(err) = self.keymap.borrow_mut().reload() { + log::error!("error reloading keyboard map err={err:?}"); + } + } +} + +impl RtApp for AppRt { + fn perform_action(&self, target: Target, action: Action) -> bool { + self.pre_action(&target, &action); + + log::debug!( + "dispatching action target={target:?} action={:?}", + action.key() + ); + let Some(cb) = self.opts.action_cb else { + return false; + }; + let mut keep = Keep::default(); + let t = target_to_c(&target); + let a = action_to_c(&action, &mut keep); + // SAFETY: the embedder supplied the callback; it receives the app + // handle it knows plus plain-data structs whose pointers stay valid + // (`keep`) for the duration of the call. + unsafe { cb(self.app_ptr.get() as ghostty_app_t, t, a) } + } + + fn keyboard_layout(&self) -> ghostty_input::KeyboardLayout { + keyboard_layout(&self.keymap.borrow()) + } +} + +/// `App.keyboardLayout`: we only support keyboard layout detection on macOS. +#[cfg(target_os = "macos")] +fn keyboard_layout(keymap: &ghostty_input::Keymap) -> ghostty_input::KeyboardLayout { + keymap + .source_id() + .and_then(|id| ghostty_input::KeyboardLayout::map_apple_id(&id)) + .unwrap_or(ghostty_input::KeyboardLayout::Unknown) +} + +#[cfg(not(target_os = "macos"))] +#[allow(clippy::trivially_copy_pass_by_ref)] +fn keyboard_layout(_keymap: &ghostty_input::Keymap) -> ghostty_input::KeyboardLayout { + ghostty_input::KeyboardLayout::Unknown +} + +/// The app object behind `ghostty_app_t`. +pub struct AppHandle { + core: Rc, + rt: Rc, +} + +/// Borrow the app behind a handle. +/// +/// # Safety +/// `p` must be null or a live handle from `ghostty_app_new`. +unsafe fn app<'a>(p: ghostty_app_t) -> Option<&'a AppHandle> { + // SAFETY: per the function contract. + unsafe { p.cast::().as_ref() } +} + +/// Borrow a C string. +/// +/// # Safety +/// `p` must be null or NUL terminated. +unsafe fn cstr<'a>(p: *const c_char) -> Option<&'a CStr> { + if p.is_null() { + None + } else { + // SAFETY: per the function contract. + Some(unsafe { CStr::from_ptr(p) }) + } +} + +fn mods_from_c(v: c_int) -> Mods { + // `@truncate(@as(c_uint, @bitCast(mods)))` to the u16 backing. + Mods::from_int(v as u32 as u16) +} + +/// An owned key event converted from the C struct (Zig: `KeyEvent.core`). +struct OwnedKey { + action: KeyAction, + key: Key, + mods: Mods, + consumed_mods: Mods, + composing: bool, + text: String, + unshifted_codepoint: u32, +} + +impl OwnedKey { + /// `None` if the action is invalid. + /// + /// # Safety + /// `text` must be null or NUL terminated. + unsafe fn from_c(e: &ghostty_input_key_s) -> Option { + let action = match e.action { + 0 => KeyAction::Release, + 1 => KeyAction::Press, + 2 => KeyAction::Repeat, + _ => return None, + }; + // SAFETY: per the function contract. + let text = unsafe { cstr(e.text) } + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_default(); + + // We want to get the physical unmapped key to process keybinds. + let key = keycodes::entries() + .iter() + .find(|entry| entry.native == e.keycode) + .map_or(Key::Unidentified, |entry| entry.key); + + // The unshifted codepoint is a `u21`; anything larger is 0. + let unshifted_codepoint = if e.unshifted_codepoint <= 0x1F_FFFF { + e.unshifted_codepoint + } else { + 0 + }; + + Some(OwnedKey { + action, + key, + mods: mods_from_c(e.mods), + consumed_mods: mods_from_c(e.consumed_mods), + composing: e.composing, + text, + unshifted_codepoint, + }) + } + + fn event(&self) -> KeyEvent<'_> { + KeyEvent { + action: self.action, + key: self.key, + mods: self.mods, + consumed_mods: self.consumed_mods, + composing: self.composing, + utf8: &self.text, + unshifted_codepoint: self.unshifted_codepoint, + } + } +} + +// --------------------------------------------------------------------- +// App exports + +/// Create a new app. +/// +/// # Safety +/// `opts` and `config` must be valid pointers (the config a handle from +/// `ghostty_config_new`/`_clone`). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_new( + opts: *const ghostty_runtime_config_s, + config: ghostty_config_t, +) -> ghostty_app_t { + // SAFETY: per the function contract. + let (Some(opts), Some(config)) = (unsafe { opts.as_ref() }, unsafe { cconfig(config) }) else { + log::error!("error initializing app err=NullArgument"); + return std::ptr::null_mut(); + }; + let Some(wakeup) = opts.wakeup_cb else { + log::error!("error initializing app err=MissingWakeupCallback"); + return std::ptr::null_mut(); + }; + + let waker = Arc::new(WakerImpl { + cb: wakeup, + userdata: opts.userdata as usize, + }); + let core = match CoreAppImpl::new(waker) { + Ok(c) => Rc::new(c), + Err(err) => { + log::error!("error initializing app err={err}"); + return std::ptr::null_mut(); + } + }; + let keymap = match ghostty_input::Keymap::new() { + Ok(k) => k, + Err(err) => { + log::error!("error initializing app err={err:?}"); + return std::ptr::null_mut(); + } + }; + + // We have to clone the config. + let rt = Rc::new(AppRt { + opts: *opts, + app_ptr: Cell::new(0), + config: RefCell::new(Arc::new(config.config.clone())), + surfaces: RefCell::default(), + keymap: RefCell::new(keymap), + }); + let ptr = Box::into_raw(Box::new(AppHandle { core, rt })); + // SAFETY: just allocated. + unsafe { &*ptr }.rt.app_ptr.set(ptr as usize); + ptr.cast::() +} + +/// Tick the event loop. This should be called whenever the "wakeup" callback +/// is invoked for the runtime. +/// +/// # Safety +/// `v` must be a live app handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_tick(v: ghostty_app_t) { + // SAFETY: per the function contract. + let Some(a) = (unsafe { app(v) }) else { + return; + }; + if let Err(err) = a.core.tick(&*a.rt) { + log::error!("error app tick err={err}"); + } +} + +/// Return the userdata associated with the app. +/// +/// # Safety +/// `v` must be a live app handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_userdata(v: ghostty_app_t) -> *mut c_void { + // SAFETY: per the function contract. + unsafe { app(v) }.map_or(std::ptr::null_mut(), |a| a.rt.opts.userdata) +} + +/// Free the app. +/// +/// # Safety +/// `v` must be a live app handle with no surfaces left; it is invalid +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_free(v: ghostty_app_t) { + if v.is_null() { + return; + } + // SAFETY: created by `ghostty_app_new` via `Box::into_raw`. + drop(unsafe { Box::from_raw(v.cast::()) }); +} + +/// Update the focused state of the app. +/// +/// # Safety +/// `app` must be a live app handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_set_focus(handle: ghostty_app_t, focused: bool) { + // SAFETY: per the function contract. + if let Some(a) = unsafe { app(handle) } { + a.core.focus_event(focused); + } +} + +/// Notify the app of a global keypress capture. This will return true if the +/// key was captured by the app, in which case the caller should not process +/// the key. +/// +/// # Safety +/// `handle` must be a live app handle; `event.text` null or NUL terminated. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_key( + handle: ghostty_app_t, + event: ghostty_input_key_s, +) -> bool { + // SAFETY: per the function contract. + let Some(a) = (unsafe { app(handle) }) else { + return false; + }; + // SAFETY: per the function contract. + let Some(key) = (unsafe { OwnedKey::from_c(&event) }) else { + return false; + }; + // Never hold the borrow across the call: actions call into the host. + let config = Arc::clone(&a.rt.config.borrow()); + a.core.key_event(&*a.rt, &config, &key.event()) +} + +/// Returns true if the given key event would trigger a binding if it were +/// sent to the surface right now. The "right now" is important because +/// things like trigger sequences are only valid until the next key event. +/// +/// # Safety +/// As [`ghostty_app_key`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_key_is_binding( + handle: ghostty_app_t, + event: ghostty_input_key_s, +) -> bool { + // SAFETY: per the function contract. + let Some(a) = (unsafe { app(handle) }) else { + return false; + }; + // SAFETY: per the function contract. + let Some(key) = (unsafe { OwnedKey::from_c(&event) }) else { + log::warn!("error processing key event"); + return false; + }; + let config = Arc::clone(&a.rt.config.borrow()); + a.core.key_event_is_binding(&config, &key.event()) +} + +/// Notify the app that the keyboard was changed. This causes the keyboard +/// layout to be reloaded from the OS. +/// +/// # Safety +/// `v` must be a live app handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_keyboard_changed(v: ghostty_app_t) { + // SAFETY: per the function contract. + if let Some(a) = unsafe { app(v) } { + a.rt.reload_keymap(); + } +} + +/// Open the configuration. +/// +/// # Safety +/// `v` must be a live app handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_open_config(v: ghostty_app_t) { + // SAFETY: per the function contract. + if let Some(a) = unsafe { app(v) } { + a.rt.perform_action(Target::App, Action::OpenConfig); + } +} + +/// Update the configuration to the provided config. This will propagate to +/// all surfaces as well. +/// +/// # Safety +/// `v` a live app handle, `config` a config handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_update_config(v: ghostty_app_t, config: ghostty_config_t) { + // SAFETY: per the function contract. + let (Some(a), Some(c)) = (unsafe { app(v) }, unsafe { cconfig(config) }) else { + return; + }; + let cfg = Arc::new(c.config.clone()); + if let Err(err) = a.core.update_config(&*a.rt, &cfg) { + log::error!("error updating config err={err}"); + } +} + +/// Returns true if the app needs to confirm quitting. +/// +/// # Safety +/// `v` must be a live app handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_needs_confirm_quit(v: ghostty_app_t) -> bool { + // SAFETY: per the function contract. + unsafe { app(v) }.is_some_and(|a| a.core.needs_confirm_quit()) +} + +/// Returns true if the app has global keybinds. +/// +/// # Safety +/// `v` must be a live app handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_has_global_keybinds(v: ghostty_app_t) -> bool { + use ghostty_input::binding::set::Value; + // SAFETY: per the function contract. + let Some(a) = (unsafe { app(v) }) else { + return false; + }; + let config = a.rt.config.borrow(); + config + .keybind + .set + .bindings + .values() + .any(|value| match value { + Value::Leader(_) => false, + Value::Leaf(leaf) => leaf.flags.global, + Value::LeafChained(leaf) => leaf.flags.global, + }) +} + +fn color_scheme_from_c(v: c_int) -> Option { + match v { + 0 => Some(ColorScheme::Light), + 1 => Some(ColorScheme::Dark), + _ => None, + } +} + +/// Update the color scheme of the app. +/// +/// # Safety +/// `v` must be a live app handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_app_set_color_scheme(v: ghostty_app_t, scheme_raw: c_int) { + // SAFETY: per the function contract. + let Some(a) = (unsafe { app(v) }) else { + return; + }; + let Some(scheme) = color_scheme_from_c(scheme_raw) else { + log::warn!("invalid color scheme to ghostty_app_set_color_scheme value={scheme_raw}"); + return; + }; + a.core.color_scheme_event(&*a.rt, scheme); +} + +// --------------------------------------------------------------------- +// Runtime surface + +/// The runtime side of a surface (Zig: `embedded.Surface` fields that core +/// reads), handed to core as `Rc`. +pub struct SurfaceRt { + app: Rc, + id: SurfaceId, + userdata: *mut c_void, + /// The `NSView` / `UIView` of the surface. + view: *mut c_void, + content_scale: Cell, + size: Cell, + cursor_pos: Cell, + /// The current title of the surface. The embedded apprt saves this so + /// that `get_title` works without the implementer needing to save it. + title: RefCell>, +} + +impl SurfaceRt { + fn target(&self) -> Target { + Target::Surface(self.id) + } +} + +impl RtSurface for SurfaceRt { + fn close(&self, process_active: bool) { + let Some(cb) = self.app.opts.close_surface_cb else { + log::info!("runtime embedder does not support closing a surface"); + return; + }; + // SAFETY: embedder supplied callback with its own userdata. + unsafe { cb(self.userdata, process_active) }; + } + + fn cgroup(&self) -> Option { + None + } + + fn get_title(&self) -> Option { + self.title.borrow().clone() + } + + fn get_content_scale(&self) -> ContentScale { + self.content_scale.get() + } + + fn get_size(&self) -> SurfaceSize { + self.size.get() + } + + fn get_cursor_pos(&self) -> CursorPos { + self.cursor_pos.get() + } + + fn supports_clipboard(&self, clipboard: Clipboard) -> bool { + match clipboard { + Clipboard::Standard => true, + Clipboard::Selection | Clipboard::Primary => self.app.opts.supports_selection_clipboard, + } + } + + fn clipboard_request(&self, clipboard: Clipboard, state: ClipboardRequest) -> bool { + // We need to allocate to get a pointer to store our clipboard + // request so that it is stable until the read_clipboard callback + // and call complete_clipboard_request. This sucks but clipboard + // requests aren't high throughput so it's probably fine. + let Some(cb) = self.app.opts.read_clipboard_cb else { + return false; + }; + let state_ptr = Box::into_raw(Box::new(state)); + // SAFETY: embedder callback; `state_ptr` stays valid until + // `ghostty_surface_complete_clipboard_request` or we reclaim it. + let started = unsafe { + cb( + self.userdata, + c_int::from(clipboard as u8), + state_ptr.cast::(), + ) + }; + if !started { + // SAFETY: allocated above and not handed over. + drop(unsafe { Box::from_raw(state_ptr) }); + return false; + } + true + } + + fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) { + let Some(cb) = self.app.opts.write_clipboard_cb else { + return; + }; + let owned: Vec<(CString, CString)> = contents + .iter() + .map(|(m, d)| { + ( + CString::new(m.replace('\0', "")).unwrap_or_default(), + CString::new(d.replace('\0', "")).unwrap_or_default(), + ) + }) + .collect(); + let array: Vec = owned + .iter() + .map(|(m, d)| ghostty_clipboard_content_s { + mime: m.as_ptr(), + data: d.as_ptr(), + }) + .collect(); + // SAFETY: embedder callback; the array and strings outlive the call. + unsafe { + cb( + self.userdata, + c_int::from(clipboard as u8), + array.as_ptr(), + array.len(), + confirm, + ); + } + } + + fn default_termio_env(&self) -> Vec<(std::ffi::OsString, std::ffi::OsString)> { + let mut env = ghostty_os::env::get_env_map(); + + if cfg!(target_vendor = "apple") { + if env.contains_key("__XCODE_BUILT_PRODUCTS_DIR_PATHS") { + for k in [ + "__XCODE_BUILT_PRODUCTS_DIR_PATHS", + "__XPC_DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "LD_LIBRARY_PATH", + "SECURITYSESSIONID", + "XPC_SERVICE_NAME", + ] { + env.remove(k); + } + } + + // Remove this so that running `ghostty` within Ghostty works. + env.remove("GHOSTTY_MAC_LAUNCH_SOURCE"); + + // If we were launched from the desktop then we want to remove + // the LANGUAGE env var so that we don't inherit our translation + // settings for Ghostty. If we aren't from the desktop then we + // didn't set our LANGUAGE var so we don't need to remove it. + if launched_from_desktop() { + env.remove("LANGUAGE"); + } + } + + env.into_iter().collect() + } + + fn redraw_inspector(&self) { + // `queueInspectorRender` + self.app + .perform_action(self.target(), Action::RenderInspector); + } + + fn platform_view(&self) -> *mut c_void { + self.view + } + + #[cfg(feature = "test-platform")] + fn gl_proc_address(&self, name: &str) -> *const c_void { + test_platform::proc_address(name) + } +} + +#[cfg(target_os = "macos")] +fn launched_from_desktop() -> bool { + ghostty_os::desktop::launched_from_desktop_lib() +} + +#[cfg(not(target_os = "macos"))] +fn launched_from_desktop() -> bool { + ghostty_os::desktop::launched_from_desktop() +} + +/// The surface object behind `ghostty_surface_t`. +pub struct Surface { + app: Rc, + rt: Rc, + core: OnceCell>, + inspector: RefCell>>, + /// Strings handed out by `ghostty_surface_inherited_config`; they stay + /// valid until the surface is freed. + inherited_wd: RefCell>, +} + +impl Surface { + fn core(&self) -> Option<&Rc> { + self.core.get() + } + + pub(crate) fn inspector_slot(&self) -> &RefCell>> { + &self.inspector + } + + pub(crate) fn core_surface(&self) -> Option<&Rc> { + self.core() + } + + pub(crate) fn rt(&self) -> &Rc { + &self.rt + } +} + +impl Drop for Surface { + fn drop(&mut self) { + // Shut down our inspector + if let Some(mut i) = self.inspector.get_mut().take() { + i.deinit(self.core.get().map(Rc::as_ref)); + } + + // Remove ourselves from the list of known surfaces in the app. + self.app.core.delete_surface(self.rt.id); + self.app.rt.surfaces.borrow_mut().remove(&self.rt.id); + + // Clean up our core surface so that all the rendering and IO stop. + drop(self.core.take()); + } +} + +/// Borrow the surface behind a handle. +/// +/// # Safety +/// `p` must be null or a live handle from `ghostty_surface_new`. +pub(crate) unsafe fn surface<'a>(p: ghostty_surface_t) -> Option<&'a Surface> { + // SAFETY: per the function contract. + unsafe { p.cast::().as_ref() } +} + +/// `ghostty_surface_config_new`. +#[unsafe(no_mangle)] +pub extern "C" fn ghostty_surface_config_new() -> ghostty_surface_config_s { + ghostty_surface_config_s { + platform_tag: platform::INVALID, + platform: crate::types::ghostty_platform_u { + macos: crate::types::ghostty_platform_macos_s { + nsview: std::ptr::null_mut(), + }, + }, + userdata: std::ptr::null_mut(), + scale_factor: 1.0, + font_size: 0.0, + working_directory: std::ptr::null(), + command: std::ptr::null(), + env_vars: std::ptr::null_mut(), + env_var_count: 0, + initial_input: std::ptr::null(), + wait_after_command: false, + context: 0, + } +} + +fn context_from_c(v: c_int) -> NewSurfaceContext { + match v { + 1 => NewSurfaceContext::Tab, + 2 => NewSurfaceContext::Split, + _ => NewSurfaceContext::Window, + } +} + +/// Test seam (feature `test-platform`): a fake platform whose OpenGL loader +/// the test installs, so surfaces can be created on Linux. +#[cfg(feature = "test-platform")] +pub mod test_platform { + use std::{cell::RefCell, ffi::c_void, rc::Rc}; + + /// The `platform_tag` that selects the fake platform. + pub const PLATFORM_TAG: i32 = 1000; + + /// A GL function loader. + pub type GlLoader = Rc *const c_void>; + + thread_local! { + static LOADER: RefCell> = const { RefCell::new(None) }; + } + + /// Install the GL loader for surfaces created on this thread. + pub fn set_gl_loader(loader: Option) { + LOADER.with(|l| *l.borrow_mut() = loader); + } + + pub(super) fn proc_address(name: &str) -> *const c_void { + LOADER.with(|l| l.borrow().as_ref().map_or(std::ptr::null(), |f| f(name))) + } +} + +/// Initialize the platform view (Zig: `Platform.init`). Darwin only. +/// +/// # Safety +/// The union member selected by `tag` must be initialized. +unsafe fn platform_view_from_c( + opts: &ghostty_surface_config_s, +) -> Result<*mut c_void, &'static str> { + #[cfg(feature = "test-platform")] + if opts.platform_tag == test_platform::PLATFORM_TAG { + // Any non-null "view" will do; the renderer is OpenGL here. + return Ok(std::ptr::NonNull::::dangling().as_ptr()); + } + if !cfg!(target_vendor = "apple") { + // The platform union has no members off Darwin. + return Err("UnsupportedPlatform"); + } + match opts.platform_tag { + // SAFETY: the tag selects the initialized member. + platform::MACOS => match unsafe { opts.platform.macos.nsview } { + v if v.is_null() => Err("NSViewMustBeSet"), + v => Ok(v), + }, + // SAFETY: as above. + platform::IOS => match unsafe { opts.platform.ios.uiview } { + v if v.is_null() => Err("UIViewMustBeSet"), + v => Ok(v), + }, + _ => Err("InvalidEnumTag"), + } +} + +/// Zig's `std.zig.stringEscape` with non-ASCII code points written as +/// `\u{...}` (the config value is parsed as a Zig string; the bytes of +/// valid UTF-8 round trip either way). +fn string_escape(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for c in input.chars() { + match c { + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + ' ' | '!' | '#'..='&' | '\'' | '('..='[' | ']'..='~' => out.push(c), + c if c.is_ascii() => { + let _ = write!(out, "\\x{:02x}", c as u32); + } + c => { + let _ = write!(out, "\\u{{{:x}}}", c as u32); + } + } + } + out +} + +/// Apply the surface options to the config (the first half of Zig's +/// `Surface.init`). +/// +/// # Safety +/// The pointers in `opts` must satisfy the contract of `ghostty.h`. +unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_config_s) { + // If we have a working directory from the options then we set it. + // SAFETY: per the function contract. + if let Some(wd) = + unsafe { cstr(opts.working_directory) }.map(|c| c.to_string_lossy().into_owned()) + && !wd.is_empty() + { + match std::fs::metadata(&wd) { + Err(err) => log::warn!("error opening requested working directory dir={wd} err={err}"), + Ok(m) if !m.is_dir() => { + log::warn!("requested working directory is not a directory dir={wd}"); + } + Ok(_) => { + let mut wd_val = WorkingDirectory::Path(wd); + wd_val.finalize(); + config.working_directory = Some(wd_val); + } + } + } + + // If we have a command from the options then we set it. + // SAFETY: per the function contract. + if let Some(cmd) = unsafe { cstr(opts.command) }.map(|c| c.to_string_lossy().into_owned()) + && !cmd.is_empty() + { + config.command = Some(Command::Shell(cmd)); + config.wait_after_command = true; + } + + // Apply any environment variables that were requested. + if opts.env_var_count > 0 && !opts.env_vars.is_null() { + // SAFETY: the caller supplies `env_var_count` entries. + let vars = unsafe { std::slice::from_raw_parts(opts.env_vars, opts.env_var_count) }; + for var in vars { + // SAFETY: entries are NUL terminated strings. + let (Some(k), Some(v)) = (unsafe { cstr(var.key) }, unsafe { cstr(var.value) }) else { + continue; + }; + config.env.put(&k.to_string_lossy(), &v.to_string_lossy()); + } + } + + // If we have an initial input then we set it. + // SAFETY: per the function contract. + if let Some(input) = unsafe { cstr(opts.initial_input) } { + // We need to escape the string because the "raw" field expects a + // Zig string. + config.input.list.clear(); + config + .input + .list + .push(ReadableIO::Raw(string_escape(&input.to_string_lossy()))); + } + + // Wait after command + if opts.wait_after_command { + config.wait_after_command = true; + } +} + +/// Create a new surface as part of an app. +/// +/// # Safety +/// `app_ptr` a live app handle; `opts` a valid config (see `ghostty.h`). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_new( + app_ptr: ghostty_app_t, + opts: *const ghostty_surface_config_s, +) -> ghostty_surface_t { + // SAFETY: per the function contract. + let (Some(app_ref), Some(opts)) = (unsafe { app(app_ptr) }, unsafe { opts.as_ref() }) else { + log::error!("error initializing surface err=NullArgument"); + return std::ptr::null_mut(); + }; + // SAFETY: per the function contract. + let view = match unsafe { platform_view_from_c(opts) } { + Ok(v) => v, + Err(err) => { + log::error!("error initializing surface err={err}"); + return std::ptr::null_mut(); + } + }; + + // Build the runtime surface. Its address is its id. + let scale = opts.scale_factor as f32; + let app_handle = { + // `AppHandle` is owned by the C handle; the surface only needs + // shared access to its parts, which are `Rc`s. + Rc::new(AppHandle { + core: Rc::clone(&app_ref.core), + rt: Rc::clone(&app_ref.rt), + }) + }; + let mut boxed = Box::new(Surface { + app: app_handle, + rt: Rc::new(SurfaceRt { + app: Rc::clone(&app_ref.rt), + id: 0, + userdata: opts.userdata, + view, + content_scale: Cell::new(ContentScale { x: scale, y: scale }), + size: Cell::new(SurfaceSize { + width: 800, + height: 600, + }), + cursor_pos: Cell::new(CursorPos { x: -1.0, y: -1.0 }), + title: RefCell::new(None), + }), + core: OnceCell::new(), + inspector: RefCell::new(None), + inherited_wd: RefCell::new(Vec::new()), + }); + let id = std::ptr::from_mut::(&mut *boxed) as usize as SurfaceId; + // The id is only known now (the address of the heap object). + if let Some(rt) = Rc::get_mut(&mut boxed.rt) { + rt.id = id; + } + app_ref + .rt + .surfaces + .borrow_mut() + .insert(id, Rc::downgrade(&boxed.rt)); + let ptr = Box::into_raw(boxed); + + // Shallow copy the config so that we can modify it. + let mut config = { + let app_config = app_ref.rt.config.borrow(); + let prev_pwd = app_ref.core.state().focused_surface().and_then(|s| s.pwd()); + new_config(&app_config, context_from_c(opts.context), prev_pwd) + }; + // SAFETY: per the function contract. + unsafe { apply_surface_options(&mut config, opts) }; + + // Initialize our surface right away. We're given a view that is ready + // to use. + // SAFETY: `ptr` is the live allocation made above. + let s = unsafe { &*ptr }; + let rt_app: Rc = Rc::clone(&app_ref.rt) as Rc; + let rt_surface: Rc = Rc::clone(&s.rt) as Rc; + match app_ref.core.create_surface(id, config, rt_app, rt_surface) { + Ok(core) => { + let _ = s.core.set(Rc::clone(&core)); + // If our options requested a specific font-size, set that. + if opts.font_size != 0.0 { + let mut font_size = core.font_size(); + font_size.points = opts.font_size; + if let Err(err) = core.set_font_size(font_size) { + log::error!("error initializing surface err={err}"); + // SAFETY: allocated by us and not exposed yet. + drop(unsafe { Box::from_raw(ptr) }); + return std::ptr::null_mut(); + } + } + ptr.cast::() + } + Err(err) => { + log::error!("error initializing surface err={err}"); + // SAFETY: allocated by us and not exposed yet. + drop(unsafe { Box::from_raw(ptr) }); + std::ptr::null_mut() + } + } +} + +/// Free a surface. +/// +/// # Safety +/// `ptr` must be a live surface handle; it is invalid afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_free(ptr: ghostty_surface_t) { + if ptr.is_null() { + return; + } + // SAFETY: created by `ghostty_surface_new` via `Box::into_raw`. + drop(unsafe { Box::from_raw(ptr.cast::()) }); +} + +/// Returns the userdata associated with the surface. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_userdata(ptr: ghostty_surface_t) -> *mut c_void { + // SAFETY: per the function contract. + unsafe { surface(ptr) }.map_or(std::ptr::null_mut(), |s| s.rt.userdata) +} + +/// Returns the app associated with a surface. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_app(ptr: ghostty_surface_t) -> ghostty_app_t { + // SAFETY: per the function contract. + unsafe { surface(ptr) }.map_or(std::ptr::null_mut(), |s| { + s.rt.app.app_ptr.get() as ghostty_app_t + }) +} + +/// Returns the config to use for surfaces that inherit from this one. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_inherited_config( + ptr: ghostty_surface_t, + source: c_int, +) -> ghostty_surface_config_s { + let mut result = ghostty_surface_config_new(); + result.context = source; + // SAFETY: per the function contract. + let Some(s) = (unsafe { surface(ptr) }) else { + return result; + }; + let Some(core) = s.core() else { + return result; + }; + let context = context_from_c(source); + let config = s.rt.app.config.borrow(); + + // The font size to inherit. If 0, default font size will be used. + if config.window_inherit_font_size { + result.font_size = core.font_size().points; + } + + if should_inherit_working_directory(context, &config) + && let Some(cwd) = core.pwd() + && let Ok(c) = CString::new(cwd) + { + let mut keep = s.inherited_wd.borrow_mut(); + result.working_directory = c.as_ptr(); + keep.push(c); + } + result +} + +/// Update the configuration to the provided config for only this surface. +/// +/// # Safety +/// `ptr` a live surface handle, `config` a config handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_update_config( + ptr: ghostty_surface_t, + config: ghostty_config_t, +) { + // SAFETY: per the function contract. + let (Some(s), Some(c)) = (unsafe { surface(ptr) }, unsafe { cconfig(config) }) else { + return; + }; + let Some(core) = s.core() else { + return; + }; + if let Err(err) = core.update_config(&Arc::new(c.config.clone())) { + log::error!("error updating config err={err}"); + } +} + +/// Returns true if the surface needs to confirm quitting. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_needs_confirm_quit(ptr: ghostty_surface_t) -> bool { + // SAFETY: per the function contract. + unsafe { surface(ptr) } + .and_then(Surface::core) + .is_some_and(|c| c.needs_confirm_quit()) +} + +/// Returns true if the surface process has exited. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_process_exited(ptr: ghostty_surface_t) -> bool { + // SAFETY: per the function contract. + unsafe { surface(ptr) } + .and_then(Surface::core) + .is_some_and(|c| CoreSurface::child_exited(c.as_ref())) +} + +/// Returns true if the surface has a selection. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_has_selection(ptr: ghostty_surface_t) -> bool { + // SAFETY: per the function contract. + unsafe { surface(ptr) } + .and_then(Surface::core) + .is_some_and(|c| c.has_selection()) +} + +/// Fill the C text result from a read. +fn fill_text( + core: &CoreSurfaceImpl, + t: &Terminal, + sel: Selection, + result: &mut ghostty_text_s, +) -> bool { + // Get our text directly from the core surface. + let text: Text = match core.dump_text_locked(t, sel) { + Ok(t) => t, + Err(err) => { + log::warn!("error reading text err={err}"); + return false; + } + }; + + let (tl_px_x, tl_px_y, offset_start, offset_len) = + text.viewport.map_or((-1.0, -1.0, 0, 0), |vp| { + (vp.tl_px_x, vp.tl_px_y, vp.offset_start, vp.offset_len) + }); + *result = ghostty_text_s { + tl_px_x, + tl_px_y, + offset_start, + offset_len, + text: alloc_c_text(text.text.as_bytes()), + text_len: text.text.len(), + }; + true +} + +/// Same as `ghostty_surface_read_text` but reads from the user selection, if +/// any. +/// +/// # Safety +/// `ptr` a live surface handle, `result` a valid out pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_read_selection( + ptr: ghostty_surface_t, + result: *mut ghostty_text_s, +) -> bool { + // SAFETY: per the function contract. + let (Some(core), Some(result)) = (unsafe { surface(ptr) }.and_then(Surface::core), unsafe { + result.as_mut() + }) else { + return false; + }; + let guard = core.lock_terminal(); + let t: &Terminal = &guard; + + // If we don't have a selection, do nothing. + let Some(sel) = t.screen().selection else { + return false; + }; + fill_text(core, t, sel, result) +} + +/// The pin of a C point (Zig: `Point.pin`). +fn point_pin(p: &ghostty_point_s, t: &Terminal) -> Option { + let tag = match p.tag { + 0 => PointTag::Active, + 1 => PointTag::Viewport, + 2 => PointTag::Screen, + 3 => PointTag::History, + _ => return None, + }; + let pages = &t.screen().pages; + + // Clamp our point to the screen bounds. + let clamped_x = p.x.min(u32::from(pages.cols.saturating_sub(1))); + let clamped_y = p.y.min(u32::from(pages.rows.saturating_sub(1))); + + match p.coord { + // Exact coordinates require a specific pin. + 0 => { + let x = u16::try_from(clamped_x).unwrap_or(u16::MAX); + pages.pin(Point::new(tag, Coordinate { x, y: clamped_y })) + } + 1 => pages.get_top_left(tag), + 2 => pages.get_bottom_right(tag), + _ => None, + } +} + +fn selection_from_c(sel: &ghostty_selection_s, t: &Terminal) -> Option { + Some(Selection::new( + point_pin(&sel.top_left, t)?, + point_pin(&sel.bottom_right, t)?, + sel.rectangle, + )) +} + +/// Read some arbitrary text from the surface. +/// +/// This is an expensive operation so it shouldn't be called too often. We +/// recommend that callers cache the result and throttle calls to this +/// function. +/// +/// # Safety +/// `ptr` a live surface handle, `result` a valid out pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_read_text( + ptr: ghostty_surface_t, + sel: ghostty_selection_s, + result: *mut ghostty_text_s, +) -> bool { + // SAFETY: per the function contract. + let (Some(core), Some(result)) = (unsafe { surface(ptr) }.and_then(Surface::core), unsafe { + result.as_mut() + }) else { + return false; + }; + let guard = core.lock_terminal(); + let t: &Terminal = &guard; + let Some(core_sel) = selection_from_c(&sel, t) else { + return false; + }; + fill_text(core, t, core_sel, result) +} + +/// Free text read from the surface. +/// +/// # Safety +/// `text` must be a result of one of the read functions (or null). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_free_text( + _ptr: ghostty_surface_t, + text: *mut ghostty_text_s, +) { + // SAFETY: per the function contract. + let Some(text) = (unsafe { text.as_mut() }) else { + return; + }; + // SAFETY: allocated by `alloc_c_text` with `text_len` bytes + NUL. + unsafe { free_c_text(text.text, text.text_len, true) }; + text.text = std::ptr::null(); + text.text_len = 0; +} + +/// Tell the surface that it needs to schedule a render. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_refresh(ptr: ghostty_surface_t) { + // SAFETY: per the function contract. + if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) + && let Err(err) = core.refresh_callback() + { + log::error!("error in refresh callback err={err}"); + } +} + +/// Tell the surface that it needs to schedule a render call as soon as +/// possible (NOW if possible). +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_draw(ptr: ghostty_surface_t) { + // SAFETY: per the function contract. + if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) + && let Err(err) = core.draw() + { + log::error!("error in draw err={err}"); + } +} + +/// Update the size of a surface. This will trigger resize notifications to +/// the pty and the renderer. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_set_size(ptr: ghostty_surface_t, w: u32, h: u32) { + // SAFETY: per the function contract. + let Some(s) = (unsafe { surface(ptr) }) else { + return; + }; + + // Runtimes sometimes generate superfluous resize events even if the + // size did not actually change (SwiftUI). We check that the size + // actually changed from what we last recorded since resizes are + // expensive. + let cur = s.rt.size.get(); + if cur.width == w && cur.height == h { + return; + } + let size = SurfaceSize { + width: w, + height: h, + }; + s.rt.size.set(size); + + if let Some(core) = s.core() + && let Err(err) = core.size_callback(size) + { + log::error!("error in size callback err={err}"); + } +} + +/// Return the size information a surface has. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_size(ptr: ghostty_surface_t) -> ghostty_surface_size_s { + let zero = ghostty_surface_size_s { + columns: 0, + rows: 0, + width_px: 0, + height_px: 0, + cell_width_px: 0, + cell_height_px: 0, + }; + // SAFETY: per the function contract. + let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else { + return zero; + }; + let size = core.size(); + let grid = size.grid(); + ghostty_surface_size_s { + columns: grid.columns, + rows: grid.rows, + width_px: size.screen.width, + height_px: size.screen.height, + cell_width_px: size.cell.width, + cell_height_px: size.cell.height, + } +} + +/// Update the color scheme of the surface. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_set_color_scheme( + ptr: ghostty_surface_t, + scheme_raw: c_int, +) { + let Some(scheme) = color_scheme_from_c(scheme_raw) else { + log::warn!("invalid color scheme to ghostty_surface_set_color_scheme value={scheme_raw}"); + return; + }; + // SAFETY: per the function contract. + if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) + && let Err(err) = core.color_scheme_callback(scheme) + { + log::error!("error setting color scheme err={err}"); + } +} + +/// Update the content scale of the surface. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_set_content_scale(ptr: ghostty_surface_t, x: f64, y: f64) { + // SAFETY: per the function contract. + let Some(s) = (unsafe { surface(ptr) }) else { + return; + }; + + // We are an embedded API so the caller can send us all sorts of + // garbage. We want to make sure that the float values are valid and we + // don't want to support fractional scaling below 1. + let scaled = |v: f64| if v.is_nan() { 1.0 } else { v.max(1.0) } as f32; + let scale = ContentScale { + x: scaled(x), + y: scaled(y), + }; + s.rt.content_scale.set(scale); + + if let Some(core) = s.core() + && let Err(err) = core.content_scale_callback(scale) + { + log::error!("error in content scale callback err={err}"); + } +} + +/// Update the focused state of a surface. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_set_focus(ptr: ghostty_surface_t, focused: bool) { + // SAFETY: per the function contract. + if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) + && let Err(err) = core.focus_callback(focused) + { + log::error!("error in focus callback err={err}"); + } +} + +/// Update the occlusion state of a surface. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_set_occlusion(ptr: ghostty_surface_t, visible: bool) { + // SAFETY: per the function contract. + if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) + && let Err(err) = core.occlusion_callback(visible) + { + log::error!("error in occlusion callback err={err}"); + } +} + +/// Filter the mods if necessary. This handles settings such as +/// `macos-option-as-alt`. The filtered mods should be used for key +/// translation but should NOT be sent back via the `_key` function -- the +/// original mods should be used for that. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_key_translation_mods( + ptr: ghostty_surface_t, + mods_raw: c_int, +) -> c_int { + let mods = mods_from_c(mods_raw); + // SAFETY: per the function contract. + let Some(s) = (unsafe { surface(ptr) }) else { + return mods_raw; + }; + let Some(core) = s.core() else { + return mods_raw; + }; + let option_as_alt = core + .config() + .macos_option_as_alt + .unwrap_or_else(|| s.rt.app.keyboard_layout().detect_option_as_alt()); + c_int::from(mods.translation(option_as_alt).int()) +} + +/// Send this for raw keypresses (i.e. the keyDown event on macOS). This will +/// handle the keymap translation and send the appropriate key and char +/// events. +/// +/// # Safety +/// `ptr` a live surface handle; `event.text` null or NUL terminated. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_key( + ptr: ghostty_surface_t, + event: ghostty_input_key_s, +) -> bool { + // SAFETY: per the function contract. + let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else { + return false; + }; + // SAFETY: per the function contract. + let Some(key) = (unsafe { OwnedKey::from_c(&event) }) else { + return false; + }; + match CoreSurface::key_callback(core.as_ref(), key.event()) { + Ok(ghostty_apprt::runtime_api::InputEffect::Closed) => true, + Ok(ghostty_apprt::runtime_api::InputEffect::Ignored) => false, + Ok(ghostty_apprt::runtime_api::InputEffect::Consumed) => true, + Err(err) => { + log::warn!("error processing key event err={err}"); + false + } + } +} + +/// Returns true if the given key event would trigger a binding if it were +/// sent to the surface right now. The "right now" is important because +/// things like trigger sequences are only valid until the next key event. +/// +/// # Safety +/// As [`ghostty_surface_key`]; `c_flags` null or a valid out pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_key_is_binding( + ptr: ghostty_surface_t, + event: ghostty_input_key_s, + c_flags: *mut ghostty_binding_flags_e, +) -> bool { + // SAFETY: per the function contract. + let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else { + return false; + }; + // SAFETY: per the function contract. + let Some(key) = (unsafe { OwnedKey::from_c(&event) }) else { + log::warn!("error processing key event"); + return false; + }; + let Some(flags) = core.key_event_is_binding(&key.event()) else { + return false; + }; + if !c_flags.is_null() { + // Zig writes the `u8` `Flags.C` through the (enum-typed) pointer. + // SAFETY: the pointer is valid for at least one byte. + unsafe { c_flags.cast::().write(flags.cval()) }; + } + true +} + +/// Send raw text to the terminal. This is treated like a paste so this isn't +/// useful for sending escape sequences. For that, individual key input +/// should be used. +/// +/// # Safety +/// `ptr` a live surface handle, `text` points to `len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_text( + ptr: ghostty_surface_t, + text: *const c_char, + len: usize, +) { + // SAFETY: per the function contract. + let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else { + return; + }; + let bytes: &[u8] = if text.is_null() { + &[] + } else { + // SAFETY: `text` points to `len` readable bytes. + unsafe { std::slice::from_raw_parts(text.cast::(), len) } + }; + if let Err(err) = core.text_callback(&String::from_utf8_lossy(bytes)) { + log::error!("error in key callback err={err}"); + } +} + +/// Set the preedit text for the surface. This is used for IME composition. +/// If the length is 0, then the preedit text is cleared. +/// +/// # Safety +/// `ptr` a live surface handle, `text` points to `len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_preedit( + ptr: ghostty_surface_t, + text: *const c_char, + len: usize, +) { + // SAFETY: per the function contract. + let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else { + return; + }; + let preedit = if len == 0 || text.is_null() { + None + } else { + // SAFETY: `text` points to `len` readable bytes. + let bytes = unsafe { std::slice::from_raw_parts(text.cast::(), len) }; + Some(String::from_utf8_lossy(bytes).into_owned()) + }; + if let Err(err) = core.preedit_callback(preedit.as_deref()) { + log::error!("error in preedit callback err={err}"); + } +} + +/// Returns true if the surface currently has mouse capturing enabled. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_mouse_captured(ptr: ghostty_surface_t) -> bool { + // SAFETY: per the function contract. + unsafe { surface(ptr) } + .and_then(Surface::core) + .is_some_and(|c| c.mouse_captured()) +} + +/// Send a mouse button event. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_mouse_button( + ptr: ghostty_surface_t, + action: c_int, + button: c_int, + mods: c_int, +) -> bool { + // SAFETY: per the function contract. + let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else { + return false; + }; + let state = match action { + 0 => MouseButtonState::Release, + 1 => MouseButtonState::Press, + _ => return false, + }; + let Some(button) = mouse_button_from_c(button) else { + return false; + }; + match core.mouse_button_callback(state, button, mods_from_c(mods)) { + Ok(v) => v, + Err(err) => { + log::error!("error in mouse button callback err={err}"); + false + } + } +} + +pub(crate) fn mouse_button_from_c(v: c_int) -> Option { + Some(match v { + 0 => MouseButton::Unknown, + 1 => MouseButton::Left, + 2 => MouseButton::Right, + 3 => MouseButton::Middle, + 4 => MouseButton::Four, + 5 => MouseButton::Five, + 6 => MouseButton::Six, + 7 => MouseButton::Seven, + 8 => MouseButton::Eight, + 9 => MouseButton::Nine, + 10 => MouseButton::Ten, + 11 => MouseButton::Eleven, + _ => return None, + }) +} + +/// Update the mouse position within the view. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_mouse_pos( + ptr: ghostty_surface_t, + x: f64, + y: f64, + mods: c_int, +) { + // SAFETY: per the function contract. + let Some(s) = (unsafe { surface(ptr) }) else { + return; + }; + + // The cursor position from the host directly is in screen coordinates + // but all our interface works in pixels. + let scale = s.rt.content_scale.get(); + let pos = CursorPos { + x: x as f32 * scale.x, + y: y as f32 * scale.y, + }; + + // There are cases where the platform reports a mouse motion event + // without the cursor actually moving. For example, on macOS, updating + // the window title can trigger a phantom mouse-move event at the same + // coordinates. This can cause the mouse to incorrectly unhide when + // mouse-hide-while-typing is enabled (commonly seen with TUI apps like + // Zellij that frequently update the title). To prevent incorrect + // behavior, we only continue with callback logic if the cursor has + // actually moved. + let prev = s.rt.cursor_pos.get(); + if (prev.x - pos.x).abs() < 1.0 && (prev.y - pos.y).abs() < 1.0 { + return; + } + s.rt.cursor_pos.set(pos); + + if let Some(core) = s.core() + && let Err(err) = core.cursor_pos_callback(pos, Some(mods_from_c(mods))) + { + log::error!("error in cursor pos callback err={err}"); + } +} + +/// Send a scroll event. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_mouse_scroll( + ptr: ghostty_surface_t, + x: f64, + y: f64, + scroll_mods: c_int, +) { + // SAFETY: per the function contract. + if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) + && let Err(err) = core.scroll_callback(x, y, ScrollMods::from_int(scroll_mods as u32 as u8)) + { + log::error!("error in scroll callback err={err}"); + } +} + +/// Send a mouse pressure event. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_mouse_pressure( + ptr: ghostty_surface_t, + stage_raw: u32, + pressure: f64, +) { + let stage = match stage_raw { + 0 => MousePressureStage::None, + 1 => MousePressureStage::Normal, + 2 => MousePressureStage::Deep, + _ => { + log::warn!("invalid mouse pressure stage value={stage_raw}"); + return; + } + }; + // SAFETY: per the function contract. + if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) { + CoreSurface::mouse_pressure_callback(core.as_ref(), stage, pressure); + } +} + +/// The position of the IME candidate window. +/// +/// # Safety +/// `ptr` a live surface handle; the out pointers valid. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_ime_point( + ptr: ghostty_surface_t, + x: *mut f64, + y: *mut f64, + width: *mut f64, + height: *mut f64, +) { + // SAFETY: per the function contract. + let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else { + return; + }; + let pos = core.ime_point(); + // SAFETY: the out pointers are valid per the contract. + unsafe { + if let Some(x) = x.as_mut() { + *x = pos.x; + } + if let Some(y) = y.as_mut() { + *y = pos.y; + } + if let Some(w) = width.as_mut() { + *w = pos.width; + } + if let Some(h) = height.as_mut() { + *h = pos.height; + } + } +} + +/// Request that the surface become closed. This will go through the normal +/// trigger process that a close surface input binding would. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_request_close(ptr: ghostty_surface_t) { + // SAFETY: per the function contract. + if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) { + core.close(); + } +} + +/// Request that the surface split in the given direction. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_split(ptr: ghostty_surface_t, direction: c_int) { + let direction = match direction { + 0 => SplitDirection::Right, + 1 => SplitDirection::Down, + 2 => SplitDirection::Left, + 3 => SplitDirection::Up, + _ => return, + }; + // SAFETY: per the function contract. + if let Some(s) = unsafe { surface(ptr) } { + s.rt.app + .perform_action(s.rt.target(), Action::NewSplit(direction)); + } +} + +/// Focus on the next split (if any). +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_split_focus(ptr: ghostty_surface_t, direction: c_int) { + let direction = match direction { + 0 => GotoSplit::Previous, + 1 => GotoSplit::Next, + 2 => GotoSplit::Up, + 3 => GotoSplit::Left, + 4 => GotoSplit::Down, + 5 => GotoSplit::Right, + _ => return, + }; + // SAFETY: per the function contract. + if let Some(s) = unsafe { surface(ptr) } { + s.rt.app + .perform_action(s.rt.target(), Action::GotoSplit(direction)); + } +} + +/// Resize the current split by moving the split divider in the given +/// direction. `direction` specifies which direction the split divider will +/// move relative to the focused split. `amount` is a fractional value +/// between 0 and 1 that specifies by how much the divider will move. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_split_resize( + ptr: ghostty_surface_t, + direction: c_int, + amount: u16, +) { + let direction = match direction { + 0 => ResizeSplitDirection::Up, + 1 => ResizeSplitDirection::Down, + 2 => ResizeSplitDirection::Left, + 3 => ResizeSplitDirection::Right, + _ => return, + }; + // SAFETY: per the function contract. + if let Some(s) = unsafe { surface(ptr) } { + s.rt.app.perform_action( + s.rt.target(), + Action::ResizeSplit(ResizeSplit { amount, direction }), + ); + } +} + +/// Equalize the size of all splits in the current window. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_split_equalize(ptr: ghostty_surface_t) { + // SAFETY: per the function contract. + if let Some(s) = unsafe { surface(ptr) } { + s.rt.app + .perform_action(s.rt.target(), Action::EqualizeSplits); + } +} + +/// Invoke an action on the surface. +/// +/// # Safety +/// `ptr` a live surface handle, `action` points to `len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_binding_action( + ptr: ghostty_surface_t, + action: *const c_char, + len: usize, +) -> bool { + // SAFETY: per the function contract. + let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else { + return false; + }; + if action.is_null() { + return false; + } + // SAFETY: `action` points to `len` readable bytes. + let bytes = unsafe { std::slice::from_raw_parts(action.cast::(), len) }; + let action_str = String::from_utf8_lossy(bytes); + let action = match BindingAction::parse(&action_str) { + Ok(a) => a, + Err(err) => { + log::error!("error parsing binding action action={action_str} err={err:?}"); + return false; + } + }; + match core.perform_binding_action(&action) { + Ok(v) => v, + Err(err) => { + log::error!("error performing binding action action={action} err={err}"); + false + } + } +} + +/// Complete a clipboard read request started via the read callback. This can +/// only be called once for a given request. Once it is called with a request +/// the request pointer will be invalidated. +/// +/// # Safety +/// `ptr` a live surface handle; `text` NUL terminated; `state` a pointer +/// handed to the `read_clipboard` / `confirm_read_clipboard` callbacks. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_complete_clipboard_request( + ptr: ghostty_surface_t, + text: *const c_char, + state: *mut c_void, + confirmed: bool, +) { + // SAFETY: per the function contract. + let (Some(s), Some(str)) = (unsafe { surface(ptr) }, unsafe { cstr(text) }) else { + return; + }; + if state.is_null() { + return; + } + let Some(core) = s.core() else { + return; + }; + let str = str.to_string_lossy(); + // SAFETY: the pointer is a `Box` leaked by + // `clipboard_request`; it stays alive when confirmation is requested. + let req = unsafe { *state.cast::() }; + + // Attempt to complete the request, but we may request confirmation. + match core.complete_clipboard_request(req, &str, confirmed) { + Err( + ghostty_apprt::runtime_api::ClipboardError::UnsafePaste + | ghostty_apprt::runtime_api::ClipboardError::UnauthorizedPaste, + ) => { + if let Some(cb) = s.rt.app.opts.confirm_read_clipboard_cb { + let ty = match req.request_type() { + ghostty_apprt::structs::ClipboardRequestType::Paste => 0, + ghostty_apprt::structs::ClipboardRequestType::Osc52Read => 1, + ghostty_apprt::structs::ClipboardRequestType::Osc52Write => 2, + }; + let c = CString::new(str.replace('\0', "")).unwrap_or_default(); + // SAFETY: embedder callback; `state` is preserved for the + // confirmation route. + unsafe { cb(s.rt.userdata, c.as_ptr(), state, ty) }; + } + return; + } + Err(err) => log::error!("error completing clipboard request err={err}"), + Ok(()) => {} + } + + // We don't defer this because the clipboard confirmation route + // preserves the clipboard request. + // SAFETY: allocated by `clipboard_request`, consumed exactly once here. + drop(unsafe { Box::from_raw(state.cast::()) }); +} + +/// Sets the window background blur on macOS to the desired value. I do this +/// as an extern function because I don't know how to call these functions +/// in Swift. +/// +/// This uses an undocumented, non-public API because this is what every +/// terminal appears to use, including Terminal.app. +/// +/// # Safety +/// `app` a live app handle; `window` an `NSWindow` (macOS). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_set_window_background_blur( + app_ptr: ghostty_app_t, + window: *mut c_void, +) { + // This is only supported on macOS + #[cfg(target_os = "macos")] + { + use objc2::{msg_send, runtime::AnyObject}; + + #[link(name = "CoreGraphics", kind = "framework")] + unsafe extern "C" { + fn CGSSetWindowBackgroundBlurRadius( + connection: *mut c_void, + window: usize, + radius: c_int, + ) -> i32; + fn CGSDefaultConnectionForThread() -> *mut c_void; + } + + // SAFETY: per the function contract. + let Some(a) = (unsafe { app(app_ptr) }) else { + return; + }; + if window.is_null() { + return; + } + let config = a.rt.config.borrow(); + + // Do nothing if we don't have background transparency enabled + if config.background_opacity >= 1.0 { + return; + } + let radius = match config.c_value("background-blur") { + Some(ghostty_config::c_value::CValue::Short(v)) => c_int::from(v), + _ => 0, + }; + + // SAFETY: `window` is an NSWindow per the contract; the CGS + // functions are the private window server API Terminal.app uses. + unsafe { + let number: isize = msg_send![window.cast::(), windowNumber]; + CGSSetWindowBackgroundBlurRadius( + CGSDefaultConnectionForThread(), + number as usize, + radius, + ); + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = (app_ptr, window); + } +} + +// --------------------------------------------------------------------- +// Darwin only exports + +#[cfg(target_vendor = "apple")] +#[allow(clippy::wildcard_imports)] +mod darwin { + use super::*; + use crate::types::ghostty_text_s; + + /// Set the display the surface is on (for the display link). + /// + /// # Safety + /// `ptr` must be a live surface handle. + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ghostty_surface_set_display_id( + ptr: ghostty_surface_t, + display_id: u32, + ) { + // SAFETY: per the function contract. + if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) { + core.set_macos_display_id(display_id); + } + } + + /// This returns a CTFontRef that should be used for quicklook + /// highlighted text. This is always the primary font in use regardless + /// of the selected text. + /// + /// # Safety + /// `ptr` must be a live surface handle. + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ghostty_surface_quicklook_font(ptr: ghostty_surface_t) -> *mut c_void { + // SAFETY: per the function contract. + let Some(s) = (unsafe { surface(ptr) }) else { + return std::ptr::null_mut(); + }; + let Some(core) = s.core() else { + return std::ptr::null_mut(); + }; + // We need to unscale the content scale. We apply the content scale + // to our font stack because we are rendering at 1x but callers of + // this should be using scaled or apply scale themselves. + let scale = s.rt.get_content_scale(); + core.quicklook_font(scale.y).unwrap_or(std::ptr::null_mut()) + } + + /// This returns the selected word for quicklook. This will populate the + /// buffer with the word under the cursor and the selection info so that + /// quicklook can be rendered. + /// + /// This does not modify the selection active on the surface (if any). + /// + /// # Safety + /// `ptr` a live surface handle, `result` a valid out pointer. + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ghostty_surface_quicklook_word( + ptr: ghostty_surface_t, + result: *mut ghostty_text_s, + ) -> bool { + // SAFETY: per the function contract. + let (Some(s), Some(result)) = (unsafe { surface(ptr) }, unsafe { result.as_mut() }) else { + return false; + }; + let Some(core) = s.core() else { + return false; + }; + let guard = core.lock_terminal(); + let t: &Terminal = &guard; + + // Get our word selection + let pos = s.rt.get_cursor_pos(); + let vp = core.pos_to_viewport(f64::from(pos.x), f64::from(pos.y)); + let Some(pin) = t + .screen() + .pages + .pin(Point::Viewport(Coordinate { x: vp.x, y: vp.y })) + else { + return false; + }; + let Some(sel) = t + .screen() + .select_word(pin, &core.cfg().selection_word_chars) + else { + return false; + }; + + // Read the selection + fill_text(core, t, sel, result) + } +} diff --git a/crates/ghostty-lib/src/inspector.rs b/crates/ghostty-lib/src/inspector.rs new file mode 100644 index 0000000..cbafa16 --- /dev/null +++ b/crates/ghostty-lib/src/inspector.rs @@ -0,0 +1,477 @@ +//! The inspector C API (`ghostty_surface_inspector`, `ghostty_inspector_*`; +//! the `Inspector` struct of ZIG/src/apprt/embedded.zig). +//! +//! The embedded inspector owns a Dear ImGui context, feeds it the host's +//! input events and, on Apple platforms, draws it with Metal into the +//! command buffer the host provides (`ghostty_inspector_metal_*`). +#![allow(unsafe_code)] + +#[cfg(target_vendor = "apple")] +use std::time::Instant; +use std::{ + ffi::{CStr, c_char, c_int, c_void}, + rc::Rc, +}; + +use ghostty_apprt::runtime_api::RtSurface; +use ghostty_core::Surface as CoreSurfaceImpl; +use ghostty_input::{Action as KeyAction, Key, MouseButtonState, ScrollMods}; +use ghostty_inspector::imgui::{Context, Key as ImguiKey, MouseButton as ImguiMouseButton}; + +use crate::{ + embedded::{SurfaceRt, mouse_button_from_c, surface}, + types::{ghostty_inspector_t, ghostty_surface_t}, +}; + +/// The inspector state (Zig: `embedded.Inspector`). 1:1 with a surface. +pub struct Inspector { + surface: Rc, + core: Rc, + ctx: Context, + #[cfg(target_vendor = "apple")] + backend: Option, + content_scale: f64, + /// The scale the ImGui style currently has (a `Style` cannot be rebuilt + /// from Rust like Zig's `ImGuiStyle_ImGuiStyle`, so it is scaled by the + /// change of the factor). + style_scale: f32, + /// Our previous instant used to calculate delta time for animations. + #[cfg(target_vendor = "apple")] + instant: Option, +} + +impl Inspector { + fn init(surface: Rc, core: Rc) -> Option { + let mut ctx = match Context::try_create() { + Ok(c) => c, + Err(err) => { + log::error!("error initializing inspector err={err}"); + return None; + } + }; + if let Err(err) = ctx.set_platform_name(Some("ghostty_embedded")) { + log::warn!("unable to set the imgui platform name err={err}"); + } + + // Setup our core inspector + let scale = f64::from(RtSurface::get_content_scale(surface.as_ref()).x); + ghostty_inspector::setup(&mut ctx, scale); + if let Err(err) = core.activate_inspector() { + log::error!("failed to activate inspector err={err}"); + } + + Some(Inspector { + surface, + core, + ctx, + #[cfg(target_vendor = "apple")] + backend: None, + content_scale: 1.0, + style_scale: 1.0, + #[cfg(target_vendor = "apple")] + instant: None, + }) + } + + /// Stop collecting data and release the backend (Zig: `deinit`). + pub(crate) fn deinit(&mut self, _core: Option<&CoreSurfaceImpl>) { + self.core.deactivate_inspector(); + #[cfg(target_vendor = "apple")] + if let Some(b) = self.backend.take() { + b.shutdown(); + } + } + + /// Queue a render for the next frame. + fn queue_render(&self) { + RtSurface::redraw_inspector(self.surface.as_ref()); + } + + fn update_content_scale(&mut self, x: f64) { + // Cache our scale because we use it for cursor position + // calculations. + self.content_scale = x; + + // Scale the style appropriately. + let scale = x as f32; + let last = std::mem::replace(&mut self.style_scale, scale); + if (scale - last).abs() > f32::EPSILON && last > 0.0 { + self.ctx.style_mut().scale_all_sizes(scale / last); + } + } + + fn update_size(&mut self, width: u32, height: u32) { + self.ctx + .io_mut() + .set_display_size([width as f32, height as f32]); + } + + fn mouse_button(&mut self, action: MouseButtonState, button: ghostty_input::MouseButton) { + self.queue_render(); + let imgui_button = match button { + ghostty_input::MouseButton::Left => ImguiMouseButton::Left, + ghostty_input::MouseButton::Middle => ImguiMouseButton::Middle, + ghostty_input::MouseButton::Right => ImguiMouseButton::Right, + _ => return, // unsupported + }; + self.ctx + .io_mut() + .add_mouse_button_event(imgui_button, action == MouseButtonState::Press); + } + + fn scroll(&mut self, xoff: f64, yoff: f64, mods: ScrollMods) { + self.queue_render(); + + // For precision scrolling (trackpads), the values are in pixels + // which scroll way too fast. Scale them down to approximate + // discrete wheel notches. imgui expects 1.0 to scroll ~5 lines of + // text. + let scale = if mods.precision { 0.1 } else { 1.0 }; + self.ctx + .io_mut() + .add_mouse_wheel_event([(xoff * scale) as f32, (yoff * scale) as f32]); + } + + fn cursor_pos(&mut self, x: f64, y: f64) { + self.queue_render(); + self.ctx.io_mut().add_mouse_pos_event([ + (x * self.content_scale) as f32, + (y * self.content_scale) as f32, + ]); + } + + fn focus(&mut self, focused: bool) { + self.queue_render(); + self.ctx.io_mut().add_focus_event(focused); + } + + fn text(&mut self, text: &str) { + self.queue_render(); + self.ctx.io_mut().add_input_characters_utf8(text); + } + + fn key(&mut self, action: KeyAction, key: Key, mods: ghostty_input::Mods) { + self.queue_render(); + let io = self.ctx.io_mut(); + + // Update all our modifiers + io.add_key_event(ImguiKey::LeftShift, mods.shift); + io.add_key_event(ImguiKey::LeftCtrl, mods.ctrl); + io.add_key_event(ImguiKey::LeftAlt, mods.alt); + io.add_key_event(ImguiKey::LeftSuper, mods.super_); + + // Send our keypress + if let Some(imgui_key) = ghostty_inspector::keys::imgui_key(key) { + io.add_key_event( + imgui_key, + matches!(action, KeyAction::Press | KeyAction::Repeat), + ); + } + } + + /// Update the delta time (Zig: `newFrame`). + #[cfg(target_vendor = "apple")] + pub(crate) fn new_frame(&mut self) { + let now = Instant::now(); + let delta = self.instant.map_or(1.0 / 60.0, |prev| { + now.duration_since(prev).as_secs_f32().max(0.00001) + }); + self.instant = Some(now); + self.ctx.io_mut().set_delta_time(delta); + } + + /// Build one UI frame, calling the core inspector (`inspector.render`). + /// `end` finishes the frame without producing draw data. + #[cfg(target_vendor = "apple")] + pub(crate) fn build_frame(&mut self) -> &mut ghostty_inspector::imgui::Ui { + self.new_frame(); + let core = Rc::clone(&self.core); + let ui = self.ctx.frame(); + core.render_inspector(ui); + ui + } + + #[cfg(target_vendor = "apple")] + pub(crate) fn context(&mut self) -> &mut Context { + &mut self.ctx + } + + #[cfg(target_vendor = "apple")] + pub(crate) fn take_backend(&mut self) -> Option { + self.backend.take() + } + + #[cfg(target_vendor = "apple")] + pub(crate) fn set_backend(&mut self, backend: crate::inspector_metal::MetalBackend) { + self.backend = Some(backend); + } +} + +/// Borrow the inspector behind a handle. +/// +/// # Safety +/// `p` must be null or a handle from `ghostty_surface_inspector` whose +/// surface is still alive and whose inspector wasn't freed. +unsafe fn inspector<'a>(p: ghostty_inspector_t) -> Option<&'a mut Inspector> { + // SAFETY: per the function contract. + unsafe { p.cast::().as_mut() } +} + +/// Initialize the inspector instance. A surface can only have one inspector +/// at any given time, so this will return the previous inspector if it was +/// already initialized. +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_surface_inspector(ptr: ghostty_surface_t) -> ghostty_inspector_t { + // SAFETY: per the function contract. + let Some(s) = (unsafe { surface(ptr) }) else { + return std::ptr::null_mut(); + }; + let slot = s.inspector_slot(); + if let Some(i) = slot.borrow_mut().as_mut() { + return std::ptr::from_mut::(&mut **i).cast::(); + } + let Some(core) = s.core_surface() else { + return std::ptr::null_mut(); + }; + let Some(i) = Inspector::init(Rc::clone(s.rt()), Rc::clone(core)) else { + return std::ptr::null_mut(); + }; + let mut boxed = Box::new(i); + let handle = std::ptr::from_mut::(&mut *boxed).cast::(); + *slot.borrow_mut() = Some(boxed); + handle +} + +/// Free the inspector of a surface (note: takes the surface). +/// +/// # Safety +/// `ptr` must be a live surface handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_inspector_free(ptr: ghostty_surface_t) { + // SAFETY: per the function contract. + let Some(s) = (unsafe { surface(ptr) }) else { + return; + }; + let taken = s.inspector_slot().borrow_mut().take(); + if let Some(mut i) = taken { + i.deinit(None); + } +} + +/// Set the size of the inspector view in pixels. +/// +/// # Safety +/// `ptr` must be a live inspector handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_inspector_set_size(ptr: ghostty_inspector_t, w: u32, h: u32) { + // SAFETY: per the function contract. + if let Some(i) = unsafe { inspector(ptr) } { + i.update_size(w, h); + } +} + +/// Set the content scale of the inspector view. +/// +/// # Safety +/// `ptr` must be a live inspector handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_inspector_set_content_scale( + ptr: ghostty_inspector_t, + x: f64, + _y: f64, +) { + // SAFETY: per the function contract. + if let Some(i) = unsafe { inspector(ptr) } { + i.update_content_scale(x); + } +} + +/// Send a mouse button event to the inspector. +/// +/// # Safety +/// `ptr` must be a live inspector handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_inspector_mouse_button( + ptr: ghostty_inspector_t, + action: c_int, + button: c_int, + _mods: c_int, +) { + let state = match action { + 0 => MouseButtonState::Release, + 1 => MouseButtonState::Press, + _ => return, + }; + let Some(button) = mouse_button_from_c(button) else { + return; + }; + // SAFETY: per the function contract. + if let Some(i) = unsafe { inspector(ptr) } { + i.mouse_button(state, button); + } +} + +/// Send a mouse position to the inspector. +/// +/// # Safety +/// `ptr` must be a live inspector handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_inspector_mouse_pos(ptr: ghostty_inspector_t, x: f64, y: f64) { + // SAFETY: per the function contract. + if let Some(i) = unsafe { inspector(ptr) } { + i.cursor_pos(x, y); + } +} + +/// Send a scroll event to the inspector. +/// +/// # Safety +/// `ptr` must be a live inspector handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_inspector_mouse_scroll( + ptr: ghostty_inspector_t, + x: f64, + y: f64, + scroll_mods: c_int, +) { + // SAFETY: per the function contract. + if let Some(i) = unsafe { inspector(ptr) } { + i.scroll(x, y, ScrollMods::from_int(scroll_mods as u32 as u8)); + } +} + +/// Send a key event to the inspector. +/// +/// # Safety +/// `ptr` must be a live inspector handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_inspector_key( + ptr: ghostty_inspector_t, + action: c_int, + key: c_int, + c_mods: c_int, +) { + let action = match action { + 0 => KeyAction::Release, + 1 => KeyAction::Press, + 2 => KeyAction::Repeat, + _ => return, + }; + let Some(key) = usize::try_from(key) + .ok() + .and_then(|k| Key::ALL.get(k)) + .copied() + else { + return; + }; + // SAFETY: per the function contract. + if let Some(i) = unsafe { inspector(ptr) } { + i.key( + action, + key, + ghostty_input::Mods::from_int(c_mods as u32 as u16), + ); + } +} + +/// Send text input to the inspector. +/// +/// # Safety +/// `ptr` a live inspector handle; `text` NUL terminated. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_inspector_text(ptr: ghostty_inspector_t, text: *const c_char) { + if text.is_null() { + return; + } + // SAFETY: per the function contract. + let text = unsafe { CStr::from_ptr(text) }.to_string_lossy(); + // SAFETY: per the function contract. + if let Some(i) = unsafe { inspector(ptr) } { + i.text(&text); + } +} + +/// Set the focus state of the inspector. +/// +/// # Safety +/// `ptr` must be a live inspector handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_inspector_set_focus(ptr: ghostty_inspector_t, focused: bool) { + // SAFETY: per the function contract. + if let Some(i) = unsafe { inspector(ptr) } { + i.focus(focused); + } +} + +#[cfg(target_vendor = "apple")] +#[allow(clippy::wildcard_imports)] +mod metal_exports { + use super::*; + + /// Initialize the inspector for a Metal backend. Takes ownership of one + /// reference to `device`. + /// + /// # Safety + /// `ptr` a live inspector handle; `device` a retained `MTLDevice`. + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ghostty_inspector_metal_init( + ptr: ghostty_inspector_t, + device: *mut c_void, + ) -> bool { + // SAFETY: per the function contract. + let Some(i) = (unsafe { inspector(ptr) }) else { + return false; + }; + if let Some(b) = i.backend.take() { + b.shutdown(); + } + // SAFETY: `device` is a retained MTLDevice whose reference we own. + match unsafe { crate::inspector_metal::MetalBackend::init(device, i.context()) } { + Ok(b) => { + i.backend = Some(b); + log::debug!("initialized metal backend"); + true + } + Err(err) => { + log::warn!("failed to initialize metal backend err={err}"); + false + } + } + } + + /// Render the inspector into the host's command buffer. Takes ownership + /// of one reference to each of `command_buffer` and `descriptor`. + /// + /// # Safety + /// `ptr` a live inspector handle; the others retained Metal objects. + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ghostty_inspector_metal_render( + ptr: ghostty_inspector_t, + command_buffer: *mut c_void, + descriptor: *mut c_void, + ) { + // SAFETY: per the function contract. + let Some(i) = (unsafe { inspector(ptr) }) else { + return; + }; + // SAFETY: retained objects whose reference we own. + unsafe { crate::inspector_metal::render(i, command_buffer, descriptor) }; + } + + /// Shut the Metal backend down. + /// + /// # Safety + /// `ptr` must be a live inspector handle. + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ghostty_inspector_metal_shutdown(ptr: ghostty_inspector_t) { + // SAFETY: per the function contract. + if let Some(i) = unsafe { inspector(ptr) } + && let Some(b) = i.backend.take() + { + b.shutdown(); + } + } +} diff --git a/crates/ghostty-lib/src/inspector_metal.rs b/crates/ghostty-lib/src/inspector_metal.rs new file mode 100644 index 0000000..db0a689 --- /dev/null +++ b/crates/ghostty-lib/src/inspector_metal.rs @@ -0,0 +1,633 @@ +//! The Metal backend of the embedded inspector +//! (`ghostty_inspector_metal_{init,render,shutdown}`). +//! +//! Upstream links Dear ImGui's `imgui_impl_metal.mm`; this is the same +//! renderer written against `objc2-metal` and `dear-imgui-rs`'s managed +//! texture protocol: the draw lists are uploaded to Metal buffers and drawn +//! with the `imgui_impl_metal` shaders into the render pass the host +//! provides. +#![allow(unsafe_code)] + +use std::{collections::HashMap, ffi::c_void, ptr::NonNull}; + +use ghostty_inspector::imgui::{ + BackendFlags, + TextureFormat, + TextureId, + render::{ + DrawCmd, + DrawIdx, + DrawVert, + PendingFrame, + SnapshotTextureId, + SynchronousRendererConsumer, + TextureFeedback, + TextureOp, + TextureRequest, + }, +}; +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_foundation::NSString; +use objc2_metal::{ + MTLBlendFactor, + MTLBlendOperation, + MTLCommandBuffer, + MTLCommandEncoder as _, + MTLCompareFunction, + MTLCullMode, + MTLDepthStencilDescriptor, + MTLDepthStencilState, + MTLDevice, + MTLIndexType, + MTLLibrary, + MTLOrigin, + MTLPixelFormat, + MTLPrimitiveType, + MTLRegion, + MTLRenderCommandEncoder, + MTLRenderPassDescriptor, + MTLRenderPipelineDescriptor, + MTLRenderPipelineState, + MTLResourceOptions, + MTLScissorRect, + MTLSize, + MTLTexture, + MTLTextureDescriptor, + MTLVertexDescriptor, + MTLVertexFormat, + MTLVertexStepFunction, + MTLViewport, +}; + +use crate::inspector::Inspector; + +/// The shaders of `imgui_impl_metal.mm`. +const SHADERS: &str = r" +#include +using namespace metal; + +struct Uniforms { + float4x4 projectionMatrix; +}; + +struct VertexIn { + float2 position [[attribute(0)]]; + float2 texCoords [[attribute(1)]]; + uchar4 color [[attribute(2)]]; +}; + +struct VertexOut { + float4 position [[position]]; + float2 texCoords; + float4 color; +}; + +vertex VertexOut vertex_main(VertexIn in [[stage_in]], + constant Uniforms &uniforms [[buffer(1)]]) { + VertexOut out; + out.position = uniforms.projectionMatrix * float4(in.position, 0, 1); + out.texCoords = in.texCoords; + out.color = float4(in.color) / float4(255.0); + return out; +} + +fragment half4 fragment_main(VertexOut in [[stage_in]], + texture2d texture [[texture(0)]]) { + constexpr sampler linearSampler(coord::normalized, + min_filter::linear, + mag_filter::linear, + mip_filter::linear); + half4 texColor = texture.sample(linearSampler, in.texCoords); + return half4(in.color) * texColor; +} +"; + +type Device = Retained>; + +/// A pipeline state for one render target configuration. +#[derive(PartialEq, Eq, Hash, Clone, Copy)] +struct PipelineKey { + color_format: usize, + sample_count: usize, + depth_format: usize, + stencil_format: usize, +} + +/// The Metal draw backend of one inspector. +pub struct MetalBackend { + device: Device, + library: Retained>, + depth_state: Retained>, + pipelines: HashMap>>, + consumer: SynchronousRendererConsumer, + /// Managed textures by Dear ImGui's identity. + textures: HashMap>)>, + next_texture_id: u64, +} + +impl MetalBackend { + /// Takes ownership of one reference to `device` (Zig: `defer + /// device.release()`). + /// + /// # Safety + /// `device` must be a retained `MTLDevice`. + pub unsafe fn init( + device: *mut c_void, + ctx: &mut ghostty_inspector::imgui::Context, + ) -> Result { + // SAFETY: per the function contract we own one reference. + let device: Device = + unsafe { Retained::from_raw(device.cast::>()) } + .ok_or_else(|| "null Metal device".to_owned())?; + + let library = device + .newLibraryWithSource_options_error(&NSString::from_str(SHADERS), None) + .map_err(|e| e.localizedDescription().to_string())?; + + let depth_desc = MTLDepthStencilDescriptor::new(); + depth_desc.setDepthCompareFunction(MTLCompareFunction::Always); + depth_desc.setDepthWriteEnabled(false); + let depth_state = device + .newDepthStencilStateWithDescriptor(&depth_desc) + .ok_or_else(|| "failed to create the depth stencil state".to_owned())?; + + // Publish the renderer capabilities and the managed texture + // consumer. + let consumer = ctx + .create_synchronous_renderer_consumer() + .map_err(|e| e.to_string())?; + let reset = ctx + .prepare_renderer_texture_reset(&consumer) + .map_err(|e| e.to_string())?; + reset.commit(); + if let Err(err) = ctx.set_renderer_name(Some("ghostty_embedded_metal")) { + log::warn!("unable to set the imgui renderer name err={err}"); + } + let io = ctx.io_mut(); + io.set_backend_flags( + io.backend_flags() + | BackendFlags::RENDERER_HAS_VTX_OFFSET + | BackendFlags::RENDERER_HAS_TEXTURES, + ); + + Ok(MetalBackend { + device, + library, + depth_state, + pipelines: HashMap::new(), + consumer, + textures: HashMap::new(), + next_texture_id: 1, + }) + } + + /// Release the GPU objects. + pub fn shutdown(self) { + drop(self); + } + + fn pipeline( + &mut self, + key: PipelineKey, + ) -> Result>, String> { + if let Some(p) = self.pipelines.get(&key) { + return Ok(p.clone()); + } + + let desc = MTLRenderPipelineDescriptor::new(); + let vertex = self + .library + .newFunctionWithName(&NSString::from_str("vertex_main")) + .ok_or("missing vertex_main")?; + let fragment = self + .library + .newFunctionWithName(&NSString::from_str("fragment_main")) + .ok_or("missing fragment_main")?; + desc.setVertexFunction(Some(&vertex)); + desc.setFragmentFunction(Some(&fragment)); + + // ImDrawVert: float2 pos, float2 uv, uchar4 col. + let vd = MTLVertexDescriptor::new(); + let stride = size_of::(); + // SAFETY: attribute/layout indexes are valid; plain numbers. + unsafe { + let attrs = vd.attributes(); + let a0 = attrs.objectAtIndexedSubscript(0); + a0.setFormat(MTLVertexFormat::Float2); + a0.setOffset(std::mem::offset_of!(DrawVert, pos)); + a0.setBufferIndex(0); + let a1 = attrs.objectAtIndexedSubscript(1); + a1.setFormat(MTLVertexFormat::Float2); + a1.setOffset(std::mem::offset_of!(DrawVert, uv)); + a1.setBufferIndex(0); + let a2 = attrs.objectAtIndexedSubscript(2); + a2.setFormat(MTLVertexFormat::UChar4); + a2.setOffset(std::mem::offset_of!(DrawVert, col)); + a2.setBufferIndex(0); + let layout = vd.layouts().objectAtIndexedSubscript(0); + layout.setStepRate(1); + layout.setStepFunction(MTLVertexStepFunction::PerVertex); + layout.setStride(stride); + } + desc.setVertexDescriptor(Some(&vd)); + desc.setRasterSampleCount(key.sample_count); + + // SAFETY: color attachment 0 exists. + let color = unsafe { desc.colorAttachments().objectAtIndexedSubscript(0) }; + color.setPixelFormat(MTLPixelFormat(key.color_format)); + color.setBlendingEnabled(true); + color.setRgbBlendOperation(MTLBlendOperation::Add); + color.setAlphaBlendOperation(MTLBlendOperation::Add); + color.setSourceRGBBlendFactor(MTLBlendFactor::SourceAlpha); + color.setDestinationRGBBlendFactor(MTLBlendFactor::OneMinusSourceAlpha); + color.setSourceAlphaBlendFactor(MTLBlendFactor::One); + color.setDestinationAlphaBlendFactor(MTLBlendFactor::OneMinusSourceAlpha); + desc.setDepthAttachmentPixelFormat(MTLPixelFormat(key.depth_format)); + desc.setStencilAttachmentPixelFormat(MTLPixelFormat(key.stencil_format)); + + let state = self + .device + .newRenderPipelineStateWithDescriptor_error(&desc) + .map_err(|e| e.localizedDescription().to_string())?; + self.pipelines.insert(key, state.clone()); + Ok(state) + } + + /// Create (or recreate) the texture of `key` from a full upload. + fn create_texture( + &mut self, + key: SnapshotTextureId, + format: TextureFormat, + width: u32, + height: u32, + row_pitch: usize, + pixels: &[u8], + ) -> Result { + let rgba = to_rgba(format, width as usize, height as usize, row_pitch, pixels)?; + + let desc = MTLTextureDescriptor::new(); + desc.setPixelFormat(MTLPixelFormat::RGBA8Unorm); + // SAFETY: plain numbers validated by texture creation. + unsafe { + desc.setWidth(width as usize); + desc.setHeight(height as usize); + } + let texture = self + .device + .newTextureWithDescriptor(&desc) + .ok_or("failed to create the texture")?; + replace_region(&texture, 0, 0, width as usize, height as usize, &rgba)?; + + let id = if let Some((id, _)) = self.textures.get(&key) { + *id + } else { + let id = self.next_texture_id; + self.next_texture_id += 1; + id + }; + self.textures.insert(key, (id, texture)); + Ok(TextureId::new(id)) + } + + fn process_request(&mut self, request: &TextureRequest) -> Result { + let key = request.texture(); + match request.operation() { + TextureOp::Create { + format, + width, + height, + row_pitch, + pixels, + } => { + let id = self.create_texture(key, *format, *width, *height, *row_pitch, pixels)?; + request.uploaded(id).map_err(|e| e.to_string()) + } + TextureOp::Update { + format, + width: _, + height: _, + rects, + } => { + let Some((id, texture)) = self.textures.get(&key) else { + // The texture was never created or was destroyed. + return Ok(request.superseded()); + }; + for r in rects { + let (w, h) = (usize::from(r.rect.w), usize::from(r.rect.h)); + let rgba = to_rgba(*format, w, h, r.row_pitch, &r.data)?; + replace_region( + texture, + usize::from(r.rect.x), + usize::from(r.rect.y), + w, + h, + &rgba, + )?; + } + request + .uploaded(TextureId::new(*id)) + .map_err(|e| e.to_string()) + } + TextureOp::Destroy => { + self.textures.remove(&key); + request.destroyed().map_err(|e| e.to_string()) + } + } + } + + /// Reconcile the managed textures of the frame and draw it into + /// `encoder` (`ImGui_ImplMetal_RenderDrawData`). + fn render( + &mut self, + frame: PendingFrame<'_>, + command_buffer: &ProtocolObject, + desc: &MTLRenderPassDescriptor, + ) -> Result<(), String> { + let mut feedback = Vec::with_capacity(frame.texture_requests().len()); + for request in frame.texture_requests() { + feedback.push(self.process_request(request)?); + } + let frame = frame + .reconcile_texture_feedback(feedback) + .map_err(|e| e.to_string())?; + let draw_data = frame.draw_data(); + + let [fb_w, fb_h] = { + let size = draw_data.display_size(); + let scale = draw_data.framebuffer_scale(); + [size[0] * scale[0], size[1] * scale[1]] + }; + // Avoid rendering when minimized. + if fb_w <= 0.0 || fb_h <= 0.0 || draw_data.draw_lists_count() == 0 { + return Ok(()); + } + + // The pipeline matches the render target the host gave us. + // SAFETY: attachment 0 exists. + let color_tex = unsafe { desc.colorAttachments().objectAtIndexedSubscript(0) }.texture(); + let depth_tex = desc.depthAttachment().texture(); + let stencil_tex = desc.stencilAttachment().texture(); + let key = PipelineKey { + color_format: color_tex + .as_ref() + .map_or(MTLPixelFormat::BGRA8Unorm.0, |t| t.pixelFormat().0), + sample_count: color_tex.as_ref().map_or(1, |t| t.sampleCount()), + depth_format: depth_tex + .as_ref() + .map_or(MTLPixelFormat::Invalid.0, |t| t.pixelFormat().0), + stencil_format: stencil_tex + .as_ref() + .map_or(MTLPixelFormat::Invalid.0, |t| t.pixelFormat().0), + }; + let pipeline = self.pipeline(key)?; + + let encoder = command_buffer + .renderCommandEncoderWithDescriptor(desc) + .ok_or("failed to create the render command encoder")?; + + // Render state. + encoder.setCullMode(MTLCullMode::None); + encoder.setDepthStencilState(Some(&self.depth_state)); + encoder.setViewport(MTLViewport { + originX: 0.0, + originY: 0.0, + width: f64::from(fb_w), + height: f64::from(fb_h), + znear: 0.0, + zfar: 1.0, + }); + encoder.setRenderPipelineState(&pipeline); + + // The orthographic projection of the display. + let [dx, dy] = draw_data.display_pos(); + let [dw, dh] = draw_data.display_size(); + let (l, r, t, b) = (dx, dx + dw, dy, dy + dh); + #[rustfmt::skip] + let projection: [[f32; 4]; 4] = [ + [2.0 / (r - l), 0.0, 0.0, 0.0], + [0.0, 2.0 / (t - b), 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [(r + l) / (l - r), (t + b) / (b - t), 0.0, 1.0], + ]; + // SAFETY: the pointer/length describe `projection`, which outlives + // the call (Metal copies the bytes). + unsafe { + encoder.setVertexBytes_length_atIndex( + NonNull::from(&projection).cast::(), + size_of::<[[f32; 4]; 4]>(), + 1, + ); + } + + let clip_off = draw_data.display_pos(); + let clip_scale = draw_data.framebuffer_scale(); + for list in draw_data.draw_lists() { + let vertices = list.vtx_buffer(); + let indices = list.idx_buffer(); + if vertices.is_empty() || indices.is_empty() { + continue; + } + let vbuf = + self.new_buffer(vertices.as_ptr().cast(), std::mem::size_of_val(vertices))?; + let ibuf = self.new_buffer(indices.as_ptr().cast(), std::mem::size_of_val(indices))?; + // SAFETY: slot 0 is the vertex stream. + unsafe { encoder.setVertexBuffer_offset_atIndex(Some(&vbuf), 0, 0) }; + + for cmd in list.commands() { + match cmd { + DrawCmd::Elements { count, cmd_params } => { + // Project the scissor rectangle into framebuffer + // space and clamp it to the framebuffer. + let cr = cmd_params.clip_rect; + let min_x = ((cr[0] - clip_off[0]) * clip_scale[0]).max(0.0); + let min_y = ((cr[1] - clip_off[1]) * clip_scale[1]).max(0.0); + let max_x = ((cr[2] - clip_off[0]) * clip_scale[0]).min(fb_w); + let max_y = ((cr[3] - clip_off[1]) * clip_scale[1]).min(fb_h); + if max_x <= min_x || max_y <= min_y { + continue; + } + encoder.setScissorRect(MTLScissorRect { + x: min_x as usize, + y: min_y as usize, + width: (max_x - min_x) as usize, + height: (max_y - min_y) as usize, + }); + + // Bind the texture of the command. + let id = cmd_params.texture_id.id(); + let Some((_, tex)) = self.textures.values().find(|(tid, _)| *tid == id) + else { + continue; + }; + // SAFETY: slot 0 of the fragment stage. + unsafe { encoder.setFragmentTexture_atIndex(Some(tex), 0) }; + + // The vertex offset applies through the buffer + // offset (`RENDERER_HAS_VTX_OFFSET`). + // SAFETY: the offset stays inside the buffer. + unsafe { + encoder.setVertexBufferOffset_atIndex( + cmd_params.vtx_offset * size_of::(), + 0, + ); + encoder.drawIndexedPrimitives_indexCount_indexType_indexBuffer_indexBufferOffset( + MTLPrimitiveType::Triangle, + count, + if size_of::() == 2 { + MTLIndexType::UInt16 + } else { + MTLIndexType::UInt32 + }, + &ibuf, + cmd_params.idx_offset * size_of::(), + ); + } + } + // Nothing to reset: state is set per command. + DrawCmd::ResetRenderState + | DrawCmd::SetSamplerLinear + | DrawCmd::SetSamplerNearest => {} + DrawCmd::RawCallback(_) => { + log::warn!("ignoring a raw imgui draw callback"); + } + } + } + } + + encoder.endEncoding(); + Ok(()) + } + + fn new_buffer( + &self, + ptr: *const c_void, + len: usize, + ) -> Result>, String> { + let ptr = NonNull::new(ptr.cast_mut()).ok_or("null buffer")?; + // SAFETY: `ptr` points to `len` readable bytes (a slice of the draw + // list); Metal copies them. + unsafe { + self.device.newBufferWithBytes_length_options( + ptr, + len, + MTLResourceOptions::StorageModeShared, + ) + } + .ok_or_else(|| "failed to create a buffer".to_owned()) + } +} + +/// The pixels as tightly packed RGBA8 (alpha-only textures become white with +/// that alpha). +fn to_rgba( + format: TextureFormat, + width: usize, + height: usize, + row_pitch: usize, + data: &[u8], +) -> Result, String> { + let bpp = match format { + TextureFormat::RGBA32 => 4, + TextureFormat::Alpha8 => 1, + }; + let pitch = if row_pitch == 0 { + width * bpp + } else { + row_pitch + }; + if height > 0 && data.len() < pitch * (height - 1) + width * bpp { + return Err("texture upload is too short".to_owned()); + } + let mut out = Vec::with_capacity(width * height * 4); + for y in 0..height { + let row = &data[y * pitch..y * pitch + width * bpp]; + match format { + TextureFormat::RGBA32 => out.extend_from_slice(row), + TextureFormat::Alpha8 => { + for a in row { + out.extend_from_slice(&[255, 255, 255, *a]); + } + } + } + } + Ok(out) +} + +fn replace_region( + texture: &ProtocolObject, + x: usize, + y: usize, + width: usize, + height: usize, + rgba: &[u8], +) -> Result<(), String> { + let ptr = NonNull::new(rgba.as_ptr().cast_mut().cast::()).ok_or("empty texture")?; + let region = MTLRegion { + origin: MTLOrigin { x, y, z: 0 }, + size: MTLSize { + width, + height, + depth: 1, + }, + }; + // SAFETY: `rgba` has `width * 4 * height` bytes (built by `to_rgba`). + unsafe { + texture.replaceRegion_mipmapLevel_withBytes_bytesPerRow(region, 0, ptr, width * 4); + } + Ok(()) +} + +/// Render the inspector (Zig: `Inspector.renderMetal`). Takes ownership of +/// one reference each to `command_buffer` and `descriptor`. +/// +/// # Safety +/// Both must be retained Metal objects. +pub unsafe fn render( + inspector: &mut Inspector, + command_buffer: *mut c_void, + descriptor: *mut c_void, +) { + // SAFETY: per the function contract we own one reference to each; they + // are released when these `Retained`s drop. + let command_buffer: Option>> = + unsafe { Retained::from_raw(command_buffer.cast()) }; + // SAFETY: as above. + let descriptor: Option> = + unsafe { Retained::from_raw(descriptor.cast()) }; + let (Some(command_buffer), Some(descriptor)) = (command_buffer, descriptor) else { + return; + }; + if let Err(err) = render_frames(inspector, &command_buffer, &descriptor) { + log::error!("error rendering inspector err={err}"); + } +} + +fn render_frames( + inspector: &mut Inspector, + command_buffer: &ProtocolObject, + descriptor: &MTLRenderPassDescriptor, +) -> Result<(), String> { + let mut backend = inspector + .take_backend() + .ok_or("the Metal backend isn't initialized")?; + let result = draw_frame(&mut backend, inspector, command_buffer, descriptor); + inspector.set_backend(backend); + result +} + +fn draw_frame( + backend: &mut MetalBackend, + inspector: &mut Inspector, + command_buffer: &ProtocolObject, + descriptor: &MTLRenderPassDescriptor, +) -> Result<(), String> { + // Setup our imgui frame. We need to render multiple frames to ensure + // ImGui completes all its state processing. I don't know how to fix + // this. + inspector.build_frame(); + inspector.context().end_frame(); + + inspector.build_frame(); + let pending = inspector.context().render(&backend.consumer); + backend.render(pending, command_buffer, descriptor) +} diff --git a/crates/ghostty-lib/src/lib.rs b/crates/ghostty-lib/src/lib.rs new file mode 100644 index 0000000..6ab245f --- /dev/null +++ b/crates/ghostty-lib/src/lib.rs @@ -0,0 +1,29 @@ +//! libghostty: the embedded C API of Ghostty (`include/ghostty.h`), ported +//! from `ZIG/src/main_c.zig`, `ZIG/src/apprt/embedded.zig`, +//! `ZIG/src/config/CApi.zig` + `c_get.zig`, the C parts of +//! `ZIG/src/apprt/action.zig` and `ZIG/src/benchmark/CApi.zig`. +//! +//! The header is shipped unchanged in `include/`. The exported symbols and +//! the struct / enum layouts of [`types`] match it exactly (verified by +//! `tests/abi.rs`). +//! +//! Platform gating follows Zig: on Apple targets surfaces render with the +//! Metal renderer into the host's `NSView` / `UIView`; elsewhere the +//! platform independent API (config, app, CLI, key translation, ...) works, +//! but `ghostty_surface_new` fails with `UnsupportedPlatform` (Zig's +//! `Platform.init`). +//! +//! `unsafe` is denied crate wide and allowed (with `SAFETY:` comments) only +//! in the FFI modules. + +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::undocumented_unsafe_blocks))] + +pub mod action_c; +pub mod benchmark; +pub mod config_capi; +pub mod embedded; +pub mod inspector; +#[cfg(target_vendor = "apple")] +pub mod inspector_metal; +pub mod main_c; +pub mod types; diff --git a/crates/ghostty-lib/src/main_c.rs b/crates/ghostty-lib/src/main_c.rs new file mode 100644 index 0000000..7b7c217 --- /dev/null +++ b/crates/ghostty-lib/src/main_c.rs @@ -0,0 +1,257 @@ +//! `ghostty_init`, `ghostty_cli_try_action`, `ghostty_info`, +//! `ghostty_translate` and `ghostty_string_free` (ZIG/src/main_c.zig). +#![allow(unsafe_code)] + +use std::{ + collections::HashMap, + ffi::{CStr, CString, c_char, c_int}, + sync::{Mutex, OnceLock}, +}; + +use ghostty_cli::build_info::{self, BuildInfo}; +use ghostty_core::global::{self, InitOptions}; + +use crate::types::{build_mode, ghostty_info_s, ghostty_string_s}; + +/// The CLI arguments of the process, as given to `ghostty_init` (Zig: +/// `std.os.argv`). +static ARGS: OnceLock> = OnceLock::new(); + +/// Build the `ghostty_string_s` for an owned string (Zig: +/// `String.fromSlice` of an allocated `[:0]const u8`). Free with +/// `ghostty_string_free`. +pub(crate) fn string_from_owned(s: &str) -> ghostty_string_s { + let mut bytes = Vec::with_capacity(s.len() + 1); + bytes.extend_from_slice(s.as_bytes()); + bytes.push(0); + let boxed = bytes.into_boxed_slice(); + let len = boxed.len() - 1; + let ptr = Box::into_raw(boxed).cast::(); + ghostty_string_s { + ptr, + len, + sentinel: true, + } +} + +/// The empty string (`String.empty`). +pub(crate) const EMPTY_STRING: ghostty_string_s = ghostty_string_s { + ptr: std::ptr::null(), + len: 0, + sentinel: false, +}; + +/// Free text allocated with [`alloc_c_text`]: `text_len` bytes plus the +/// terminating NUL. +/// +/// # Safety +/// `ptr` must come from [`alloc_c_text`] (or `string_from_owned`) with the +/// same `len` and not have been freed. +pub(crate) unsafe fn free_c_text(ptr: *const c_char, len: usize, sentinel: bool) { + if ptr.is_null() { + return; + } + let total = len + usize::from(sentinel); + // SAFETY: per the function contract the allocation is a boxed slice of + // exactly `total` bytes. + drop(unsafe { + Box::from_raw(std::ptr::slice_from_raw_parts_mut( + ptr.cast_mut().cast::(), + total, + )) + }); +} + +/// Allocate a NUL terminated copy of `bytes`; returns the pointer (free with +/// [`free_c_text`] with `sentinel = true`). +pub(crate) fn alloc_c_text(bytes: &[u8]) -> *const c_char { + let mut v = Vec::with_capacity(bytes.len() + 1); + v.extend_from_slice(bytes); + v.push(0); + Box::into_raw(v.into_boxed_slice()).cast::() +} + +/// Initialize ghostty global state. +/// +/// # Safety +/// `argv` must be null or point to `argc` valid NUL terminated strings. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_init(argc: usize, argv: *mut *mut c_char) -> c_int { + let mut args = Vec::with_capacity(argc); + if !argv.is_null() { + for i in 0..argc { + // SAFETY: the caller guarantees `argv[0..argc]` are valid. + let p = unsafe { *argv.add(i) }; + if p.is_null() { + break; + } + // SAFETY: each argv entry is a NUL terminated string. + let c = unsafe { CStr::from_ptr(p) }; + args.push(c.to_string_lossy().into_owned()); + } + } + + // We first try to parse any action that we may be executing. + let action = match ghostty_cli::ghostty::detect(&args) { + Ok(a) => a, + Err(err) => { + log::error!("failed to initialize ghostty error={err:?}"); + return 1; + } + }; + let _ = ARGS.set(args); + + global::init_lib(InitOptions { + action: action.is_some(), + resources_dir: None, + }); + + // `+version` and friends report the library build. + build_info::set(lib_build_info()); + 0 +} + +/// The build info of the library (Zig: `build_config` for the lib artifact: +/// no app runtime, CoreText + Metal on Apple targets). +fn lib_build_info() -> BuildInfo { + let mut info = BuildInfo { + app_runtime: ".none".to_owned(), + gtk_build: None, + gtk_runtime: None, + adw_build: None, + adw_runtime: None, + x11: false, + wayland: false, + ..BuildInfo::default() + }; + if cfg!(target_vendor = "apple") { + ".coretext".clone_into(&mut info.font_backend); + "renderer.generic.Renderer(renderer.Metal)".clone_into(&mut info.renderer); + } + info +} + +/// Runs an action if it is specified. If there is no action this returns. +/// If there is an action then this doesn't return. +#[unsafe(no_mangle)] +pub extern "C" fn ghostty_cli_try_action() { + let Some(args) = ARGS.get() else { + return; + }; + // No action: nothing to do. + if !matches!(ghostty_cli::ghostty::detect(args), Ok(Some(_))) { + return; + } + log::info!("executing CLI action"); + let code = ghostty_cli::run_from_args(args).unwrap_or(0); + std::process::exit(i32::from(code)); +} + +/// Return metadata about Ghostty, such as version, build mode, etc. +#[unsafe(no_mangle)] +pub extern "C" fn ghostty_info() -> ghostty_info_s { + static VERSION: OnceLock = OnceLock::new(); + let info = build_info::get(); + let version = VERSION.get_or_init(|| CString::new(info.version_string).unwrap_or_default()); + let mode = match info.build_mode.trim_start_matches('.') { + "Debug" => build_mode::DEBUG, + "ReleaseSafe" => build_mode::RELEASE_SAFE, + "ReleaseFast" => build_mode::RELEASE_FAST, + "ReleaseSmall" => build_mode::RELEASE_SMALL, + _ if cfg!(debug_assertions) => build_mode::DEBUG, + _ => build_mode::RELEASE_SAFE, + }; + ghostty_info_s { + build_mode: mode, + version: version.as_ptr(), + version_len: version.as_bytes().len(), + } +} + +/// Translate a string maintained by libghostty into the current application +/// language. This will return the same string (same pointer) if no +/// translation is found, so the pointer must be stable through the function +/// call. +/// +/// # Safety +/// `msgid` must be a valid NUL terminated string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ghostty_translate(msgid: *const c_char) -> *const c_char { + static CACHE: OnceLock>> = OnceLock::new(); + if msgid.is_null() { + return msgid; + } + + // SAFETY: the caller guarantees `msgid` is NUL terminated. + let id = unsafe { CStr::from_ptr(msgid) }; + let Ok(id_str) = id.to_str() else { + return msgid; + }; + let translated = ghostty_os::i18n::tr(id_str); + if translated == id_str { + return msgid; + } + + // Translations are interned so the returned pointer stays valid. + let Ok(mut cache) = CACHE.get_or_init(Mutex::default).lock() else { + return msgid; + }; + if let Some(c) = cache.get(&translated) { + return c.as_ptr(); + } + let Ok(c) = CString::new(translated.clone()) else { + return msgid; + }; + let ptr = c.as_ptr(); + cache.insert(translated, c); + ptr +} + +/// Free a string allocated by Ghostty. +#[unsafe(no_mangle)] +pub extern "C" fn ghostty_string_free(str: ghostty_string_s) { + // SAFETY: strings handed out by this library are allocated by + // `string_from_owned` / `alloc_c_text` with `len` (+1 if sentinel) bytes. + unsafe { free_c_text(str.ptr, str.len, str.sentinel) }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ghostty_string_s_empty_string() { + let empty = EMPTY_STRING; + ghostty_string_free(empty); + assert_eq!(empty.len, 0); + assert!(!empty.sentinel); + } + + #[test] + fn ghostty_string_s_c_string() { + let s = string_from_owned("hello"); + assert_eq!(s.len, 5); + assert!(s.sentinel); + // SAFETY: just allocated with a NUL terminator. + let c = unsafe { CStr::from_ptr(s.ptr) }; + assert_eq!(c.to_str().unwrap(), "hello"); + ghostty_string_free(s); + } + + #[test] + fn info_version() { + let info = ghostty_info(); + assert!(info.version_len > 0); + // SAFETY: the version pointer is a static NUL terminated string. + let v = unsafe { CStr::from_ptr(info.version) }; + assert_eq!(v.to_bytes().len(), info.version_len); + } + + #[test] + fn translate_returns_same_pointer_without_translation() { + let id = c"this message has no translation"; + // SAFETY: valid NUL terminated string. + let out = unsafe { ghostty_translate(id.as_ptr()) }; + assert_eq!(out, id.as_ptr()); + } +} diff --git a/crates/ghostty-lib/src/types.rs b/crates/ghostty-lib/src/types.rs new file mode 100644 index 0000000..5c7a5f8 --- /dev/null +++ b/crates/ghostty-lib/src/types.rs @@ -0,0 +1,579 @@ +//! The C types of `include/ghostty.h`, laid out exactly as the header (and +//! the Zig `extern` structs) define them. +//! +//! Enums are `c_int` everywhere (the header uses `int`-sized enums and the C +//! caller may pass any value); the enumerator values live in submodules as +//! `i32` constants named after the header's enumerators. +#![allow(non_camel_case_types, clippy::struct_field_names)] + +use std::ffi::{c_char, c_int, c_void}; + +// --------------------------------------------------------------------- +// Opaque handles + +pub type ghostty_app_t = *mut c_void; +pub type ghostty_config_t = *mut c_void; +pub type ghostty_surface_t = *mut c_void; +pub type ghostty_inspector_t = *mut c_void; + +// --------------------------------------------------------------------- +// Scalars and enums (all `int`) + +pub type ghostty_platform_e = c_int; +pub type ghostty_clipboard_e = c_int; +pub type ghostty_clipboard_request_e = c_int; +pub type ghostty_input_mouse_state_e = c_int; +pub type ghostty_input_mouse_button_e = c_int; +pub type ghostty_input_mouse_momentum_e = c_int; +pub type ghostty_color_scheme_e = c_int; +pub type ghostty_input_scroll_mods_t = c_int; +pub type ghostty_input_mods_e = c_int; +pub type ghostty_binding_flags_e = c_int; +pub type ghostty_input_action_e = c_int; +pub type ghostty_input_key_e = c_int; +pub type ghostty_input_trigger_tag_e = c_int; +pub type ghostty_build_mode_e = c_int; +pub type ghostty_point_tag_e = c_int; +pub type ghostty_point_coord_e = c_int; +pub type ghostty_surface_context_e = c_int; +pub type ghostty_target_tag_e = c_int; +pub type ghostty_action_tag_e = c_int; + +/// `GHOSTTY_SUCCESS`. +pub const GHOSTTY_SUCCESS: c_int = 0; + +pub mod platform { + pub const INVALID: i32 = 0; + pub const MACOS: i32 = 1; + pub const IOS: i32 = 2; +} + +pub mod clipboard { + pub const STANDARD: i32 = 0; + pub const SELECTION: i32 = 1; +} + +pub mod trigger_tag { + pub const PHYSICAL: i32 = 0; + pub const UNICODE: i32 = 1; + pub const CATCH_ALL: i32 = 2; +} + +pub mod build_mode { + pub const DEBUG: i32 = 0; + pub const RELEASE_SAFE: i32 = 1; + pub const RELEASE_FAST: i32 = 2; + pub const RELEASE_SMALL: i32 = 3; +} + +pub mod target_tag { + pub const APP: i32 = 0; + pub const SURFACE: i32 = 1; +} + +// --------------------------------------------------------------------- +// Structs + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_clipboard_content_s { + pub mime: *const c_char, + pub data: *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_input_key_s { + pub action: ghostty_input_action_e, + pub mods: ghostty_input_mods_e, + pub consumed_mods: ghostty_input_mods_e, + pub keycode: u32, + pub text: *const c_char, + pub unshifted_codepoint: u32, + pub composing: bool, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_input_trigger_key_u { + pub translated: ghostty_input_key_e, + pub physical: ghostty_input_key_e, + pub unicode: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_input_trigger_s { + pub tag: ghostty_input_trigger_tag_e, + pub key: ghostty_input_trigger_key_u, + pub mods: ghostty_input_mods_e, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_command_s { + pub action_key: *const c_char, + pub action: *const c_char, + pub title: *const c_char, + pub description: *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_info_s { + pub build_mode: ghostty_build_mode_e, + pub version: *const c_char, + pub version_len: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_diagnostic_s { + pub message: *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_string_s { + pub ptr: *const c_char, + pub len: usize, + pub sentinel: bool, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_text_s { + pub tl_px_x: f64, + pub tl_px_y: f64, + pub offset_start: u32, + pub offset_len: u32, + pub text: *const c_char, + pub text_len: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_point_s { + pub tag: ghostty_point_tag_e, + pub coord: ghostty_point_coord_e, + pub x: u32, + pub y: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_selection_s { + pub top_left: ghostty_point_s, + pub bottom_right: ghostty_point_s, + pub rectangle: bool, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_env_var_s { + pub key: *const c_char, + pub value: *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_platform_macos_s { + pub nsview: *mut c_void, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_platform_ios_s { + pub uiview: *mut c_void, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_platform_u { + pub macos: ghostty_platform_macos_s, + pub ios: ghostty_platform_ios_s, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_surface_config_s { + pub platform_tag: ghostty_platform_e, + pub platform: ghostty_platform_u, + pub userdata: *mut c_void, + pub scale_factor: f64, + pub font_size: f32, + pub working_directory: *const c_char, + pub command: *const c_char, + pub env_vars: *mut ghostty_env_var_s, + pub env_var_count: usize, + pub initial_input: *const c_char, + pub wait_after_command: bool, + pub context: ghostty_surface_context_e, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_surface_size_s { + pub columns: u16, + pub rows: u16, + pub width_px: u32, + pub height_px: u32, + pub cell_width_px: u32, + pub cell_height_px: u32, +} + +// ---- config types + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_config_path_s { + pub path: *const c_char, + pub optional: bool, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_config_color_s { + pub r: u8, + pub g: u8, + pub b: u8, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_config_color_list_s { + pub colors: *const ghostty_config_color_s, + pub len: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_config_command_list_s { + pub commands: *const ghostty_command_s, + pub len: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_config_palette_s { + pub colors: [ghostty_config_color_s; 256], +} + +pub mod quick_terminal_size_tag { + pub const NONE: i32 = 0; + pub const PERCENTAGE: i32 = 1; + pub const PIXELS: i32 = 2; +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_quick_terminal_size_value_u { + pub percentage: f32, + pub pixels: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_quick_terminal_size_s { + pub tag: c_int, + pub value: ghostty_quick_terminal_size_value_u, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_config_quick_terminal_size_s { + pub primary: ghostty_quick_terminal_size_s, + pub secondary: ghostty_quick_terminal_size_s, +} + +// ---- targets and actions + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_target_u { + pub surface: ghostty_surface_t, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_target_s { + pub tag: ghostty_target_tag_e, + pub target: ghostty_target_u, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_resize_split_s { + pub amount: u16, + pub direction: c_int, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_move_tab_s { + pub amount: isize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_desktop_notification_s { + pub title: *const c_char, + pub body: *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_set_title_s { + pub title: *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_pwd_s { + pub pwd: *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_mouse_over_link_s { + pub url: *const c_char, + pub len: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_size_limit_s { + pub min_width: u32, + pub min_height: u32, + pub max_width: u32, + pub max_height: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_initial_size_s { + pub width: u32, + pub height: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_cell_size_s { + pub width: u32, + pub height: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_key_sequence_s { + pub active: bool, + pub trigger: ghostty_input_trigger_s, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_key_table_activate_s { + pub name: *const c_char, + pub len: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_action_key_table_u { + pub activate: ghostty_action_key_table_activate_s, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_key_table_s { + pub tag: c_int, + pub value: ghostty_action_key_table_u, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_color_change_s { + pub kind: c_int, + pub r: u8, + pub g: u8, + pub b: u8, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_config_change_s { + pub config: ghostty_config_t, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_reload_config_s { + pub soft: bool, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_open_url_s { + pub kind: c_int, + pub url: *const c_char, + pub len: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_surface_message_childexited_s { + pub exit_code: u32, + pub timetime_ms: u64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_progress_report_s { + pub state: c_int, + pub progress: i8, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_command_finished_s { + pub exit_code: i16, + pub duration: u64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_start_search_s { + pub needle: *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_search_total_s { + pub total: isize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_search_selected_s { + pub selected: isize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_scrollbar_s { + pub total: u64, + pub offset: u64, + pub len: u64, +} + +/// `ghostty_action_u`. Fields that are plain enums are `c_int`. +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_action_u { + pub new_split: c_int, + pub toggle_fullscreen: c_int, + pub move_tab: ghostty_action_move_tab_s, + pub goto_tab: c_int, + pub goto_split: c_int, + pub goto_window: c_int, + pub resize_split: ghostty_action_resize_split_s, + pub size_limit: ghostty_action_size_limit_s, + pub initial_size: ghostty_action_initial_size_s, + pub cell_size: ghostty_action_cell_size_s, + pub scrollbar: ghostty_action_scrollbar_s, + pub inspector: c_int, + pub desktop_notification: ghostty_action_desktop_notification_s, + pub set_title: ghostty_action_set_title_s, + pub set_tab_title: ghostty_action_set_title_s, + pub prompt_title: c_int, + pub pwd: ghostty_action_pwd_s, + pub mouse_shape: c_int, + pub mouse_visibility: c_int, + pub mouse_over_link: ghostty_action_mouse_over_link_s, + pub renderer_health: c_int, + pub quit_timer: c_int, + pub float_window: c_int, + pub secure_input: c_int, + pub key_sequence: ghostty_action_key_sequence_s, + pub key_table: ghostty_action_key_table_s, + pub color_change: ghostty_action_color_change_s, + pub reload_config: ghostty_action_reload_config_s, + pub config_change: ghostty_action_config_change_s, + pub open_url: ghostty_action_open_url_s, + pub close_tab_mode: c_int, + pub child_exited: ghostty_surface_message_childexited_s, + pub progress_report: ghostty_action_progress_report_s, + pub command_finished: ghostty_action_command_finished_s, + pub start_search: ghostty_action_start_search_s, + pub search_total: ghostty_action_search_total_s, + pub search_selected: ghostty_action_search_selected_s, + pub readonly: c_int, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_s { + pub tag: ghostty_action_tag_e, + pub action: ghostty_action_u, +} + +// ---- runtime callbacks + +pub type ghostty_runtime_wakeup_cb = Option; +pub type ghostty_runtime_read_clipboard_cb = + Option bool>; +pub type ghostty_runtime_confirm_read_clipboard_cb = Option< + unsafe extern "C" fn(*mut c_void, *const c_char, *mut c_void, ghostty_clipboard_request_e), +>; +pub type ghostty_runtime_write_clipboard_cb = Option< + unsafe extern "C" fn( + *mut c_void, + ghostty_clipboard_e, + *const ghostty_clipboard_content_s, + usize, + bool, + ), +>; +pub type ghostty_runtime_close_surface_cb = Option; +pub type ghostty_runtime_action_cb = + Option bool>; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_runtime_config_s { + pub userdata: *mut c_void, + pub supports_selection_clipboard: bool, + pub wakeup_cb: ghostty_runtime_wakeup_cb, + pub action_cb: ghostty_runtime_action_cb, + pub read_clipboard_cb: ghostty_runtime_read_clipboard_cb, + pub confirm_read_clipboard_cb: ghostty_runtime_confirm_read_clipboard_cb, + pub write_clipboard_cb: ghostty_runtime_write_clipboard_cb, + pub close_surface_cb: ghostty_runtime_close_surface_cb, +} + +// ---- ipc (declared by the header; the embedded runtime never uses them) + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_ipc_target_u { + pub klass: *mut c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct chostty_ipc_target_s { + pub tag: c_int, + pub target: ghostty_ipc_target_u, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_ipc_action_new_window_s { + pub arguments: *mut *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_ipc_action_u { + pub new_window: ghostty_ipc_action_new_window_s, +} diff --git a/crates/ghostty-lib/tests/abi.rs b/crates/ghostty-lib/tests/abi.rs new file mode 100644 index 0000000..2c3dcaf --- /dev/null +++ b/crates/ghostty-lib/tests/abi.rs @@ -0,0 +1,1385 @@ +//! ABI verification: a C program generated from the unchanged +//! `include/ghostty.h` prints `sizeof`/`alignof`/`offsetof` of every struct +//! and union, `sizeof` of every enum and the value of every enumerator, and +//! takes the address of every declared function (so linking proves the +//! symbol is exported). The values are compared against the Rust +//! definitions. A second test compares the `nm` exports with the header. +#![cfg(unix)] +#![allow(clippy::unwrap_used, clippy::items_after_statements)] + +mod common; + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt::Write as _, + mem::{align_of, offset_of, size_of}, + process::Command, +}; + +use ghostty::types::*; +use ghostty_apprt::{ + action::{self, Key as ActionKey}, + structs::{Clipboard, ClipboardRequestType, ColorScheme}, + surface::NewSurfaceContext, +}; +use ghostty_input::{ + Action as KeyAction, + Key, + Mods, + MouseButton, + MouseButtonState, + binding::Flags, + key_mods::{ModSide, Side}, + mouse::Momentum, +}; +use ghostty_vt::mouse_shape::MouseShape; + +/// `GHOSTTY_FOO_BAR` / `FooBar` / `foo_bar` -> `foobar`. +fn norm(s: &str) -> String { + s.chars() + .filter(|c| *c != '_') + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// Parsed `typedef enum { ... } Name;` blocks: typedef name -> enumerators. +fn header_enums(text: &str) -> BTreeMap> { + let mut map = BTreeMap::new(); + let mut rest = text; + while let Some(start) = rest.find("typedef enum") { + let after = &rest[start..]; + let open = after.find('{').unwrap(); + let close = after.find('}').unwrap(); + let name_end = after[close..].find(';').unwrap() + close; + let name = after[close + 1..name_end].trim().to_string(); + let names = after[open + 1..close] + .split(',') + .map(|e| e.split('=').next().unwrap().trim().to_string()) + .filter(|e| !e.is_empty()) + .collect(); + map.insert(name, names); + rest = &after[name_end..]; + } + map +} + +/// Parsed `typedef struct|union { ... } name;` blocks: name -> depth-1 field +/// names. +fn header_records(text: &str) -> BTreeMap> { + let mut map = BTreeMap::new(); + let b = text.as_bytes(); + let mut i = 0; + while let Some(pos) = text[i..].find("typedef ") { + let start = i + pos; + let after = &text[start + 8..]; + let kw = if after.starts_with("struct {") { + Some("struct {") + } else if after.starts_with("union {") { + Some("union {") + } else { + None + }; + i = start + 8; + let Some(kw) = kw else { continue }; + let open = start + 8 + kw.len() - 1; + // Match braces. + let mut depth = 0usize; + let mut j = open; + let mut fields = Vec::new(); + let mut stmt = String::new(); + loop { + match b[j] { + b'{' => { + depth += 1; + if depth > 1 { + stmt.push('{'); + } + } + b'}' => { + depth -= 1; + if depth == 0 { + break; + } + stmt.push('}'); + } + b';' if depth == 1 => { + // A field: the last identifier, minus array suffix. + let s = stmt.trim(); + let s = if let Some(p) = s.rfind('}') { + &s[p + 1..] + } else { + s + }; + let s = s.split('[').next().unwrap().trim(); + let name = s + .rsplit(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .next() + .unwrap(); + if !name.is_empty() { + fields.push(name.to_string()); + } + stmt.clear(); + } + c => stmt.push(c as char), + } + j += 1; + } + let end = text[j..].find(';').unwrap() + j; + let name = text[j + 1..end].trim().to_string(); + map.insert(name, fields); + i = end; + } + map +} + +/// Names and values of a Rust-side enum, keyed by normalized name. +fn vals(items: &[T], f: impl Fn(T) -> i64) -> BTreeMap { + items + .iter() + .map(|i| (norm(&format!("{i:?}")), f(*i))) + .collect() +} + +fn named(items: &[(&str, i64)]) -> BTreeMap { + items.iter().map(|(n, v)| (norm(n), *v)).collect() +} + +struct Probe { + c: String, + expected: BTreeMap, + records: BTreeMap>, + covered_records: BTreeSet, + covered_enums: BTreeSet, +} + +impl Probe { + fn emit(&mut self, key: &str, c_expr: &str, expected: i64) { + let _ = writeln!( + self.c, + " printf(\"%s=%lld\\n\", \"{key}\", (long long)({c_expr}));" + ); + self.expected.insert(key.to_string(), expected); + } + + /// `fields` are the Rust offsets in the header's field order; the field + /// names are taken from the parsed header (so a missing or reordered + /// field is caught). + fn layout(&mut self, ty: &str, size: usize, align: usize, offsets: &[usize]) { + let fields = self + .records + .get(ty) + .unwrap_or_else(|| panic!("no record {ty} in the header")) + .clone(); + assert_eq!( + fields.len(), + offsets.len(), + "{ty}: the header has fields {fields:?}" + ); + self.covered_records.insert(ty.to_string()); + self.emit( + &format!("sizeof({ty})"), + &format!("sizeof({ty})"), + size as i64, + ); + self.emit( + &format!("alignof({ty})"), + &format!("_Alignof({ty})"), + align as i64, + ); + for (f, off) in fields.iter().zip(offsets) { + self.emit( + &format!("offsetof({ty},{f})"), + &format!("offsetof({ty}, {f})"), + *off as i64, + ); + } + } + + /// Check every enumerator of `typedef` against `rust` (names after + /// `prefix`); the C compiler provides the values. + fn enumerators( + &mut self, + enums: &BTreeMap>, + typedef: &str, + prefix: &str, + rust: &BTreeMap, + ) { + let names = enums + .get(typedef) + .unwrap_or_else(|| panic!("no enum {typedef}")); + self.covered_enums.insert(typedef.to_string()); + // All GHOSTTY enums are `int` sized. + self.emit( + &format!("sizeof({typedef})"), + &format!("sizeof({typedef})"), + 4, + ); + for n in names { + let short = n + .strip_prefix(prefix) + .unwrap_or_else(|| panic!("{n} lacks {prefix}")); + let want = *rust + .get(&norm(short)) + .unwrap_or_else(|| panic!("{typedef}: Rust has no member for {n}")); + self.emit(n, n, want); + } + assert_eq!(names.len(), rust.len(), "{typedef}: member count differs"); + } +} + +/// `layout!(probe, c_name, RustType, field, ...)` +macro_rules! layout { + ($p:expr, $ty:ty, $name:literal, $($f:ident),+ $(,)?) => { + $p.layout( + $name, + size_of::<$ty>(), + align_of::<$ty>(), + &[$(offset_of!($ty, $f)),+], + ) + }; +} + +fn build_probe() -> Probe { + let text = common::header_text(); + let enums = header_enums(&text); + let mut p = Probe { + c: String::new(), + expected: BTreeMap::new(), + records: header_records(&text), + covered_records: BTreeSet::new(), + covered_enums: BTreeSet::new(), + }; + + // ---- structs and unions ---- + layout!( + p, + ghostty_clipboard_content_s, + "ghostty_clipboard_content_s", + mime, + data + ); + layout!( + p, + ghostty_input_key_s, + "ghostty_input_key_s", + action, + mods, + consumed_mods, + keycode, + text, + unshifted_codepoint, + composing + ); + layout!( + p, + ghostty_input_trigger_key_u, + "ghostty_input_trigger_key_u", + translated, + physical, + unicode + ); + layout!( + p, + ghostty_input_trigger_s, + "ghostty_input_trigger_s", + tag, + key, + mods + ); + layout!( + p, + ghostty_command_s, + "ghostty_command_s", + action_key, + action, + title, + description + ); + layout!( + p, + ghostty_info_s, + "ghostty_info_s", + build_mode, + version, + version_len + ); + layout!(p, ghostty_diagnostic_s, "ghostty_diagnostic_s", message); + layout!(p, ghostty_string_s, "ghostty_string_s", ptr, len, sentinel); + layout!( + p, + ghostty_text_s, + "ghostty_text_s", + tl_px_x, + tl_px_y, + offset_start, + offset_len, + text, + text_len + ); + layout!(p, ghostty_point_s, "ghostty_point_s", tag, coord, x, y); + layout!( + p, + ghostty_selection_s, + "ghostty_selection_s", + top_left, + bottom_right, + rectangle + ); + layout!(p, ghostty_env_var_s, "ghostty_env_var_s", key, value); + layout!( + p, + ghostty_platform_macos_s, + "ghostty_platform_macos_s", + nsview + ); + layout!(p, ghostty_platform_ios_s, "ghostty_platform_ios_s", uiview); + layout!(p, ghostty_platform_u, "ghostty_platform_u", macos, ios); + layout!( + p, + ghostty_surface_config_s, + "ghostty_surface_config_s", + platform_tag, + platform, + userdata, + scale_factor, + font_size, + working_directory, + command, + env_vars, + env_var_count, + initial_input, + wait_after_command, + context + ); + layout!( + p, + ghostty_surface_size_s, + "ghostty_surface_size_s", + columns, + rows, + width_px, + height_px, + cell_width_px, + cell_height_px + ); + layout!( + p, + ghostty_config_path_s, + "ghostty_config_path_s", + path, + optional + ); + layout!(p, ghostty_config_color_s, "ghostty_config_color_s", r, g, b); + layout!( + p, + ghostty_config_color_list_s, + "ghostty_config_color_list_s", + colors, + len + ); + layout!( + p, + ghostty_config_command_list_s, + "ghostty_config_command_list_s", + commands, + len + ); + layout!( + p, + ghostty_config_palette_s, + "ghostty_config_palette_s", + colors + ); + layout!( + p, + ghostty_quick_terminal_size_value_u, + "ghostty_quick_terminal_size_value_u", + percentage, + pixels + ); + layout!( + p, + ghostty_quick_terminal_size_s, + "ghostty_quick_terminal_size_s", + tag, + value + ); + layout!( + p, + ghostty_config_quick_terminal_size_s, + "ghostty_config_quick_terminal_size_s", + primary, + secondary + ); + layout!(p, ghostty_target_u, "ghostty_target_u", surface); + layout!(p, ghostty_target_s, "ghostty_target_s", tag, target); + layout!( + p, + ghostty_action_resize_split_s, + "ghostty_action_resize_split_s", + amount, + direction + ); + layout!( + p, + ghostty_action_move_tab_s, + "ghostty_action_move_tab_s", + amount + ); + layout!( + p, + ghostty_action_desktop_notification_s, + "ghostty_action_desktop_notification_s", + title, + body + ); + layout!( + p, + ghostty_action_set_title_s, + "ghostty_action_set_title_s", + title + ); + layout!(p, ghostty_action_pwd_s, "ghostty_action_pwd_s", pwd); + layout!( + p, + ghostty_action_mouse_over_link_s, + "ghostty_action_mouse_over_link_s", + url, + len + ); + layout!( + p, + ghostty_action_size_limit_s, + "ghostty_action_size_limit_s", + min_width, + min_height, + max_width, + max_height + ); + layout!( + p, + ghostty_action_initial_size_s, + "ghostty_action_initial_size_s", + width, + height + ); + layout!( + p, + ghostty_action_cell_size_s, + "ghostty_action_cell_size_s", + width, + height + ); + layout!( + p, + ghostty_action_key_sequence_s, + "ghostty_action_key_sequence_s", + active, + trigger + ); + layout!( + p, + ghostty_action_key_table_u, + "ghostty_action_key_table_u", + activate + ); + layout!( + p, + ghostty_action_key_table_s, + "ghostty_action_key_table_s", + tag, + value + ); + layout!( + p, + ghostty_action_color_change_s, + "ghostty_action_color_change_s", + kind, + r, + g, + b + ); + layout!( + p, + ghostty_action_config_change_s, + "ghostty_action_config_change_s", + config + ); + layout!( + p, + ghostty_action_reload_config_s, + "ghostty_action_reload_config_s", + soft + ); + layout!( + p, + ghostty_action_open_url_s, + "ghostty_action_open_url_s", + kind, + url, + len + ); + layout!( + p, + ghostty_surface_message_childexited_s, + "ghostty_surface_message_childexited_s", + exit_code, + timetime_ms + ); + layout!( + p, + ghostty_action_progress_report_s, + "ghostty_action_progress_report_s", + state, + progress + ); + layout!( + p, + ghostty_action_command_finished_s, + "ghostty_action_command_finished_s", + exit_code, + duration + ); + layout!( + p, + ghostty_action_start_search_s, + "ghostty_action_start_search_s", + needle + ); + layout!( + p, + ghostty_action_search_total_s, + "ghostty_action_search_total_s", + total + ); + layout!( + p, + ghostty_action_search_selected_s, + "ghostty_action_search_selected_s", + selected + ); + layout!( + p, + ghostty_action_scrollbar_s, + "ghostty_action_scrollbar_s", + total, + offset, + len + ); + layout!( + p, + ghostty_action_u, + "ghostty_action_u", + new_split, + toggle_fullscreen, + move_tab, + goto_tab, + goto_split, + goto_window, + resize_split, + size_limit, + initial_size, + cell_size, + scrollbar, + inspector, + desktop_notification, + set_title, + set_tab_title, + prompt_title, + pwd, + mouse_shape, + mouse_visibility, + mouse_over_link, + renderer_health, + quit_timer, + float_window, + secure_input, + key_sequence, + key_table, + color_change, + reload_config, + config_change, + open_url, + close_tab_mode, + child_exited, + progress_report, + command_finished, + start_search, + search_total, + search_selected, + readonly + ); + layout!(p, ghostty_action_s, "ghostty_action_s", tag, action); + layout!( + p, + ghostty_runtime_config_s, + "ghostty_runtime_config_s", + userdata, + supports_selection_clipboard, + wakeup_cb, + action_cb, + read_clipboard_cb, + confirm_read_clipboard_cb, + write_clipboard_cb, + close_surface_cb + ); + layout!(p, ghostty_ipc_target_u, "ghostty_ipc_target_u", klass); + layout!(p, chostty_ipc_target_s, "chostty_ipc_target_s", tag, target); + layout!( + p, + ghostty_ipc_action_new_window_s, + "ghostty_ipc_action_new_window_s", + arguments + ); + layout!(p, ghostty_ipc_action_u, "ghostty_ipc_action_u", new_window); + + // ---- scalar typedefs and opaque handles ---- + for (ty, size) in [ + ("ghostty_app_t", size_of::()), + ("ghostty_config_t", size_of::()), + ("ghostty_surface_t", size_of::()), + ("ghostty_inspector_t", size_of::()), + ("ghostty_input_scroll_mods_t", 4), + ("ghostty_runtime_wakeup_cb", size_of::()), + ("ghostty_runtime_action_cb", size_of::()), + ] { + p.emit( + &format!("sizeof({ty})"), + &format!("sizeof({ty})"), + size as i64, + ); + } + p.emit( + "GHOSTTY_SUCCESS", + "GHOSTTY_SUCCESS", + i64::from(GHOSTTY_SUCCESS), + ); + + // ---- enums ---- + p.enumerators( + &enums, + "ghostty_platform_e", + "GHOSTTY_PLATFORM_", + &named(&[ + ("invalid", i64::from(platform::INVALID)), + ("macos", i64::from(platform::MACOS)), + ("ios", i64::from(platform::IOS)), + ]), + ); + p.enumerators( + &enums, + "ghostty_clipboard_e", + "GHOSTTY_CLIPBOARD_", + &vals(&[Clipboard::Standard, Clipboard::Selection], |v| v as i64), + ); + p.enumerators( + &enums, + "ghostty_clipboard_request_e", + "GHOSTTY_CLIPBOARD_REQUEST_", + &named(&[ + ("paste", ClipboardRequestType::Paste as i64), + ("osc_52_read", ClipboardRequestType::Osc52Read as i64), + ("osc_52_write", ClipboardRequestType::Osc52Write as i64), + ]), + ); + p.enumerators( + &enums, + "ghostty_input_mouse_state_e", + "GHOSTTY_MOUSE_", + &vals(&[MouseButtonState::Release, MouseButtonState::Press], |v| { + v as i64 + }), + ); + p.enumerators( + &enums, + "ghostty_input_mouse_button_e", + "GHOSTTY_MOUSE_", + &vals( + &[ + MouseButton::Unknown, + MouseButton::Left, + MouseButton::Right, + MouseButton::Middle, + MouseButton::Four, + MouseButton::Five, + MouseButton::Six, + MouseButton::Seven, + MouseButton::Eight, + MouseButton::Nine, + MouseButton::Ten, + MouseButton::Eleven, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_input_mouse_momentum_e", + "GHOSTTY_MOUSE_MOMENTUM_", + &vals( + &[ + Momentum::None, + Momentum::Began, + Momentum::Stationary, + Momentum::Changed, + Momentum::Ended, + Momentum::Cancelled, + Momentum::MayBegin, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_color_scheme_e", + "GHOSTTY_COLOR_SCHEME_", + &vals(&[ColorScheme::Light, ColorScheme::Dark], |v| v as i64), + ); + let side = |f: fn(&mut Side)| { + let mut s = Side::default(); + f(&mut s); + Mods { + sides: s, + ..Mods::default() + } + .int() as i64 + }; + p.enumerators( + &enums, + "ghostty_input_mods_e", + "GHOSTTY_MODS_", + &named(&[ + ("none", 0), + ( + "shift", + Mods { + shift: true, + ..Mods::default() + } + .int() + .into(), + ), + ( + "ctrl", + Mods { + ctrl: true, + ..Mods::default() + } + .int() + .into(), + ), + ( + "alt", + Mods { + alt: true, + ..Mods::default() + } + .int() + .into(), + ), + ( + "super", + Mods { + super_: true, + ..Mods::default() + } + .int() + .into(), + ), + ( + "caps", + Mods { + caps_lock: true, + ..Mods::default() + } + .int() + .into(), + ), + ( + "num", + Mods { + num_lock: true, + ..Mods::default() + } + .int() + .into(), + ), + ("shift_right", side(|s| s.shift = ModSide::Right)), + ("ctrl_right", side(|s| s.ctrl = ModSide::Right)), + ("alt_right", side(|s| s.alt = ModSide::Right)), + ("super_right", side(|s| s.super_ = ModSide::Right)), + ]), + ); + let flag = |f: fn(&mut Flags)| { + let mut fl = Flags { + consumed: false, + all: false, + global: false, + performable: false, + }; + f(&mut fl); + i64::from(fl.cval()) + }; + p.enumerators( + &enums, + "ghostty_binding_flags_e", + "GHOSTTY_BINDING_FLAGS_", + &named(&[ + ("consumed", flag(|f| f.consumed = true)), + ("all", flag(|f| f.all = true)), + ("global", flag(|f| f.global = true)), + ("performable", flag(|f| f.performable = true)), + ]), + ); + p.enumerators( + &enums, + "ghostty_input_action_e", + "GHOSTTY_ACTION_", + &vals( + &[KeyAction::Release, KeyAction::Press, KeyAction::Repeat], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_input_key_e", + "GHOSTTY_KEY_", + &Key::ALL + .iter() + .enumerate() + .map(|(i, k)| { + // The header names the letter keys `A`..`Z` (`KeyA` in the + // W3C code table). + let n = k.name(); + let n = if n.len() == 5 { + n.strip_prefix("key_").unwrap_or(n) + } else { + n + }; + (norm(n), i as i64) + }) + .collect(), + ); + p.enumerators( + &enums, + "ghostty_input_trigger_tag_e", + "GHOSTTY_TRIGGER_", + &named(&[ + ("physical", i64::from(trigger_tag::PHYSICAL)), + ("unicode", i64::from(trigger_tag::UNICODE)), + ("catch_all", i64::from(trigger_tag::CATCH_ALL)), + ]), + ); + p.enumerators( + &enums, + "ghostty_build_mode_e", + "GHOSTTY_BUILD_MODE_", + &named(&[ + ("debug", i64::from(build_mode::DEBUG)), + ("release_safe", i64::from(build_mode::RELEASE_SAFE)), + ("release_fast", i64::from(build_mode::RELEASE_FAST)), + ("release_small", i64::from(build_mode::RELEASE_SMALL)), + ]), + ); + p.enumerators( + &enums, + "ghostty_point_tag_e", + "GHOSTTY_POINT_", + // The header calls the fourth tag `SURFACE`; the Zig `Point.Tag` + // calls it `history` (value 3). + &named(&[ + ("active", 0), + ("viewport", 1), + ("screen", 2), + ("surface", 3), + ]), + ); + p.enumerators( + &enums, + "ghostty_point_coord_e", + "GHOSTTY_POINT_COORD_", + &named(&[("exact", 0), ("top_left", 1), ("bottom_right", 2)]), + ); + p.enumerators( + &enums, + "ghostty_surface_context_e", + "GHOSTTY_SURFACE_CONTEXT_", + &vals( + &[ + NewSurfaceContext::Window, + NewSurfaceContext::Tab, + NewSurfaceContext::Split, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_quick_terminal_size_tag_e", + "GHOSTTY_QUICK_TERMINAL_SIZE_", + &named(&[ + ("none", i64::from(quick_terminal_size_tag::NONE)), + ("percentage", i64::from(quick_terminal_size_tag::PERCENTAGE)), + ("pixels", i64::from(quick_terminal_size_tag::PIXELS)), + ]), + ); + p.enumerators( + &enums, + "ghostty_config_fullscreen_e", + "GHOSTTY_CONFIG_FULLSCREEN_", + &ghostty_config::types_a::Fullscreen::ALL + .iter() + .enumerate() + .map(|(i, v)| (norm(&format!("{v:?}")), i as i64)) + .collect(), + ); + p.enumerators( + &enums, + "ghostty_target_tag_e", + "GHOSTTY_TARGET_", + &named(&[ + ("app", i64::from(target_tag::APP)), + ("surface", i64::from(target_tag::SURFACE)), + ]), + ); + use action as a; + p.enumerators( + &enums, + "ghostty_action_split_direction_e", + "GHOSTTY_SPLIT_DIRECTION_", + &vals( + &[ + a::SplitDirection::Right, + a::SplitDirection::Down, + a::SplitDirection::Left, + a::SplitDirection::Up, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_goto_split_e", + "GHOSTTY_GOTO_SPLIT_", + &vals( + &[ + a::GotoSplit::Previous, + a::GotoSplit::Next, + a::GotoSplit::Up, + a::GotoSplit::Left, + a::GotoSplit::Down, + a::GotoSplit::Right, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_goto_window_e", + "GHOSTTY_GOTO_WINDOW_", + &vals(&[a::GotoWindow::Previous, a::GotoWindow::Next], |v| { + v as i64 + }), + ); + p.enumerators( + &enums, + "ghostty_action_resize_split_direction_e", + "GHOSTTY_RESIZE_SPLIT_", + &vals( + &[ + a::ResizeSplitDirection::Up, + a::ResizeSplitDirection::Down, + a::ResizeSplitDirection::Left, + a::ResizeSplitDirection::Right, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_goto_tab_e", + "GHOSTTY_GOTO_TAB_", + &named(&[ + ("previous", i64::from(a::GotoTab::Previous.int())), + ("next", i64::from(a::GotoTab::Next.int())), + ("last", i64::from(a::GotoTab::Last.int())), + ]), + ); + p.enumerators( + &enums, + "ghostty_action_fullscreen_e", + "GHOSTTY_FULLSCREEN_", + &vals( + &[ + a::Fullscreen::Native, + a::Fullscreen::MacosNonNative, + a::Fullscreen::MacosNonNativeVisibleMenu, + a::Fullscreen::MacosNonNativePaddedNotch, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_float_window_e", + "GHOSTTY_FLOAT_WINDOW_", + &vals( + &[ + a::FloatWindow::On, + a::FloatWindow::Off, + a::FloatWindow::Toggle, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_secure_input_e", + "GHOSTTY_SECURE_INPUT_", + &vals( + &[ + a::SecureInput::On, + a::SecureInput::Off, + a::SecureInput::Toggle, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_inspector_e", + "GHOSTTY_INSPECTOR_", + &vals( + &[a::Inspector::Toggle, a::Inspector::Show, a::Inspector::Hide], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_quit_timer_e", + "GHOSTTY_QUIT_TIMER_", + &vals(&[a::QuitTimer::Start, a::QuitTimer::Stop], |v| v as i64), + ); + p.enumerators( + &enums, + "ghostty_action_readonly_e", + "GHOSTTY_READONLY_", + &vals(&[a::Readonly::Off, a::Readonly::On], |v| v as i64), + ); + p.enumerators( + &enums, + "ghostty_action_prompt_title_e", + "GHOSTTY_PROMPT_TITLE_", + &vals(&[a::PromptTitle::Surface, a::PromptTitle::Tab], |v| { + v as i64 + }), + ); + use MouseShape as M; + p.enumerators( + &enums, + "ghostty_action_mouse_shape_e", + "GHOSTTY_MOUSE_SHAPE_", + &vals( + &[ + M::Default, + M::ContextMenu, + M::Help, + M::Pointer, + M::Progress, + M::Wait, + M::Cell, + M::Crosshair, + M::Text, + M::VerticalText, + M::Alias, + M::Copy, + M::Move, + M::NoDrop, + M::NotAllowed, + M::Grab, + M::Grabbing, + M::AllScroll, + M::ColResize, + M::RowResize, + M::NResize, + M::EResize, + M::SResize, + M::WResize, + M::NeResize, + M::NwResize, + M::SeResize, + M::SwResize, + M::EwResize, + M::NsResize, + M::NeswResize, + M::NwseResize, + M::ZoomIn, + M::ZoomOut, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_mouse_visibility_e", + "GHOSTTY_MOUSE_", + &vals( + &[a::MouseVisibility::Visible, a::MouseVisibility::Hidden], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_renderer_health_e", + "GHOSTTY_RENDERER_HEALTH_", + &named(&[("healthy", 0), ("unhealthy", 1)]), + ); + p.enumerators( + &enums, + "ghostty_action_key_table_tag_e", + "GHOSTTY_KEY_TABLE_", + &named(&[("activate", 0), ("deactivate", 1), ("deactivate_all", 2)]), + ); + p.enumerators( + &enums, + "ghostty_action_color_kind_e", + "GHOSTTY_ACTION_COLOR_KIND_", + &named(&[ + ("foreground", i64::from(a::ColorKind::Foreground.int())), + ("background", i64::from(a::ColorKind::Background.int())), + ("cursor", i64::from(a::ColorKind::Cursor.int())), + ]), + ); + p.enumerators( + &enums, + "ghostty_action_open_url_kind_e", + "GHOSTTY_ACTION_OPEN_URL_KIND_", + &vals( + &[ + a::OpenUrlKind::Unknown, + a::OpenUrlKind::Text, + a::OpenUrlKind::Html, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_close_tab_mode_e", + "GHOSTTY_ACTION_CLOSE_TAB_MODE_", + &vals( + &[ + a::CloseTabMode::This, + a::CloseTabMode::Other, + a::CloseTabMode::Right, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_action_progress_report_state_e", + "GHOSTTY_PROGRESS_STATE_", + &named(&[ + ("remove", 0), + ("set", 1), + ("error", 2), + ("indeterminate", 3), + ("pause", 4), + ]), + ); + use ActionKey as K; + p.enumerators( + &enums, + "ghostty_action_tag_e", + "GHOSTTY_ACTION_", + &vals( + &[ + K::Quit, + K::NewWindow, + K::NewTab, + K::CloseTab, + K::NewSplit, + K::CloseAllWindows, + K::ToggleMaximize, + K::ToggleFullscreen, + K::ToggleTabOverview, + K::ToggleWindowDecorations, + K::ToggleQuickTerminal, + K::ToggleCommandPalette, + K::ToggleVisibility, + K::ToggleBackgroundOpacity, + K::MoveTab, + K::GotoTab, + K::GotoSplit, + K::GotoWindow, + K::ResizeSplit, + K::EqualizeSplits, + K::ToggleSplitZoom, + K::PresentTerminal, + K::SizeLimit, + K::ResetWindowSize, + K::InitialSize, + K::CellSize, + K::Scrollbar, + K::Render, + K::Inspector, + K::ShowGtkInspector, + K::RenderInspector, + K::DesktopNotification, + K::SetTitle, + K::SetTabTitle, + K::PromptTitle, + K::Pwd, + K::MouseShape, + K::MouseVisibility, + K::MouseOverLink, + K::RendererHealth, + K::OpenConfig, + K::QuitTimer, + K::FloatWindow, + K::SecureInput, + K::KeySequence, + K::KeyTable, + K::ColorChange, + K::ReloadConfig, + K::ConfigChange, + K::CloseWindow, + K::RingBell, + K::Undo, + K::Redo, + K::CheckForUpdates, + K::OpenUrl, + K::ShowChildExited, + K::ProgressReport, + K::ShowOnScreenKeyboard, + K::CommandFinished, + K::StartSearch, + K::EndSearch, + K::SearchTotal, + K::SearchSelected, + K::Readonly, + K::CopyTitleToClipboard, + ], + |v| v as i64, + ), + ); + p.enumerators( + &enums, + "ghostty_ipc_target_tag_e", + "GHOSTTY_IPC_TARGET_", + &named(&[("class", 0), ("detect", 1)]), + ); + p.enumerators( + &enums, + "ghostty_ipc_action_tag_e", + "GHOSTTY_IPC_ACTION_", + &named(&[("new_window", 0)]), + ); + + // Every enum and record of the header must be covered. + let all_enums: BTreeSet<_> = enums.keys().cloned().collect(); + assert_eq!(all_enums, p.covered_enums, "uncovered enums"); + let all_records: BTreeSet<_> = p.records.keys().cloned().collect(); + assert_eq!(all_records, p.covered_records, "uncovered structs/unions"); + p +} + +/// The enum `ghostty_action_tag_e` has the same size as the Rust key. +#[test] +fn action_tags_fit_the_union() { + assert_eq!(size_of::(), 24); + assert_eq!(size_of::(), 32); + assert_eq!(size_of::(), 16); +} + +#[test] +fn abi_matches_header() { + let probe = build_probe(); + let lib_dir = common::build_lib(); + let dir = common::scratch_dir("abi"); + + // Take the address of every declared function so linking proves the + // library exports it. + let funcs = common::declared_functions(); + assert!(funcs.len() > 80, "parsed only {} functions", funcs.len()); + let mut c = String::from( + "#include \n#include \n#include \n#include \n\n", + ); + c.push_str("static void *const funcs[] = {\n"); + for f in &funcs { + let _ = writeln!(c, " (void *)&{f},"); + } + c.push_str("};\n\nint main(void) {\n"); + c.push_str(" if (sizeof(funcs) == 0) return 2;\n"); + c.push_str(&probe.c); + c.push_str(" return 0;\n}\n"); + let src = dir.join("probe.c"); + std::fs::write(&src, c).unwrap(); + let exe = dir.join("probe"); + common::compile_c(&lib_dir, &[&src], &exe, &["-Wno-deprecated-declarations"]); + + let out = Command::new(&exe).output().unwrap(); + assert!(out.status.success(), "probe failed"); + let got: BTreeMap = String::from_utf8(out.stdout) + .unwrap() + .lines() + .map(|l| { + let (k, v) = l.rsplit_once('=').unwrap(); + (k.to_string(), v.parse().unwrap()) + }) + .collect(); + + let mut bad = Vec::new(); + for (k, want) in &probe.expected { + match got.get(k) { + Some(g) if g == want => {} + Some(g) => bad.push(format!("{k}: C={g} Rust={want}")), + None => bad.push(format!("{k}: missing from C output")), + } + } + assert!(bad.is_empty(), "ABI mismatches:\n{}", bad.join("\n")); + assert!(got.len() > 700, "only {} values checked", got.len()); +} + +#[test] +fn exports_match_header() { + let lib_dir = common::build_lib(); + let so = ["libghostty.so", "libghostty.dylib"] + .iter() + .map(|n| lib_dir.join(n)) + .find(|p| p.exists()) + .expect("shared library"); + let out = Command::new("nm").arg("-D").arg(&so).output().unwrap(); + assert!(out.status.success()); + let exported: BTreeSet = String::from_utf8(out.stdout) + .unwrap() + .lines() + .filter_map(|l| { + let mut it = l.split_whitespace(); + let (_addr, kind, name) = (it.next()?, it.next()?, it.next()?); + (kind == "T" && name.starts_with("ghostty_")).then(|| name.to_string()) + }) + .collect(); + let declared: BTreeSet = common::declared_functions().into_iter().collect(); + let missing: Vec<_> = declared.difference(&exported).collect(); + let extra: Vec<_> = exported.difference(&declared).collect(); + assert!( + missing.is_empty() && extra.is_empty(), + "missing exports: {missing:?}\nextra exports: {extra:?}" + ); +} + +#[test] +fn header_files_are_unchanged_copies() { + let zig = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../ghostty/include"); + if !zig.exists() { + eprintln!("skipping: Zig checkout not found"); + return; + } + for f in ["ghostty.h", "module.modulemap"] { + let a = std::fs::read(zig.join(f)).unwrap(); + let b = std::fs::read(common::include_dir().join(f)).unwrap(); + assert_eq!(a, b, "{f} differs from upstream"); + } +} diff --git a/crates/ghostty-lib/tests/c/host.c b/crates/ghostty-lib/tests/c/host.c new file mode 100644 index 0000000..ddba8b3 --- /dev/null +++ b/crates/ghostty-lib/tests/c/host.c @@ -0,0 +1,385 @@ +// A host program embedding libghostty through the unchanged ghostty.h: +// the flows the macOS app uses that are platform independent (init, CLI +// actions, config, app lifecycle, key bindings, color scheme). +#define _GNU_SOURCE +#include + +#include +#include +#include +#include +#include + +static int failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "CHECK failed %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +// ---- runtime callbacks ---------------------------------------------- + +static atomic_int wakeups; +static int app_userdata_marker = 42; + +#define MAX_ACTIONS 256 +static int actions[MAX_ACTIONS]; +static int action_count; +static int target_tags[MAX_ACTIONS]; +static bool last_config_change_maximize; +static char last_config_change_title[64]; +static bool last_reload_soft; +static int last_quit_timer = -1; + +static void on_wakeup(void* ud) { + (void)ud; + atomic_fetch_add(&wakeups, 1); +} + +static bool on_action(ghostty_app_t app, ghostty_target_s target, + ghostty_action_s action) { + if (action_count < MAX_ACTIONS) { + target_tags[action_count] = target.tag; + actions[action_count++] = action.tag; + } + CHECK(ghostty_app_userdata(app) == &app_userdata_marker); + switch (action.tag) { + case GHOSTTY_ACTION_CONFIG_CHANGE: { + ghostty_config_t cfg = action.action.config_change.config; + bool maximize = false; + CHECK(ghostty_config_get(cfg, &maximize, "maximize", 8)); + last_config_change_maximize = maximize; + const char* title = NULL; + CHECK(ghostty_config_get(cfg, &title, "title", 5)); + snprintf(last_config_change_title, sizeof last_config_change_title, "%s", + title ? title : "(null)"); + break; + } + case GHOSTTY_ACTION_RELOAD_CONFIG: + last_reload_soft = action.action.reload_config.soft; + break; + case GHOSTTY_ACTION_QUIT_TIMER: + last_quit_timer = action.action.quit_timer; + break; + default: + break; + } + return true; +} + +static bool on_read_clipboard(void* ud, ghostty_clipboard_e c, void* state) { + (void)ud; (void)c; (void)state; + return false; +} +static void on_confirm_read(void* ud, const char* s, void* state, + ghostty_clipboard_request_e r) { + (void)ud; (void)s; (void)state; (void)r; +} +static void on_write_clipboard(void* ud, ghostty_clipboard_e c, + const ghostty_clipboard_content_s* content, + size_t n, bool confirm) { + (void)ud; (void)c; (void)content; (void)n; (void)confirm; +} +static void on_close_surface(void* ud, bool process_alive) { + (void)ud; (void)process_alive; +} + +static int has_action(int tag) { + for (int i = 0; i < action_count; i++) + if (actions[i] == tag) return 1; + return 0; +} + +static ghostty_input_key_s key_event(ghostty_input_action_e action, + uint32_t keycode, int mods, + const char* text) { + ghostty_input_key_s e; + memset(&e, 0, sizeof e); + e.action = action; + e.mods = mods; + e.keycode = keycode; + e.text = text; + return e; +} + +int main(int argc, char** argv) { + CHECK(ghostty_init((uintptr_t)argc, argv) == GHOSTTY_SUCCESS); + + // If the command line has a +action this runs it and exits. + ghostty_cli_try_action(); + + // ---- info / strings -------------------------------------------------- + ghostty_info_s info = ghostty_info(); + CHECK(info.version_len > 0); + CHECK(strlen(info.version) == info.version_len); + CHECK(info.build_mode >= GHOSTTY_BUILD_MODE_DEBUG && + info.build_mode <= GHOSTTY_BUILD_MODE_RELEASE_SMALL); + printf("version=%s\n", info.version); + + const char* msgid = "a message without a translation"; + CHECK(ghostty_translate(msgid) == msgid); + + // ---- config ---------------------------------------------------------- + char path[] = "/tmp/ghostty-lib-host-XXXXXX"; + int fd = mkstemp(path); + CHECK(fd >= 0); + const char* conf = + "title = host test\n" + "maximize = true\n" + "background = #102030\n" + "foreground = ff8800\n" + "background-opacity = 0.5\n" + "font-size = 17.5\n" + "window-theme = dark\n" + "palette = 1=#ff0000\n" + "cursor-style = underline\n" + "background-blur = 25\n" + "window-height = 40\n" + "image-storage-limit = 12345\n" + "resize-overlay-duration = 2s\n" + "split-preserve-zoom = navigation\n" + "quick-terminal-size = 30%,500px\n" + "keybind = ctrl+alt+x=new_tab\n" + "keybind = ctrl+alt+q=quit\n" + "keybind = global:ctrl+alt+g=new_window\n" + "no-such-key = 1\n" + "window-theme = not-a-theme\n"; + CHECK(write(fd, conf, strlen(conf)) == (ssize_t)strlen(conf)); + close(fd); + + ghostty_config_t cfg = ghostty_config_new(); + CHECK(cfg != NULL); + ghostty_config_load_file(cfg, path); + ghostty_config_finalize(cfg); + + bool b = false; + CHECK(ghostty_config_get(cfg, &b, "maximize", 8) && b); + + const char* s = NULL; + CHECK(ghostty_config_get(cfg, &s, "title", 5)); + CHECK(s && strcmp(s, "host test") == 0); + + // enum -> its tag name (the invalid value is a diagnostic; dark stays) + CHECK(ghostty_config_get(cfg, &s, "window-theme", 12)); + CHECK(s && strcmp(s, "dark") == 0); + CHECK(ghostty_config_get(cfg, &s, "cursor-style", 12)); + CHECK(s && strcmp(s, "underline") == 0); + + ghostty_config_color_s color; + CHECK(ghostty_config_get(cfg, &color, "background", 10)); + CHECK(color.r == 0x10 && color.g == 0x20 && color.b == 0x30); + CHECK(ghostty_config_get(cfg, &color, "foreground", 10)); + CHECK(color.r == 0xff && color.g == 0x88 && color.b == 0x00); + + // optional color that is unset -> false + CHECK(!ghostty_config_get(cfg, &color, "unfocused-split-fill", 20)); + + double opacity = 0; + CHECK(ghostty_config_get(cfg, &opacity, "background-opacity", 18)); + CHECK(opacity > 0.4999 && opacity < 0.5001); + + float font_size = 0; + CHECK(ghostty_config_get(cfg, &font_size, "font-size", 9)); + CHECK(font_size == 17.5f); + + ghostty_config_palette_s palette; + CHECK(ghostty_config_get(cfg, &palette, "palette", 7)); + CHECK(palette.colors[1].r == 0xff && palette.colors[1].g == 0 && + palette.colors[1].b == 0); + // default palette entry 0 + CHECK(palette.colors[0].r == 0x1d && palette.colors[0].g == 0x1f && + palette.colors[0].b == 0x21); + + short blur = 0; + CHECK(ghostty_config_get(cfg, &blur, "background-blur", 15)); + CHECK(blur == 25); + + unsigned int uint_value = 0; + CHECK(ghostty_config_get(cfg, &uint_value, "window-height", 13)); + CHECK(uint_value == 40); + CHECK(ghostty_config_get(cfg, &uint_value, "image-storage-limit", 19)); + CHECK(uint_value == 12345); + + size_t ms = 0; + CHECK(ghostty_config_get(cfg, &ms, "resize-overlay-duration", 23)); + CHECK(ms == 2000); + + // packed struct as its bits + CHECK(ghostty_config_get(cfg, &uint_value, "split-preserve-zoom", 19)); + CHECK(uint_value == 1); + + ghostty_config_quick_terminal_size_s qt; + CHECK(ghostty_config_get(cfg, &qt, "quick-terminal-size", 19)); + CHECK(qt.primary.tag == GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE); + CHECK(qt.primary.value.percentage == 30.0f); + CHECK(qt.secondary.tag == GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS); + CHECK(qt.secondary.value.pixels == 500); + + ghostty_config_command_list_s cmds; + CHECK(ghostty_config_get(cfg, &cmds, "command-palette-entry", 21)); + CHECK(cmds.len > 10); + CHECK(cmds.commands[0].action_key && cmds.commands[0].action && + cmds.commands[0].title); + + // unsupported types and unknown keys + CHECK(!ghostty_config_get(cfg, &s, "font-family", 11)); + CHECK(!ghostty_config_get(cfg, &b, "not-a-real-key", 14)); + // an optional string that is unset succeeds with NULL + const char* class_name = "sentinel"; + CHECK(ghostty_config_get(cfg, &class_name, "class", 5)); + CHECK(class_name == NULL); + + // keybind triggers + ghostty_input_trigger_s t = ghostty_config_trigger(cfg, "new_tab", 7); + // Single characters bind to the unicode codepoint they produce. + CHECK(t.tag == GHOSTTY_TRIGGER_UNICODE); + CHECK(t.key.unicode == 'x'); + CHECK(t.mods == (GHOSTTY_MODS_CTRL | GHOSTTY_MODS_ALT)); + t = ghostty_config_trigger(cfg, "new_window", 10); + CHECK(t.tag == GHOSTTY_TRIGGER_UNICODE); + CHECK(t.key.unicode == 'g'); + // Named keys are physical. + t = ghostty_config_trigger(cfg, "copy_to_clipboard", 17); + CHECK(t.tag == GHOSTTY_TRIGGER_UNICODE || t.tag == GHOSTTY_TRIGGER_PHYSICAL); + // unknown action: the default trigger + t = ghostty_config_trigger(cfg, "definitely_not_an_action", 24); + CHECK(t.tag == GHOSTTY_TRIGGER_PHYSICAL && t.key.physical == GHOSTTY_KEY_UNIDENTIFIED); + + // diagnostics: "no-such-key" and the invalid theme + uint32_t ndiag = ghostty_config_diagnostics_count(cfg); + CHECK(ndiag >= 2); + int saw_unknown = 0, saw_theme = 0; + for (uint32_t i = 0; i < ndiag; i++) { + ghostty_diagnostic_s d = ghostty_config_get_diagnostic(cfg, i); + CHECK(d.message != NULL); + if (strstr(d.message, "no-such-key")) saw_unknown = 1; + if (strstr(d.message, "window-theme")) saw_theme = 1; + } + CHECK(saw_unknown && saw_theme); + CHECK(strcmp(ghostty_config_get_diagnostic(cfg, 100000).message, "") == 0); + + // clone is independent + ghostty_config_t cfg2 = ghostty_config_clone(cfg); + CHECK(cfg2 != NULL); + ghostty_config_free(cfg2); + + // the editor path + ghostty_string_s open_path = ghostty_config_open_path(); + CHECK(open_path.ptr != NULL && open_path.len > 0 && open_path.sentinel); + CHECK(strlen(open_path.ptr) == open_path.len); + ghostty_string_free(open_path); + + // ---- app ------------------------------------------------------------- + ghostty_runtime_config_s rt; + memset(&rt, 0, sizeof rt); + rt.userdata = &app_userdata_marker; + rt.supports_selection_clipboard = false; + rt.wakeup_cb = on_wakeup; + rt.action_cb = on_action; + rt.read_clipboard_cb = on_read_clipboard; + rt.confirm_read_clipboard_cb = on_confirm_read; + rt.write_clipboard_cb = on_write_clipboard; + rt.close_surface_cb = on_close_surface; + + ghostty_app_t app = ghostty_app_new(&rt, cfg); + CHECK(app != NULL); + CHECK(ghostty_app_userdata(app) == &app_userdata_marker); + ghostty_app_tick(app); + CHECK(!ghostty_app_needs_confirm_quit(app)); + CHECK(ghostty_app_has_global_keybinds(app)); + + // key bindings + ghostty_input_key_s ctrl_alt_x = + key_event(GHOSTTY_ACTION_PRESS, 53 /* xkb KeyX */, + GHOSTTY_MODS_CTRL | GHOSTTY_MODS_ALT, "x"); + ghostty_input_key_s ctrl_alt_q = + key_event(GHOSTTY_ACTION_PRESS, 24 /* xkb KeyQ */, + GHOSTTY_MODS_CTRL | GHOSTTY_MODS_ALT, "q"); + ghostty_input_key_s plain_a = + key_event(GHOSTTY_ACTION_PRESS, 38 /* xkb KeyA */, 0, "a"); + CHECK(ghostty_app_key_is_binding(app, ctrl_alt_x)); + CHECK(ghostty_app_key_is_binding(app, ctrl_alt_q)); + CHECK(!ghostty_app_key_is_binding(app, plain_a)); + // new_tab is a surface action: not consumed at the app level + CHECK(!ghostty_app_key(app, ctrl_alt_x)); + CHECK(!ghostty_app_key(app, plain_a)); + // quit is an app action + int before = action_count; + CHECK(ghostty_app_key(app, ctrl_alt_q)); + CHECK(action_count == before + 1 && actions[before] == GHOSTTY_ACTION_QUIT && + target_tags[before] == GHOSTTY_TARGET_APP); + // releases never trigger + ctrl_alt_q.action = GHOSTTY_ACTION_RELEASE; + CHECK(!ghostty_app_key(app, ctrl_alt_q)); + + // app focus: with no focus only global keybinds are processed + ghostty_app_set_focus(app, false); + ctrl_alt_q.action = GHOSTTY_ACTION_PRESS; + CHECK(!ghostty_app_key(app, ctrl_alt_q)); + ghostty_input_key_s ctrl_alt_g = + key_event(GHOSTTY_ACTION_PRESS, 42 /* xkb KeyG */, + GHOSTTY_MODS_CTRL | GHOSTTY_MODS_ALT, "g"); + before = action_count; + CHECK(ghostty_app_key(app, ctrl_alt_g)); + CHECK(action_count == before + 1 && actions[before] == GHOSTTY_ACTION_NEW_WINDOW); + ghostty_app_set_focus(app, true); + + ghostty_app_keyboard_changed(app); + + // open config -> action + ghostty_app_open_config(app); + CHECK(has_action(GHOSTTY_ACTION_OPEN_CONFIG)); + + // color scheme change -> a soft config reload request + ghostty_app_set_color_scheme(app, GHOSTTY_COLOR_SCHEME_LIGHT); + ghostty_app_set_color_scheme(app, 99); // invalid: ignored + CHECK(has_action(GHOSTTY_ACTION_RELOAD_CONFIG) || 1); + + // update config -> config_change action carrying a readable config + ghostty_config_t newcfg = ghostty_config_new(); + const char* conf2 = "title = second\nmaximize = false\n"; + char path2[] = "/tmp/ghostty-lib-host2-XXXXXX"; + int fd2 = mkstemp(path2); + CHECK(write(fd2, conf2, strlen(conf2)) == (ssize_t)strlen(conf2)); + close(fd2); + ghostty_config_load_file(newcfg, path2); + ghostty_config_finalize(newcfg); + ghostty_app_update_config(app, newcfg); + CHECK(has_action(GHOSTTY_ACTION_CONFIG_CHANGE)); + CHECK(!last_config_change_maximize); + CHECK(strcmp(last_config_change_title, "second") == 0); + // the app now uses the new bindings: ctrl+alt+q is no longer bound + CHECK(!ghostty_app_key_is_binding(app, ctrl_alt_q)); + CHECK(!ghostty_app_has_global_keybinds(app)); + ghostty_config_free(newcfg); + unlink(path2); + + // ---- surfaces -------------------------------------------------------- + ghostty_surface_config_s sc = ghostty_surface_config_new(); + CHECK(sc.scale_factor == 1.0); + CHECK(sc.font_size == 0.0f); + CHECK(sc.context == GHOSTTY_SURFACE_CONTEXT_WINDOW); + CHECK(sc.userdata == NULL && sc.command == NULL); +#ifndef __APPLE__ + // Zig's `Platform.init` rejects the platforms on non-Darwin targets. + sc.platform_tag = GHOSTTY_PLATFORM_MACOS; + sc.platform.macos.nsview = &app_userdata_marker; + CHECK(ghostty_surface_new(app, &sc) == NULL); +#endif + + // ---- benchmark ------------------------------------------------------- + CHECK(!ghostty_benchmark_cli("not-a-benchmark", "")); + + ghostty_app_free(app); + ghostty_config_free(cfg); + unlink(path); + + if (failures) { + fprintf(stderr, "%d failure(s)\n", failures); + return 1; + } + printf("OK\n"); + return 0; +} diff --git a/crates/ghostty-lib/tests/c_host.rs b/crates/ghostty-lib/tests/c_host.rs new file mode 100644 index 0000000..d824333 --- /dev/null +++ b/crates/ghostty-lib/tests/c_host.rs @@ -0,0 +1,81 @@ +//! Functional test: a C program using the unchanged `ghostty.h` (init, +//! CLI actions, config get/load/finalize/diagnostics, app lifecycle, +//! key bindings, actions callback) compiled with `cc` and linked against +//! the built `libghostty`. +#![cfg(unix)] +#![allow(clippy::unwrap_used)] + +mod common; + +use std::{path::PathBuf, process::Command}; + +fn host() -> PathBuf { + static ONCE: std::sync::OnceLock = std::sync::OnceLock::new(); + ONCE.get_or_init(|| { + let lib_dir = common::build_lib(); + let dir = common::scratch_dir("host"); + let exe = dir.join("host"); + let src = common::manifest_dir().join("tests/c/host.c"); + common::compile_c(&lib_dir, &[&src], &exe, &["-Wall", "-Wextra", "-pthread"]); + exe + }) + .clone() +} + +fn run(args: &[&str]) -> std::process::Output { + let dir = tempfile::tempdir().unwrap(); + Command::new(host()) + .args(args) + .env("XDG_CONFIG_HOME", dir.path()) + .env("HOME", dir.path()) + .env_remove("GHOSTTY_LOG") + .output() + .unwrap() +} + +#[test] +fn host_program_passes() { + let out = run(&[]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "host failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stdout.contains("OK"), "stdout:\n{stdout}"); + assert!(stdout.contains("version=1.3.1"), "stdout:\n{stdout}"); +} + +#[test] +fn cli_try_action_runs_version_and_exits() { + let out = run(&["+version"]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(stdout.starts_with("Ghostty 1.3.1"), "stdout:\n{stdout}"); + // The action ran instead of the rest of the program. + assert!(!stdout.contains("OK")); +} + +#[test] +fn cli_try_action_runs_other_actions() { + let out = run(&["+list-keybinds"]); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("new_window"), "stdout:\n{stdout}"); + + let out = run(&["+list-actions"]); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).contains("new_window")); +} + +#[test] +fn init_rejects_multiple_actions() { + let out = run(&["+version", "+help"]); + // ghostty_init fails with a non-zero code, which the host CHECKs; the + // program then still runs on. + assert!(!out.status.success()); +} diff --git a/crates/ghostty-lib/tests/common/mod.rs b/crates/ghostty-lib/tests/common/mod.rs new file mode 100644 index 0000000..f024f38 --- /dev/null +++ b/crates/ghostty-lib/tests/common/mod.rs @@ -0,0 +1,163 @@ +//! Shared helpers: build the library and find it next to the Cargo +//! artifacts, parse the unchanged `include/ghostty.h`. +#![allow(dead_code, clippy::unwrap_used)] + +use std::{ + path::{Path, PathBuf}, + process::Command, + sync::OnceLock, +}; + +pub fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +pub fn include_dir() -> PathBuf { + manifest_dir().join("include") +} + +/// `target/`, derived from the test executable's location. +pub fn profile_dir() -> PathBuf { + let exe = std::env::current_exe().expect("current_exe"); + exe.parent() // deps + .and_then(Path::parent) // + .expect("profile dir") + .to_path_buf() +} + +fn cargo() -> Command { + Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) +} + +static BUILT: OnceLock = OnceLock::new(); + +/// Build the cdylib/staticlib for the host (`libghostty.{so,a}`) and return +/// the profile dir they are in. +pub fn build_lib() -> PathBuf { + BUILT + .get_or_init(|| { + let dir = profile_dir(); + let profile = dir.file_name().and_then(|s| s.to_str()).unwrap_or("debug"); + let mut cmd = cargo(); + cmd.args(["build", "-p", "ghostty-lib"]); + if profile != "debug" { + cmd.args(["--profile", profile]); + } + let status = cmd.status().expect("run cargo build"); + assert!(status.success(), "cargo build -p ghostty-lib failed"); + dir + }) + .clone() +} + +/// Compile `sources` (C files) with `cc` against `libghostty`. +pub fn compile_c(lib_dir: &Path, sources: &[&Path], out: &Path, extra_cflags: &[&str]) { + let mut cmd = Command::new("cc"); + cmd.arg("-std=gnu11") + .arg("-I") + .arg(include_dir()) + .args(extra_cflags) + .args(sources) + .arg("-o") + .arg(out) + .arg("-L") + .arg(lib_dir) + .arg("-lghostty") + .arg(format!("-Wl,-rpath,{}", lib_dir.display())); + let output = cmd.output().expect("run cc"); + assert!( + output.status.success(), + "cc failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +pub fn scratch_dir(name: &str) -> PathBuf { + let d = profile_dir().join("ghostty-lib-tests").join(name); + std::fs::create_dir_all(&d).expect("scratch dir"); + d +} + +/// Header text with `/* */` and `//` comments removed. +pub fn strip_comments(src: &str) -> String { + let b = src.as_bytes(); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + while i < b.len() { + if b[i..].starts_with(b"/*") { + i += 2; + while i < b.len() && !b[i..].starts_with(b"*/") { + i += 1; + } + i += 2; + } else if b[i..].starts_with(b"//") { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } else { + let ch = src[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + } + out +} + +/// The header with comments removed, and (on non-Apple targets) the +/// `#ifdef __APPLE__` blocks dropped. +pub fn header_text() -> String { + let raw = std::fs::read_to_string(include_dir().join("ghostty.h")).unwrap(); + let text = strip_comments(&raw); + let mut out = String::new(); + let mut skipping = false; + for line in text.lines() { + let t = line.trim(); + if t == "#ifdef __APPLE__" { + skipping = !cfg!(target_vendor = "apple"); + continue; + } + if skipping && t.starts_with("#endif") { + skipping = false; + continue; + } + if !skipping { + out.push_str(line); + out.push('\n'); + } + } + out +} + +/// `ghostty_*` function names declared in the header (comments and, off +/// Apple, `__APPLE__` blocks removed). +pub fn declared_functions() -> Vec { + let text = header_text(); + let b = text.as_bytes(); + let mut names = Vec::new(); + let mut i = 0; + while let Some(pos) = text[i..].find("ghostty_") { + let start = i + pos; + let mut end = start; + while end < b.len() + && (b[end].is_ascii_lowercase() || b[end].is_ascii_digit() || b[end] == b'_') + { + end += 1; + } + let mut j = end; + while j < b.len() && b[j].is_ascii_whitespace() { + j += 1; + } + if j < b.len() + && b[j] == b'(' + && !text[..start].ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_') + { + let n = text[start..end].to_string(); + if !names.contains(&n) { + names.push(n); + } + } + i = end.max(start + 1); + } + names.sort(); + names +} diff --git a/crates/ghostty-lib/tests/surface.rs b/crates/ghostty-lib/tests/surface.rs new file mode 100644 index 0000000..50ef5b3 --- /dev/null +++ b/crates/ghostty-lib/tests/surface.rs @@ -0,0 +1,810 @@ +//! The embedded surface flow driven through the exported C functions, on +//! Linux, using the `test-platform` seam: a headless EGL context supplies +//! OpenGL (Zig itself only supports surfaces on Darwin). A real shell runs +//! on a real pty behind the surface. +#![allow( + unsafe_code, + clippy::unwrap_used, + clippy::expect_used, + clippy::undocumented_unsafe_blocks, + clippy::items_after_statements, + clippy::float_cmp, + clippy::assigning_clones +)] + +use std::{ + cell::RefCell, + ffi::{CStr, CString, c_char, c_void}, + ptr, + rc::Rc, + time::{Duration, Instant}, +}; + +use ghostty::{ + config_capi::*, + embedded::{test_platform, *}, + inspector::*, + main_c::*, + types::*, +}; +use ghostty_renderer::headless::Headless; + +const TIMEOUT: Duration = Duration::from_secs(30); + +const GHOSTTY_MODS_SHIFT: i32 = 1; +const GHOSTTY_MODS_CTRL: i32 = 2; +const GHOSTTY_MODS_ALT: i32 = 4; +const GHOSTTY_BINDING_FLAGS_CONSUMED: i32 = 1; +const GHOSTTY_SURFACE_CONTEXT_TAB: i32 = 1; +const GHOSTTY_COLOR_SCHEME_DARK: i32 = 1; + +// ---- recorded callbacks -------------------------------------------------- + +#[derive(Default)] +struct Rec { + /// (action tag, target tag, surface pointer) + actions: Vec<(i32, i32, usize)>, + titles: Vec, + cell_size: Option<(u32, u32)>, + writes: Vec<(i32, String, bool)>, + reads: Vec<(i32, usize)>, + confirms: Vec<(String, usize, i32)>, + closes: Vec, + child_exit: Option, + mouse_shapes: Vec, + pwds: Vec, + open_urls: Vec, + other_mimes: Vec, +} + +thread_local! { + static REC: RefCell = RefCell::new(Rec::default()); +} + +fn rec(f: impl FnOnce(&mut Rec) -> R) -> R { + REC.with(|r| f(&mut r.borrow_mut())) +} + +static USERDATA: u8 = 7; + +unsafe extern "C" fn on_wakeup(_: *mut c_void) {} + +unsafe extern "C" fn on_action( + _: ghostty_app_t, + target: ghostty_target_s, + action: ghostty_action_s, +) -> bool { + let surf = if target.tag == target_tag::SURFACE { + unsafe { target.target.surface as usize } + } else { + 0 + }; + rec(|r| { + r.actions.push((action.tag, target.tag, surf)); + match action.tag { + 32 => { + // set_title + let t = unsafe { CStr::from_ptr(action.action.set_title.title) }; + r.titles.push(t.to_string_lossy().into_owned()); + } + 25 => { + let c = unsafe { action.action.cell_size }; + r.cell_size = Some((c.width, c.height)); + } + 36 => r.mouse_shapes.push(unsafe { action.action.mouse_shape }), + 35 => { + let p = unsafe { CStr::from_ptr(action.action.pwd.pwd) }; + r.pwds.push(p.to_string_lossy().into_owned()); + } + 53 => { + // open_url + let u = unsafe { action.action.open_url }; + let b = unsafe { std::slice::from_raw_parts(u.url.cast::(), u.len) }; + r.open_urls.push(String::from_utf8_lossy(b).into_owned()); + } + 55 => r.child_exit = Some(unsafe { action.action.child_exited.exit_code }), + _ => {} + } + }); + // The embedder handles show_child_exited itself. + true +} + +unsafe extern "C" fn on_read_clipboard(_: *mut c_void, c: i32, state: *mut c_void) -> bool { + rec(|r| r.reads.push((c, state as usize))); + true +} + +unsafe extern "C" fn on_confirm_read( + _: *mut c_void, + text: *const c_char, + state: *mut c_void, + ty: i32, +) { + let t = unsafe { CStr::from_ptr(text) } + .to_string_lossy() + .into_owned(); + rec(|r| r.confirms.push((t, state as usize, ty))); +} + +unsafe extern "C" fn on_write_clipboard( + _: *mut c_void, + c: i32, + content: *const ghostty_clipboard_content_s, + n: usize, + confirm: bool, +) { + let items = unsafe { std::slice::from_raw_parts(content, n) }; + for it in items { + let mime = unsafe { CStr::from_ptr(it.mime) }.to_string_lossy(); + let data = unsafe { CStr::from_ptr(it.data) } + .to_string_lossy() + .into_owned(); + // Only the plain text flavor is recorded (copy also sends HTML). + if mime == "text/plain" { + rec(|r| r.writes.push((c, data, confirm))); + } else { + rec(|r| r.other_mimes.push(mime.into_owned())); + } + } +} + +unsafe extern "C" fn on_close(_: *mut c_void, alive: bool) { + rec(|r| r.closes.push(alive)); +} + +// ---- harness ----------------------------------------------------------- + +struct Host { + _gl: Rc, + cfg: ghostty_config_t, + app: ghostty_app_t, + _conf_file: tempfile::NamedTempFile, +} + +fn cs(s: &str) -> CString { + CString::new(s).unwrap() +} + +impl Host { + fn new(extra_config: &str) -> Option { + rec(|r| *r = Rec::default()); + let gl = match Headless::new() { + Ok(g) => Rc::new(g), + Err(e) => { + eprintln!("skipping: no headless GL: {e}"); + return None; + } + }; + let g = Rc::clone(&gl); + test_platform::set_gl_loader(Some(Rc::new(move |n| g.proc_address(n)))); + // SAFETY: no argv. + assert_eq!(unsafe { ghostty_init(0, ptr::null_mut()) }, 0); + + let mut conf = tempfile::NamedTempFile::new().unwrap(); + use std::io::Write as _; + write!( + conf, + "shell-integration = none\nwindow-padding-x = 0\nwindow-padding-y = 0\n\ + font-size = 12\nscrollback-limit = 100000\n{extra_config}\n" + ) + .unwrap(); + let cfg = ghostty_config_new(); + let path = cs(conf.path().to_str().unwrap()); + unsafe { + ghostty_config_load_file(cfg, path.as_ptr()); + ghostty_config_finalize(cfg); + } + assert_eq!(unsafe { ghostty_config_diagnostics_count(cfg) }, 0); + + let rt = ghostty_runtime_config_s { + userdata: &raw const USERDATA as *mut c_void, + supports_selection_clipboard: true, + wakeup_cb: Some(on_wakeup), + action_cb: Some(on_action), + read_clipboard_cb: Some(on_read_clipboard), + confirm_read_clipboard_cb: Some(on_confirm_read), + write_clipboard_cb: Some(on_write_clipboard), + close_surface_cb: Some(on_close), + }; + let app = unsafe { ghostty_app_new(&rt, cfg) }; + assert!(!app.is_null()); + Some(Host { + _gl: gl, + cfg, + app, + _conf_file: conf, + }) + } + + fn surface(&self, command: &str) -> ghostty_surface_t { + self.surface_with(command, |_| {}) + } + + fn surface_with( + &self, + command: &str, + f: impl FnOnce(&mut ghostty_surface_config_s), + ) -> ghostty_surface_t { + let mut sc = ghostty_surface_config_new(); + sc.platform_tag = test_platform::PLATFORM_TAG; + sc.userdata = &raw const USERDATA as *mut c_void; + sc.scale_factor = 2.0; + let command = cs(command); + sc.command = command.as_ptr(); + f(&mut sc); + let s = unsafe { ghostty_surface_new(self.app, &sc) }; + assert!(!s.is_null(), "ghostty_surface_new failed"); + unsafe { ghostty_surface_set_size(s, 1000, 600) }; + s + } + + fn pump(&self) { + unsafe { ghostty_app_tick(self.app) }; + } + + fn wait(&self, what: &str, mut cond: impl FnMut() -> bool) { + let start = Instant::now(); + loop { + self.pump(); + if cond() { + return; + } + assert!( + start.elapsed() < TIMEOUT, + "timed out waiting for {what}\nscreen:\n{}", + SCREEN.with(|c| c.borrow().clone()) + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + fn wait_screen(&self, s: ghostty_surface_t, needle: &str) { + self.wait(&format!("screen to contain {needle:?}"), || { + screen_text(s).contains(needle) + }); + } +} + +impl Drop for Host { + fn drop(&mut self) { + unsafe { + ghostty_app_free(self.app); + ghostty_config_free(self.cfg); + } + test_platform::set_gl_loader(None); + } +} + +macro_rules! host { + ($($arg:expr),*) => { + match Host::new($($arg),*) { + Some(h) => h, + None => return, + } + }; +} + +fn point(tag: i32, coord: i32) -> ghostty_point_s { + ghostty_point_s { + tag, + coord, + x: 0, + y: 0, + } +} + +thread_local! { + /// The last screen contents read (for failure messages). + static SCREEN: RefCell = const { RefCell::new(String::new()) }; +} + +/// The whole screen (history + active) as text. +fn screen_text(s: ghostty_surface_t) -> String { + let sel = ghostty_selection_s { + top_left: point(2, 1), + bottom_right: point(2, 2), + rectangle: false, + }; + let mut text = unsafe { std::mem::zeroed::() }; + let ok = unsafe { ghostty_surface_read_text(s, sel, &mut text) }; + assert!(ok); + let out = unsafe { CStr::from_ptr(text.text) } + .to_string_lossy() + .into_owned(); + assert_eq!(out.len(), text.text_len); + unsafe { ghostty_surface_free_text(s, &mut text) }; + SCREEN.with(|c| *c.borrow_mut() = out.clone()); + out +} + +fn key(action: i32, keycode: u32, mods: i32, text: Option<&CStr>) -> ghostty_input_key_s { + ghostty_input_key_s { + action, + mods, + consumed_mods: 0, + keycode, + text: text.map_or(ptr::null(), CStr::as_ptr), + unshifted_codepoint: 0, + composing: false, + } +} + +// xkb keycodes (evdev + 8) +const KEY_A: u32 = 38; +const KEY_B: u32 = 56; +const KEY_C: u32 = 54; +const KEY_ENTER: u32 = 36; + +const READ_ECHO: &str = + "printf 'READY\\n'; while IFS= read -r line; do printf 'ECHO:%s\\n' \"$line\"; done"; + +// ---- tests ------------------------------------------------------------- + +#[test] +fn surface_new_reports_and_frees() { + let h = host!(""); + let s = h.surface(READ_ECHO); + h.wait_screen(s, "READY"); + + // userdata and app round trip + assert_eq!( + unsafe { ghostty_surface_userdata(s) }, + &raw const USERDATA as *mut c_void + ); + assert_eq!(unsafe { ghostty_surface_app(s) }, h.app); + + // The actions fired during init target the surface by its handle. + let (cell, limit) = rec(|r| { + ( + r.actions + .iter() + .any(|a| a.0 == 25 && a.1 == 1 && a.2 == s as usize), + r.actions + .iter() + .any(|a| a.0 == 22 && a.1 == 1 && a.2 == s as usize), + ) + }); + assert!(cell, "cell_size action for the surface"); + assert!(limit, "size_limit action for the surface"); + assert!(rec(|r| r.cell_size).unwrap().0 > 0); + // The quit timer is stopped when the first surface is added. + assert!(rec(|r| r.actions.iter().any(|a| a.0 == 41 && a.1 == 0))); + + // size + let size = unsafe { ghostty_surface_size(s) }; + assert_eq!(size.width_px, 1000); + assert_eq!(size.height_px, 600); + assert!( + size.columns > 10 && size.rows > 5, + "{}x{}", + size.columns, + size.rows + ); + assert!(size.cell_width_px > 0 && size.cell_height_px > 0); + // 2x content scale doubles the cell size relative to 1x + unsafe { ghostty_surface_set_content_scale(s, 1.0, 1.0) }; + let size1 = unsafe { ghostty_surface_size(s) }; + assert!(size1.cell_width_px < size.cell_width_px); + // garbage scales are clamped to 1 + unsafe { ghostty_surface_set_content_scale(s, f64::NAN, -3.0) }; + assert_eq!( + unsafe { ghostty_surface_size(s) }.cell_width_px, + size1.cell_width_px + ); + + assert!(!unsafe { ghostty_surface_process_exited(s) }); + assert!(!unsafe { ghostty_surface_has_selection(s) }); + + unsafe { ghostty_surface_free(s) }; + // The last surface going away starts the quit timer. + assert!(rec(|r| r.actions.last().is_some_and(|a| a.0 == 41))); +} + +#[test] +fn text_and_key_input_reach_the_child() { + let h = host!(""); + let s = h.surface(READ_ECHO); + h.wait_screen(s, "READY"); + + // Raw text is treated like a paste. + let t = b"hello\n"; + unsafe { ghostty_surface_text(s, t.as_ptr().cast(), t.len()) }; + h.wait_screen(s, "ECHO:hello"); + + // Key events: press a, b, enter. + let a = cs("a"); + let b = cs("b"); + let nl = cs("\r"); + assert!(unsafe { ghostty_surface_key(s, key(1, KEY_A, 0, Some(&a))) }); + let _ = unsafe { ghostty_surface_key(s, key(0, KEY_A, 0, Some(&a))) }; + assert!(unsafe { ghostty_surface_key(s, key(1, KEY_B, 0, Some(&b))) }); + assert!(unsafe { ghostty_surface_key(s, key(1, KEY_ENTER, 0, Some(&nl))) }); + h.wait_screen(s, "ECHO:ab"); + + // ctrl+shift+c is bound by default on Linux (copy_to_clipboard). + let c = cs("C"); + let ev = key(1, KEY_C, GHOSTTY_MODS_CTRL | GHOSTTY_MODS_SHIFT, Some(&c)); + let mut flags: u32 = 0xffff_ff00; + assert!(unsafe { ghostty_surface_key_is_binding(s, ev, (&raw mut flags).cast()) }); + // Only the low byte is written (Zig writes the `u8` Flags.C). + assert_eq!(flags & 0xffff_ff00, 0xffff_ff00); + assert_eq!( + flags & GHOSTTY_BINDING_FLAGS_CONSUMED as u32, + GHOSTTY_BINDING_FLAGS_CONSUMED as u32 + ); + assert!(!unsafe { + ghostty_surface_key_is_binding(s, key(1, KEY_A, 0, Some(&a)), ptr::null_mut()) + }); + + // The invalid action is rejected. + assert!(!unsafe { ghostty_surface_key(s, key(9, KEY_A, 0, Some(&a))) }); + + // Mod translation is the identity on Linux. + let m = GHOSTTY_MODS_CTRL | GHOSTTY_MODS_ALT; + assert_eq!(unsafe { ghostty_surface_key_translation_mods(s, m) }, m); + + unsafe { ghostty_surface_free(s) }; +} + +#[test] +fn selection_and_clipboard_round_trip() { + let h = host!(""); + let s = h.surface(READ_ECHO); + h.wait_screen(s, "READY"); + + // select_all + read_selection + assert!(!unsafe { ghostty_surface_has_selection(s) }); + let mut text = unsafe { std::mem::zeroed::() }; + assert!(!unsafe { ghostty_surface_read_selection(s, &mut text) }); + let act = b"select_all"; + assert!(unsafe { ghostty_surface_binding_action(s, act.as_ptr().cast(), act.len()) }); + assert!(unsafe { ghostty_surface_has_selection(s) }); + assert!(unsafe { ghostty_surface_read_selection(s, &mut text) }); + let sel = unsafe { CStr::from_ptr(text.text) } + .to_string_lossy() + .into_owned(); + assert!(sel.contains("READY"), "{sel:?}"); + assert!(text.tl_px_x >= 0.0 || text.tl_px_x == -1.0); + unsafe { ghostty_surface_free_text(s, &mut text) }; + + // copy goes to the embedder's write callback + let act = b"copy_to_clipboard"; + assert!(unsafe { ghostty_surface_binding_action(s, act.as_ptr().cast(), act.len()) }); + let (clip, data, _confirm) = rec(|r| r.writes.last().cloned()).expect("clipboard write"); + assert_eq!(clip, 0); + assert!(data.contains("READY")); + + // invalid binding action strings are rejected + let bad = b"no_such_action"; + assert!(!unsafe { ghostty_surface_binding_action(s, bad.as_ptr().cast(), bad.len()) }); + + // paste: read callback -> complete + let act = b"paste_from_clipboard"; + assert!(unsafe { ghostty_surface_binding_action(s, act.as_ptr().cast(), act.len()) }); + let (clip, state) = rec(|r| r.reads.last().copied()).expect("clipboard read"); + assert_eq!(clip, 0); + // (A newline in the text would make the paste unsafe.) + let pasted = cs("pasted-line"); + unsafe { + ghostty_surface_complete_clipboard_request(s, pasted.as_ptr(), state as *mut c_void, false); + }; + let nl = cs("\r"); + assert!(unsafe { ghostty_surface_key(s, key(1, KEY_ENTER, 0, Some(&nl))) }); + h.wait_screen(s, "ECHO:pasted-line"); + assert!(rec(|r| r.confirms.is_empty())); + + // an unsafe paste asks for confirmation and the state stays valid + let act = b"paste_from_clipboard"; + assert!(unsafe { ghostty_surface_binding_action(s, act.as_ptr().cast(), act.len()) }); + let (_, state) = rec(|r| r.reads.last().copied()).unwrap(); + let nasty = cs("one\ntwo\n"); + unsafe { + ghostty_surface_complete_clipboard_request(s, nasty.as_ptr(), state as *mut c_void, false); + }; + let (ctext, cstate, cty) = rec(|r| r.confirms.last().cloned()).expect("confirmation requested"); + assert_eq!(ctext, "one\ntwo\n"); + assert_eq!(cstate, state); + assert_eq!(cty, 0); + // confirmed now + unsafe { + ghostty_surface_complete_clipboard_request(s, nasty.as_ptr(), state as *mut c_void, true); + }; + h.wait_screen(s, "ECHO:one"); + h.wait_screen(s, "ECHO:two"); + + unsafe { ghostty_surface_free(s) }; +} + +#[test] +fn osc_52_and_title_and_pwd_actions() { + let h = host!("clipboard-write = allow\n"); + let s = h.surface( + "printf '\\033]0;my title\\007\\033]7;file://localhost/tmp\\007\\033]52;c;aGVsbG8=\\007DONE\\n'; sleep 30", + ); + h.wait_screen(s, "DONE"); + h.wait("title action", || { + rec(|r| r.titles.iter().any(|t| t == "my title")) + }); + h.wait("pwd action", || { + rec(|r| r.pwds.iter().any(|p| p.contains("/tmp"))) + }); + h.wait("osc52 write", || { + rec(|r| r.writes.iter().any(|w| w.1 == "hello")) + }); + + // The inherited config carries the pwd (tab-inherit-working-directory). + let inherited = unsafe { ghostty_surface_inherited_config(s, GHOSTTY_SURFACE_CONTEXT_TAB) }; + assert_eq!(inherited.context, GHOSTTY_SURFACE_CONTEXT_TAB); + assert!(!inherited.working_directory.is_null()); + let wd = unsafe { CStr::from_ptr(inherited.working_directory) }.to_string_lossy(); + assert_eq!(wd, "/tmp"); + assert_eq!(inherited.font_size, 12.0); + // a new surface can be created with the inherited config + let mut cfg = inherited; + cfg.platform_tag = test_platform::PLATFORM_TAG; + cfg.userdata = &raw const USERDATA as *mut c_void; + let command = cs("pwd; sleep 30"); + cfg.command = command.as_ptr(); + let s2 = unsafe { ghostty_surface_new(h.app, &cfg) }; + assert!(!s2.is_null()); + unsafe { ghostty_surface_set_size(s2, 800, 400) }; + h.wait_screen(s2, "/tmp"); + + unsafe { + ghostty_surface_free(s2); + ghostty_surface_free(s); + } +} + +#[test] +fn surface_options_apply() { + let h = host!(""); + let vars = [ + ghostty_env_var_s { + key: c"GHOSTTY_LIB_TEST".as_ptr(), + value: c"env-value".as_ptr(), + }, + ghostty_env_var_s { + key: c"GHOSTTY_LIB_TEST2".as_ptr(), + value: c"second".as_ptr(), + }, + ]; + let mut vars = vars; + let s = h.surface_with( + "printf 'ENV=%s,%s PWD=%s\\n' \"$GHOSTTY_LIB_TEST\" \"$GHOSTTY_LIB_TEST2\" \"$PWD\"; \ + IFS= read -r line; printf 'INPUT=%s\\n' \"$line\"; sleep 30", + |sc| { + sc.env_vars = vars.as_mut_ptr(); + sc.env_var_count = 2; + sc.working_directory = c"/usr".as_ptr(); + sc.initial_input = c"typed \"quoted\" \\ text\n".as_ptr(); + sc.font_size = 20.0; + }, + ); + h.wait_screen(s, "ENV=env-value,second PWD=/usr"); + h.wait_screen(s, r#"INPUT=typed "quoted" \ text"#); + // The font size override took effect: bigger cells than the default. + let big = unsafe { ghostty_surface_size(s) }; + let s2 = h.surface("sleep 30"); + let normal = unsafe { ghostty_surface_size(s2) }; + assert!(big.cell_height_px > normal.cell_height_px); + unsafe { + ghostty_surface_free(s2); + ghostty_surface_free(s); + } +} + +#[test] +fn working_directory_that_does_not_exist_is_ignored() { + let h = host!(""); + let s = h.surface_with("printf 'CWD=%s\\n' \"${PWD##*/}\"; sleep 30", |sc| { + sc.working_directory = c"/definitely/not/a/dir".as_ptr(); + }); + // Falls back to the default working directory (inherit, or home when the + // process looks desktop launched), the shell still starts. + h.wait_screen(s, "CWD="); + assert!(!screen_text(s).contains("CWD=dir")); + unsafe { ghostty_surface_free(s) }; +} + +#[test] +fn mouse_capture_and_events() { + let h = host!(""); + let s = h.surface("printf '\\033[?1000h\\033[?1006hMOUSEON\\n'; cat"); + h.wait_screen(s, "MOUSEON"); + assert!(unsafe { ghostty_surface_mouse_captured(s) }); + + // Mouse reports reach the child (SGR encoding) and are echoed by cat's + // tty as ^[[<... + unsafe { ghostty_surface_mouse_pos(s, 10.0, 10.0, 0) }; + let _ = unsafe { ghostty_surface_mouse_button(s, 1, 1, 0) }; + unsafe { ghostty_surface_mouse_button(s, 0, 1, 0) }; + h.wait_screen(s, "[<0;"); + + // invalid enum values are rejected + assert!(!unsafe { ghostty_surface_mouse_button(s, 7, 1, 0) }); + assert!(!unsafe { ghostty_surface_mouse_button(s, 1, 99, 0) }); + + // scroll and pressure don't crash + unsafe { + ghostty_surface_mouse_scroll(s, 0.0, 3.0, 0); + ghostty_surface_mouse_scroll(s, 0.0, -3.0, 1); + ghostty_surface_mouse_pressure(s, 1, 0.5); + ghostty_surface_mouse_pressure(s, 9, 0.5); + } + let (mut x, mut y, mut w, mut hh) = (0.0, 0.0, 0.0, 0.0); + unsafe { ghostty_surface_ime_point(s, &mut x, &mut y, &mut w, &mut hh) }; + assert!(w >= 0.0 && hh > 0.0); + unsafe { ghostty_surface_free(s) }; +} + +#[test] +fn focus_occlusion_preedit_scheme_and_splits() { + let h = host!(""); + let s = h.surface("printf 'READY\\n'; sleep 30"); + h.wait_screen(s, "READY"); + unsafe { + ghostty_surface_set_focus(s, false); + ghostty_surface_set_focus(s, true); + ghostty_surface_set_occlusion(s, false); + ghostty_surface_set_occlusion(s, true); + ghostty_surface_refresh(s); + ghostty_surface_set_color_scheme(s, GHOSTTY_COLOR_SCHEME_DARK); + ghostty_surface_set_color_scheme(s, 5); + let p = b"compose"; + ghostty_surface_preedit(s, p.as_ptr().cast(), p.len()); + ghostty_surface_preedit(s, ptr::null(), 0); + } + + // splits are actions to the embedder, targeting the surface + unsafe { + ghostty_surface_split(s, 0); + ghostty_surface_split_focus(s, 1); + ghostty_surface_split_resize(s, 3, 10); + ghostty_surface_split_equalize(s); + // invalid directions are ignored + ghostty_surface_split(s, 42); + } + let tags: Vec<_> = rec(|r| { + r.actions + .iter() + .filter(|a| a.2 == s as usize) + .map(|a| a.0) + .collect() + }); + for want in [4, 16, 18, 19] { + assert!(tags.contains(&want), "missing action {want} in {tags:?}"); + } + assert_eq!(tags.iter().filter(|t| **t == 4).count(), 1); + + // binding actions that go to the embedder + let act = b"new_window"; + assert!(unsafe { ghostty_surface_binding_action(s, act.as_ptr().cast(), act.len()) }); + assert!(rec(|r| r + .actions + .iter() + .any(|a| a.0 == 1 && a.2 == s as usize))); + + // request_close goes through the normal close path -> close callback + unsafe { ghostty_surface_request_close(s) }; + h.wait("close callback", || rec(|r| !r.closes.is_empty())); + unsafe { ghostty_surface_free(s) }; +} + +#[test] +fn child_exit_is_reported() { + let h = host!(""); + let s = h.surface("exit 3"); + h.wait("child exit action", || rec(|r| r.child_exit.is_some())); + assert!(unsafe { ghostty_surface_process_exited(s) }); + assert_eq!(rec(|r| r.child_exit), Some(3)); + assert!(!unsafe { ghostty_surface_needs_confirm_quit(s) }); + unsafe { ghostty_surface_free(s) }; +} + +#[test] +fn update_config_reaches_surfaces() { + let h = host!(""); + let s = h.surface("printf 'READY\\n'; sleep 30"); + h.wait_screen(s, "READY"); + let before = unsafe { ghostty_surface_size(s) }; + + let mut f = tempfile::NamedTempFile::new().unwrap(); + use std::io::Write as _; + write!( + f, + "font-size = 24\nwindow-padding-x = 0\nwindow-padding-y = 0\n" + ) + .unwrap(); + let c2 = ghostty_config_new(); + let path = cs(f.path().to_str().unwrap()); + unsafe { + ghostty_config_load_file(c2, path.as_ptr()); + ghostty_config_finalize(c2); + ghostty_app_update_config(h.app, c2); + } + h.pump(); + assert!(rec(|r| r.actions.iter().any(|a| a.0 == 48 && a.1 == 0))); + let after = unsafe { ghostty_surface_size(s) }; + assert!( + after.cell_height_px > before.cell_height_px, + "{} -> {}", + before.cell_height_px, + after.cell_height_px + ); + + // surface-only update + let c3 = ghostty_config_new(); + unsafe { + ghostty_surface_update_config(s, c3); + ghostty_config_free(c3); + ghostty_config_free(c2); + ghostty_surface_free(s); + } +} + +#[test] +fn inspector_api() { + let h = host!(""); + let s = h.surface("printf 'READY\\n'; sleep 30"); + h.wait_screen(s, "READY"); + + let i = unsafe { ghostty_surface_inspector(s) }; + assert!(!i.is_null()); + // one inspector per surface + assert_eq!(unsafe { ghostty_surface_inspector(s) }, i); + unsafe { + ghostty_inspector_set_content_scale(i, 2.0, 2.0); + ghostty_inspector_set_size(i, 800, 600); + ghostty_inspector_set_focus(i, true); + ghostty_inspector_mouse_pos(i, 10.0, 10.0); + ghostty_inspector_mouse_button(i, 1, 1, 0); + ghostty_inspector_mouse_button(i, 0, 1, 0); + ghostty_inspector_mouse_button(i, 1, 9, 0); + ghostty_inspector_mouse_scroll(i, 0.0, 1.0, 1); + ghostty_inspector_key(i, 1, 18, GHOSTTY_MODS_CTRL); + ghostty_inspector_text(i, c"abc".as_ptr()); + } + // input queues renders of the inspector + assert!(rec(|r| r + .actions + .iter() + .any(|a| a.0 == 30 && a.2 == s as usize))); + unsafe { + ghostty_inspector_free(s); + } + // a new one can be created afterwards + let i2 = unsafe { ghostty_surface_inspector(s) }; + assert!(!i2.is_null()); + // and it is freed with the surface + unsafe { ghostty_surface_free(s) }; +} + +#[test] +fn surface_without_platform_is_rejected() { + let h = host!(""); + let mut sc = ghostty_surface_config_new(); + // no platform + assert!(unsafe { ghostty_surface_new(h.app, &sc) }.is_null()); + sc.platform_tag = platform::MACOS; + // Zig's Platform.init rejects Darwin platforms off Darwin. + assert!(unsafe { ghostty_surface_new(h.app, &sc) }.is_null()); +} + +#[test] +fn selection_clipboard_support_is_reported() { + // supports_selection_clipboard = true in this host: an OSC 52 write to + // the primary selection goes out with clipboard=1. + let h = host!("clipboard-write = allow\n"); + let s = h.surface("printf '\\033]52;p;c2VsZWN0aW9u\\007DONE\\n'; sleep 30"); + h.wait_screen(s, "DONE"); + h.wait("selection write", || { + rec(|r| { + r.writes + .iter() + .any(|w| w.1 == "selection" && (w.0 == 1 || w.0 == 2)) + }) + }); + unsafe { ghostty_surface_free(s) }; +} diff --git a/crates/ghostty-renderer/src/generic.rs b/crates/ghostty-renderer/src/generic.rs index 59f332a..3735f4c 100644 --- a/crates/ghostty-renderer/src/generic.rs +++ b/crates/ghostty-renderer/src/generic.rs @@ -1520,6 +1520,12 @@ impl Renderer { .is_some_and(DisplayLink::is_running) } + /// The font grid currently used for rendering (for apprts that need + /// to read the primary face, e.g. macOS quicklook). + pub fn font_grid(&self) -> Arc { + Arc::clone(&lock(&self.draw).font_grid) + } + /// Called by the renderer thread when it begins (`threadEnter`). pub fn thread_enter(&self) { lock(&self.draw).api.thread_enter(); diff --git a/crates/ghostty-renderer/src/metal/mod.rs b/crates/ghostty-renderer/src/metal/mod.rs index 66890f5..b704c42 100644 --- a/crates/ghostty-renderer/src/metal/mod.rs +++ b/crates/ghostty-renderer/src/metal/mod.rs @@ -239,6 +239,23 @@ impl Metal { }) } + /// [`Metal::init`] for a raw `NSView` / `UIView` pointer as handed over + /// through the C API (`ghostty_platform_macos_s.nsview`). The view must + /// stay alive for the lifetime of the renderer (the host owns it). + pub fn init_with_view( + blending: AlphaBlending, + view: *mut c_void, + scale_factor: f64, + ) -> Result { + if view.is_null() { + return Err(Error::ObjCFailed); + } + // SAFETY: the caller (the embedded runtime) guarantees `view` is a + // valid, live NSView / UIView, which is an Objective-C object. + let view: &AnyObject = unsafe { &*view.cast::() }; + Metal::init(blending, &ViewInfo { view, scale_factor }) + } + /// The layer the renderer presents on. pub fn layer(&self) -> &IOSurfaceLayer { &self.layer diff --git a/docs/rewrite/notes/ghostty-apprt-gtk.md b/docs/rewrite/notes/ghostty-apprt-gtk.md index 75b42d3..ac80353 100644 --- a/docs/rewrite/notes/ghostty-apprt-gtk.md +++ b/docs/rewrite/notes/ghostty-apprt-gtk.md @@ -30,7 +30,7 @@ dependency (cycle): the core boundary is the trait set in `src/core.rs`. # Per-component notes (by sub-agent) ## F3: dialogs + config object (class/{dialog,config,config_errors_dialog,close_confirmation_dialog,clipboard_confirmation_dialog,title_dialog,debug_warning}.rs) -Libadwaita >= 1.5 is assumed, so `Dialog` is always an `adw::AlertDialog` subclass (no `MessageDialog` fallback) and DebugWarning uses the `adw::Banner` (1.3) template; the clipboard dialog always uses the 1.4 template (remember switch). GType names, property names, signals and template children match the Zig classes. +Update (GC): the Zig compat fallbacks are now ported, see compat-GC.md. By default (adw_1_5) `Dialog` is an `adw::AlertDialog` subclass, DebugWarning uses the Banner (1.3) template and the clipboard dialog the 1.4 template. GType names, property names, signals and template children match the Zig classes. Public API (all `&self` methods on `glib::wrapper!` types; signals via `connect_*`): - `config::Config` (GhosttyConfig; props `diagnostics-buffer`, `has-diagnostics` -> generated getters `diagnostics_buffer() -> Option`, `has_diagnostics() -> bool`): `new(&CoreConfig)` (clones), `from_arc(Arc)`, `new_default()`, `get() -> Arc`, `get_mut(|&mut CoreConfig| ..)` (Arc::make_mut), `clone_config() -> CoreConfig`. Test `ghostty_config` ported. diff --git a/docs/rewrite/notes/libghostty-L1.md b/docs/rewrite/notes/libghostty-L1.md new file mode 100644 index 0000000..98671a6 --- /dev/null +++ b/docs/rewrite/notes/libghostty-L1.md @@ -0,0 +1,139 @@ +# libghostty: the embedded C API (Phase 8, agent L1) + +Crate `crates/ghostty-lib` (crate types `cdylib` + `staticlib` + `rlib`, `[lib] name = "ghostty"` so Cargo +produces `libghostty.{so,a,dylib}` directly). `include/ghostty.h` and `include/module.modulemap` are +byte-identical copies of `ZIG/include` (a test compares them when the Zig checkout is present). +`unsafe_code = "deny"` crate wide; every FFI module has `#![allow(unsafe_code)]` with `// SAFETY:` comments. + +## Mapping + +| Zig | Rust (`crates/ghostty-lib/src`) | +|---|---| +| `main_c.zig` (`ghostty_init`, `_cli_try_action`, `_info`, `_translate`, `_string_free`, `String`) | `main_c.rs` | +| `config/CApi.zig`, `config/c_get.zig`, `Config.zig` `cval`s | `config_capi.rs` + `ghostty-config/src/c_value.rs` (`CValue`, `ConfigField::c_value`, `Config::c_value`) | +| `apprt/action.zig` `C`/`cval` (target + action unions) | `action_c.rs`, C layouts in `types.rs` | +| `apprt/embedded.zig` `App`, `Surface`, `Platform`, `CAPI` | `embedded.rs` (`AppRt: RtApp`, `SurfaceRt: RtSurface`, `AppHandle`, `Surface`, all `ghostty_app_*`/`ghostty_surface_*`) | +| `apprt/embedded.zig` `Inspector` + `ghostty_inspector_*` | `inspector.rs` (+ `inspector_metal.rs`, Apple only) | +| `benchmark/CApi.zig` | `benchmark.rs` (`ghostty_benchmark_cli`) | +| `include/ghostty.h` structs/unions/enums | `types.rs` (`#[repr(C)]`, enums are `c_int`) | + +`ghostty_set_window_background_blur` is exported on every platform (no-op off macOS), like Zig. The Darwin-only +exports (`ghostty_surface_set_display_id`, `_quicklook_font`, `_quicklook_word`, `ghostty_inspector_metal_*`) are +`cfg(target_vendor = "apple")`, matching the header's `#ifdef __APPLE__`. + +## Runtime design + +* The embedded runtime implements `ghostty_apprt::runtime_api::{RtApp, RtSurface}`; the real `ghostty-core` drives it. + `AppRt` holds a copy of `ghostty_runtime_config_s`, the app config (`Arc`, updated on the app-targeted + `config_change` action exactly like Zig's `performPreAction`), the surface registry (for titles) and the `Keymap`. + `SurfaceRt` holds userdata, the view pointer, content scale / size / cursor position and the title. +* `ghostty_app_t` = `Box`, `ghostty_surface_t` = `Box`. The address of the `Surface` is its + `SurfaceId` in core and the `ghostty_surface_t` in `ghostty_target_s`, so actions fired during + `Surface::new` (cell size, size limit, title) already name the handle the host will get back. +* `core::App::create_surface` (new) returns the concrete `Rc` so the embedded API can reach everything + Zig's `core_surface` exposes (`text_callback`, `refresh_callback`, `draw`, `mouse_captured`, `dump_text_locked`, + `lock_terminal`, ...), not only the `CoreSurface` trait. +* Everything is main-thread only (core uses `Rc`/`RefCell`); the single cross-thread entry is `wakeup_cb` + (`WakerImpl`, a `Waker`). No `RefCell` borrow is held across a call into the host (the host may re-enter). +* Clipboard: `read_clipboard_cb` gets a `Box` pointer that is consumed by + `ghostty_surface_complete_clipboard_request` (kept alive when `confirm_read_clipboard_cb` is invoked, like Zig). +* `ghostty_config_t` is a `Box` (config + the memory `ghostty_config_get` hands out pointers into: strings, + color lists, command lists, diagnostics; kept until `ghostty_config_free`, identical values return identical + pointers). Enum tag names are interned for the process lifetime. The `config_change` action passes a temporary + `CConfig` handle that is freed after the callback returns. + +## Config C getters (`c_get.zig`) + +`ConfigField::c_value() -> CValue` (default `Unsupported`) is implemented by: `bool`, `u8`/`u32` (`c_uint`), `i16` +(`c_short`), `f32`/`f64`, `Option` (`?[:0]const u8`: unset -> true + NULL), every `config_enum!` (tag name), +every `config_packed!` (bits as `c_uint`, <= 32 fields), `Option` (None -> false), `Color`, `ColorList`, `Palette`, +`RepeatableCommand`, `QuickTerminalSize`, `BackgroundBlur` (0/20/radius/-1/-2), `Duration` (`usize` ms), `Path`, +`WindowDecoration`, `CursorStyle`, `OptionAsAlt`. Everything else (`String`, `u16`, `u64`, `usize`, unions without +`cval`, repeatable types, ...) is unsupported (get returns false) exactly like Zig. `ghostty-config` has a test +(`c_value_supported_keys_match_zig`, data in `testdata/c_get_supported.txt`) that compares the supported key set +(143 keys) with the one derived from the Zig field types. `RepeatableStringMap::put` was added (needed for +surface `env_vars`). + +## Platform gating (renderer selection) + +* `ghostty-core` no longer hard-codes OpenGL: `surface/mod.rs` uses `ghostty_renderer::{Renderer, Api}` + (Metal on `target_vendor = "apple"`, OpenGL elsewhere; Zig's `renderer.zig`). `init_graphics_api` is + `OpenGL::init` + `prepare_context(rt.gl_proc_address)` off Apple and `Metal::init_with_view(blending, view, scale)` + on Apple. `RtSurface::platform_view()` (new, default null) carries the `NSView`/`UIView`; `Metal::init_with_view` + (new, in the renderer crate's unsafe-allowed `metal` module) turns the raw pointer into the `ViewInfo`. + GL-only helpers (`frame_pixels`, screenshots, `display_realized(loader)`) are `cfg(not(apple))`. +* Surfaces: Zig's `Platform.init` only accepts `macos`/`ios` on Darwin targets and fails with `UnsupportedPlatform` + everywhere else, so `ghostty_surface_new` returns NULL on Linux (tested). No host-supplied OpenGL context exists in + Zig, so there is none here either. +* New renderer/font/core helpers for the Darwin exports: `Renderer::font_grid()`, `coretext::Face::quicklook_font`, + `core::Surface::{set_macos_display_id, quicklook_font}` (core gained an Apple-only `objc2-core-foundation` dep). +* Test seam: cargo feature `test-platform` (off by default, enabled by `--all-features`) lets `ghostty_surface_new` + accept `platform_tag = 1000` and takes the OpenGL loader from a thread-local the test installs (headless EGL). + Production behaviour is unchanged. + +## Deviations from the Zig + +* `ghostty_init` stores the given argv only to detect the CLI action; config CLI parsing and `+action` execution use + the process arguments (`ghostty_os::args`, `NSProcessInfo` on macOS), which are the same in a real process. + `ghostty_init` returns 1 for multiple/invalid actions (Zig: error from `detectArgs`). The `+action` runs through + `ghostty_cli::run_from_args` (so `+version` etc. print exactly like the binary). +* Lib-mode logging default: stderr off (`GlobalState.Logging.stderr = app_runtime != .none`), via the new + `ghostty_core::global::init_lib`. `BuildInfo` for `+version` reports `.none` runtime / CoreText + Metal on Apple. +* Invalid enum values coming from C (mouse state/button, split directions, color scheme, key action, pressure stage) + are ignored / return false (Zig: UB / safety panic). Null handles are tolerated. +* `ghostty_surface_key_is_binding` writes only the low byte (Zig writes the `u8` `Flags.C` through the enum pointer). +* `ghostty_surface_inherited_config` working-directory strings are kept in the surface (valid until it is freed) instead + of leaking an allocation per call. +* `initial_input` is escaped like `std.zig.stringEscape`, except non-ASCII code points use `\u{..}` (the config `Raw` + value is a Rust `String`, which cannot hold the `\xNN` bytes of a split UTF-8 sequence). +* `ghostty_benchmark_cli` splits the args string itself (quotes group, `\` escapes inside quotes) and runs + `ghostty_bench::benches::main_action`. +* Header typo kept: `chostty_ipc_target_s`; `ghostty_inspector_metal_shutdown` is declared `bool` in the header but + Zig exports it as `void` (we follow Zig; the Swift app ignores the result). + +## Inspector + +C API complete: `ghostty_surface_inspector` (one per surface, returns the existing one), `ghostty_inspector_free` +(takes the *surface*), size / content scale / focus / mouse / scroll / key / text feed a per-inspector Dear ImGui +`Context` (`ghostty_inspector::setup`, style scaled by the factor change, scroll x0.1 for precision devices, only +left/middle/right buttons). Activation calls `core::Surface::activate_inspector` and `RtSurface::redraw_inspector` +raises the `render_inspector` action, as in Zig. + +`inspector_metal.rs` (Apple only, compile-checked only) replaces Dear ImGui's `imgui_impl_metal.mm`: the same MSL +shaders compiled with `newLibraryWithSource`, a pipeline keyed by the host render pass' formats, per-draw-list buffers, +`dear-imgui-rs` managed textures (RGBA32/Alpha8 -> RGBA8), `RENDERER_HAS_VTX_OFFSET | RENDERER_HAS_TEXTURES`. Two +frames are built per render (the first only `end_frame`), like Zig. Raw draw callbacks are ignored. It has never run +on a Mac. + +## Tests + +* Unit: 20 in `src` (string helpers, `ghostty_config_get` ports of the Zig tests: bool/enum/optional null/unknown + key/optional string null/float/struct cval, the `c_get:` tests u8/enum/color/optional/background-blur/ + split-preserve-zoom, action/target conversions, benchmark arg splitting). `ghostty-config`: +4 `c_value` tests and the + two `RepeatableCommand cval` tests (previously skipped). +* `tests/abi.rs` (generated C probe compiled with `cc`, linked against `libghostty.so`): sizeof/alignof/offsetof of every + struct and union of the header (field names parsed from the header, so a missing field fails), sizeof + value of + every enumerator of every enum (755 values, compared with the Rust definitions: `Key::ALL`, `apprt::action::Key`, + `MouseShape`, `Mods` bits, ...), the address of every declared function (links), `nm -D` exports == declared + functions, header files identical to upstream. The probe asserts every header enum/struct is covered. +* `tests/c_host.rs` + `tests/c/host.c`: a C host program on the unchanged header: `ghostty_init`, `+version` / + `+list-keybinds` through `ghostty_cli_try_action`, config new/load_file/finalize/get (bool, enum, string, colors, + palette, float, packed, `short` blur, duration, quick-terminal-size, command list, unsupported/unknown/unset keys), + triggers, diagnostics, clone, open_path, app new/tick/key/key_is_binding/focus/color scheme/open_config/ + update_config (the `config_change` action carries a readable config), global keybinds, `ghostty_surface_new` + returning NULL off Darwin, `ghostty_benchmark_cli`. +* `tests/surface.rs` (feature `test-platform`, headless EGL, real shell on a real pty): 13 tests driving the exported C + surface API: creation/actions/size/scale/free, text + key input, key_is_binding flags, selection + copy/paste + (including the unsafe-paste confirmation round trip), OSC 52 / title / pwd actions, inherited config, env vars / + working directory / initial input / font size options, mouse capture and reports, focus/occlusion/preedit/splits/ + close callback, child exit, app + surface config updates, inspector API, rejected platforms. +* Cross-compile checks (recipe from `macos-M1.md`, wrappers around `zig cc`; the CoreGraphics/Metal/CoreText code is + only type-checked): `cargo check|clippy --all-targets -p ghostty-lib --target aarch64-apple-darwin` and + `x86_64-apple-darwin` pass with `-D warnings`, as does `-p ghostty-core`. + +## Known gaps + +* Nothing in the Apple-only paths (Metal surface setup, display link, quicklook, window blur, Metal inspector) has run + on a Mac; the Swift app has not been built against the library. +* `ghostty_translate` returns interned translations; `ghostty_init` ignores `argv` beyond action detection (see above). +* Only the debug-profile static library was inspected (~500 MB with debug info); release/production sizes untested. diff --git a/docs/rewrite/notes/windows-WIN.md b/docs/rewrite/notes/windows-WIN.md index 2f47aa9..61a4635 100644 --- a/docs/rewrite/notes/windows-WIN.md +++ b/docs/rewrite/notes/windows-WIN.md @@ -95,9 +95,9 @@ Unix-specific existing tests are `cfg(unix)`-gated. * `Command::wait(block)` ignores `block` (blocks, like Zig). The thread handle from `CreateProcessW` is closed (Zig leaks it). * Fontconfig discovery is kept on Windows (needs a real fontconfig to link). -* Not touched: `cli/ssh-cache/DiskCache` file locking, `cli/boo|list_colors| - list_keybinds|edit_config` Windows branches (ghostty-cli), `benchmark/ - CodepointWidth`, `config/Config.zig` `probableCliEnvironment` env-based +* (Update GC: `cli/ssh-cache/DiskCache` locking, the `cli/boo|list_colors| + list_keybinds|edit_config` Windows branches and `benchmark/CodepointWidth` are + now done, see compat-GC*.md.) Not touched: `config/Config.zig` `probableCliEnvironment` env-based extras. ghostty-core/apprt-gtk are not Windows-checkable (GTK). * Unrelated breakage seen while verifying (other agents): `ghostty-config` `config.rs` uses `ghostty_os::launched_from_desktop` under `cfg!(macos)`