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:
1 parent
4787565397
commit
49d4ecefae
47 files changed
+10296
-34
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user