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)
This commit is contained in:
ddidderr committed 2026-10-12 06:38:33 +02:00
1 parent f2252bb165
commit 75bf604375
47 files changed
+10296 -34

No files matched your search

+1385
View File
@@ -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<String, Vec<String>> {
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<String, Vec<String>> {
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<T: std::fmt::Debug + Copy>(items: &[T], f: impl Fn(T) -> i64) -> BTreeMap<String, i64> {
items
.iter()
.map(|i| (norm(&format!("{i:?}")), f(*i)))
.collect()
}
fn named(items: &[(&str, i64)]) -> BTreeMap<String, i64> {
items.iter().map(|(n, v)| (norm(n), *v)).collect()
}
struct Probe {
c: String,
expected: BTreeMap<String, i64>,
records: BTreeMap<String, Vec<String>>,
covered_records: BTreeSet<String>,
covered_enums: BTreeSet<String>,
}
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<String, Vec<String>>,
typedef: &str,
prefix: &str,
rust: &BTreeMap<String, i64>,
) {
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::<usize>()),
("ghostty_config_t", size_of::<usize>()),
("ghostty_surface_t", size_of::<usize>()),
("ghostty_inspector_t", size_of::<usize>()),
("ghostty_input_scroll_mods_t", 4),
("ghostty_runtime_wakeup_cb", size_of::<usize>()),
("ghostty_runtime_action_cb", size_of::<usize>()),
] {
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::<ghostty_action_u>(), 24);
assert_eq!(size_of::<ghostty_action_s>(), 32);
assert_eq!(size_of::<ghostty_target_s>(), 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 <stdio.h>\n#include <stddef.h>\n#include <stdint.h>\n#include <ghostty.h>\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, i64> = 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> = 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<String> = 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");
}
}
+385
View File
@@ -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 <ghostty.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
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;
}
+81
View File
@@ -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<PathBuf> = 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());
}
+163
View File
@@ -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/<profile>`, 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) // <profile>
.expect("profile dir")
.to_path_buf()
}
fn cargo() -> Command {
Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
}
static BUILT: OnceLock<PathBuf> = 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<String> {
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
}
+810
View File
@@ -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<String>,
cell_size: Option<(u32, u32)>,
writes: Vec<(i32, String, bool)>,
reads: Vec<(i32, usize)>,
confirms: Vec<(String, usize, i32)>,
closes: Vec<bool>,
child_exit: Option<u32>,
mouse_shapes: Vec<i32>,
pwds: Vec<String>,
open_urls: Vec<String>,
other_mimes: Vec<String>,
}
thread_local! {
static REC: RefCell<Rec> = RefCell::new(Rec::default());
}
fn rec<R>(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::<u8>(), 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<Headless>,
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<Host> {
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<String> = 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::<ghostty_text_s>() };
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::<ghostty_text_s>() };
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) };
}