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

+348
View File
@@ -0,0 +1,348 @@
//! `apprt.Target.cval` and `apprt.Action.cval`: the conversion of core
//! actions to the `ghostty_target_s` / `ghostty_action_s` C structs
//! (ZIG/src/apprt/action.zig).
#![allow(unsafe_code)]
use std::ffi::{CString, c_char, c_int};
use ghostty_apprt::{
action::{Action, KeySequence, KeyTable, Target},
runtime_api::SurfaceId,
};
use ghostty_renderer::Health;
use ghostty_vt::osc::ProgressState;
use crate::{
config_capi::{CConfig, default_trigger, trigger_to_c},
types::{
ghostty_action_command_finished_s,
ghostty_action_s,
ghostty_action_u,
ghostty_config_t,
ghostty_target_s,
ghostty_target_u,
target_tag,
},
};
/// Memory that the pointers inside a converted action point to. It must
/// outlive the callback invocation.
#[derive(Default)]
pub struct Keep {
strings: Vec<CString>,
config: Option<ghostty_config_t>,
}
impl Keep {
/// A NUL terminated copy of `s` (interior NULs are dropped).
fn cstr(&mut self, s: &str) -> *const c_char {
let c = CString::new(s.replace('\0', "")).unwrap_or_default();
let ptr = c.as_ptr();
// The heap buffer of a CString doesn't move when the Vec grows.
self.strings.push(c);
ptr
}
}
impl Drop for Keep {
fn drop(&mut self) {
if let Some(h) = self.config.take() {
// SAFETY: created by `CConfig::into_handle` in `action_to_c`
// and not handed out beyond the callback.
unsafe { crate::config_capi::ghostty_config_free(h) };
}
}
}
/// `Target.cval`. The surface handle is the id of the surface (the address
/// of the embedded runtime's surface object, see `embedded.rs`).
#[must_use]
pub fn target_to_c(target: &Target<SurfaceId>) -> ghostty_target_s {
match target {
Target::App => ghostty_target_s {
tag: target_tag::APP,
target: ghostty_target_u {
surface: std::ptr::null_mut(),
},
},
Target::Surface(id) => ghostty_target_s {
tag: target_tag::SURFACE,
target: ghostty_target_u {
surface: *id as usize as *mut std::ffi::c_void,
},
},
}
}
fn zeroed_union() -> ghostty_action_u {
// SAFETY: every field of the union is plain old data for which the
// all-zero bit pattern is valid (integers, floats, null pointers, bool).
unsafe { std::mem::zeroed() }
}
/// `Action.cval`.
#[must_use]
pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s {
let mut v = zeroed_union();
match action {
Action::CloseTab(m) => v.close_tab_mode = *m as c_int,
Action::NewSplit(d) => v.new_split = *d as c_int,
Action::ToggleFullscreen(f) => v.toggle_fullscreen = *f as c_int,
Action::MoveTab(m) => v.move_tab.amount = m.amount,
Action::GotoTab(t) => v.goto_tab = t.int(),
Action::GotoSplit(s) => v.goto_split = *s as c_int,
Action::GotoWindow(w) => v.goto_window = *w as c_int,
Action::ResizeSplit(r) => {
v.resize_split.amount = r.amount;
v.resize_split.direction = r.direction as c_int;
}
Action::SizeLimit(s) => {
v.size_limit.min_width = s.min_width;
v.size_limit.min_height = s.min_height;
v.size_limit.max_width = s.max_width;
v.size_limit.max_height = s.max_height;
}
Action::InitialSize(s) => {
v.initial_size.width = s.width;
v.initial_size.height = s.height;
}
Action::CellSize(s) => {
v.cell_size.width = s.width;
v.cell_size.height = s.height;
}
Action::Scrollbar(s) => {
v.scrollbar.total = s.total as u64;
v.scrollbar.offset = s.offset as u64;
v.scrollbar.len = s.len as u64;
}
Action::Inspector(i) => v.inspector = *i as c_int,
Action::DesktopNotification(n) => {
v.desktop_notification.title = keep.cstr(&n.title);
v.desktop_notification.body = keep.cstr(&n.body);
}
Action::SetTitle(t) => v.set_title.title = keep.cstr(&t.title),
Action::SetTabTitle(t) => v.set_tab_title.title = keep.cstr(&t.title),
Action::PromptTitle(p) => v.prompt_title = *p as c_int,
Action::Pwd(p) => v.pwd.pwd = keep.cstr(&p.pwd),
Action::MouseShape(s) => v.mouse_shape = *s as c_int,
Action::MouseVisibility(m) => v.mouse_visibility = *m as c_int,
Action::MouseOverLink(l) => {
v.mouse_over_link.url = keep.cstr(&l.url);
v.mouse_over_link.len = l.url.len();
}
Action::RendererHealth(h) => {
v.renderer_health = match h {
Health::Healthy => 0,
Health::Unhealthy => 1,
};
}
Action::QuitTimer(q) => v.quit_timer = *q as c_int,
Action::FloatWindow(f) => v.float_window = *f as c_int,
Action::SecureInput(s) => v.secure_input = *s as c_int,
Action::KeySequence(KeySequence::Trigger(t)) => {
v.key_sequence.active = true;
v.key_sequence.trigger = trigger_to_c(&t.cval());
}
Action::KeySequence(KeySequence::End) => {
v.key_sequence.active = false;
v.key_sequence.trigger = default_trigger();
}
Action::KeyTable(KeyTable::Activate(name)) => {
v.key_table.tag = 0;
v.key_table.value.activate.name = keep.cstr(name);
v.key_table.value.activate.len = name.len();
}
Action::KeyTable(KeyTable::Deactivate) => v.key_table.tag = 1,
Action::KeyTable(KeyTable::DeactivateAll) => v.key_table.tag = 2,
Action::ColorChange(c) => {
v.color_change.kind = c.kind.int();
v.color_change.r = c.r;
v.color_change.g = c.g;
v.color_change.b = c.b;
}
Action::ReloadConfig(r) => v.reload_config.soft = r.soft,
Action::ConfigChange(c) => {
// The embedder reads (and clones) the config during the
// callback; it is freed again when `keep` drops.
if let Some(old) = keep.config.take() {
// SAFETY: created below by an earlier conversion.
unsafe { crate::config_capi::ghostty_config_free(old) };
}
let handle = CConfig::new((*c.config).clone()).into_handle();
keep.config = Some(handle);
v.config_change.config = handle;
}
Action::OpenUrl(u) => {
v.open_url.kind = u.kind as c_int;
v.open_url.url = keep.cstr(&u.url);
v.open_url.len = u.url.len();
}
Action::ShowChildExited(c) => {
v.child_exited.exit_code = c.exit_code;
v.child_exited.timetime_ms = c.runtime_ms;
}
Action::ProgressReport(p) => {
v.progress_report.state = match p.state {
ProgressState::Remove => 0,
ProgressState::Set => 1,
ProgressState::Error => 2,
ProgressState::Indeterminate => 3,
ProgressState::Pause => 4,
};
v.progress_report.progress = p.progress.map_or(-1, |p| p as i8);
}
Action::CommandFinished(c) => {
v.command_finished = ghostty_action_command_finished_s {
exit_code: c.exit_code.map_or(-1, i16::from),
duration: c.duration.duration,
};
}
Action::StartSearch(s) => v.start_search.needle = keep.cstr(&s.needle),
Action::SearchTotal(s) => {
v.search_total.total = s.total.map_or(-1, |t| t as isize);
}
Action::SearchSelected(s) => {
v.search_selected.selected = s.selected.map_or(-1, |t| t as isize);
}
Action::Readonly(r) => v.readonly = *r as c_int,
_ => {}
}
ghostty_action_s {
tag: action.key() as c_int,
action: v,
}
}
#[cfg(test)]
mod tests {
use ghostty_apprt::action::{
CellSize,
CommandFinished,
GotoTab,
MouseOverLink,
ResizeSplit,
ResizeSplitDirection,
SearchTotal,
SetTitle,
};
use super::*;
#[test]
fn target_cval() {
let t = target_to_c(&Target::App);
assert_eq!(t.tag, 0);
let t = target_to_c(&Target::Surface(0x1000));
assert_eq!(t.tag, 1);
// SAFETY: tag says surface.
assert_eq!(unsafe { t.target.surface } as usize, 0x1000);
}
#[test]
fn simple_payloads() {
let mut keep = Keep::default();
let a = action_to_c(
&Action::CellSize(CellSize {
width: 7,
height: 9,
}),
&mut keep,
);
assert_eq!(a.tag, 25);
// SAFETY: tag says cell_size.
assert_eq!(
unsafe { (a.action.cell_size.width, a.action.cell_size.height) },
(7, 9)
);
let a = action_to_c(&Action::GotoTab(GotoTab::Last), &mut keep);
// SAFETY: tag says goto_tab.
assert_eq!(unsafe { a.action.goto_tab }, -3);
let a = action_to_c(
&Action::ResizeSplit(ResizeSplit {
amount: 5,
direction: ResizeSplitDirection::Left,
}),
&mut keep,
);
// SAFETY: tag says resize_split.
assert_eq!(
unsafe {
(
a.action.resize_split.amount,
a.action.resize_split.direction,
)
},
(5, 2)
);
let a = action_to_c(&Action::SearchTotal(SearchTotal { total: None }), &mut keep);
// SAFETY: tag says search_total.
assert_eq!(unsafe { a.action.search_total.total }, -1);
let a = action_to_c(
&Action::CommandFinished(CommandFinished {
exit_code: None,
duration: ghostty_config::types_b::Duration { duration: 5 },
}),
&mut keep,
);
// SAFETY: tag says command_finished.
assert_eq!(
unsafe {
(
a.action.command_finished.exit_code,
a.action.command_finished.duration,
)
},
(-1, 5)
);
}
#[test]
fn strings_stay_valid_while_kept() {
let mut keep = Keep::default();
let a = action_to_c(
&Action::SetTitle(SetTitle {
title: "hello".into(),
}),
&mut keep,
);
// SAFETY: tag says set_title; the pointer points into `keep`.
let s = unsafe { std::ffi::CStr::from_ptr(a.action.set_title.title) };
assert_eq!(s.to_str().unwrap(), "hello");
let a = action_to_c(
&Action::MouseOverLink(MouseOverLink {
url: "https://x".into(),
}),
&mut keep,
);
// SAFETY: tag says mouse_over_link.
assert_eq!(unsafe { a.action.mouse_over_link.len }, 9);
}
#[test]
fn config_change_hands_out_a_config() {
let mut keep = Keep::default();
let cfg = std::sync::Arc::new(ghostty_config::config::Config::default());
let a = action_to_c(
&Action::ConfigChange(ghostty_apprt::action::ConfigChange { config: cfg }),
&mut keep,
);
// SAFETY: tag says config_change.
let h = unsafe { a.action.config_change.config };
let mut out = false;
let key = "maximize";
// SAFETY: valid handle and destination.
assert!(unsafe {
crate::config_capi::ghostty_config_get(
h,
(&raw mut out).cast(),
key.as_ptr().cast(),
key.len(),
)
});
}
}