refactor: extract libghostty-vt binding into ghostty-vt workspace crate (#4661)

* refactor: extract libghostty-vt binding into ghostty-vt workspace crate

* fix: build libghostty-vt into each build's out dir
This commit is contained in:
Can Celik
2026-09-27 00:06:29 +03:00
committed by GitHub
parent c33fd40eab
commit fff6c820aa
24 changed files with 273 additions and 192 deletions
+5 -5
View File
@@ -14,7 +14,7 @@ env:
# Keep file/line backtraces without generating type and variable debug information.
CARGO_PROFILE_DEV_DEBUG: "1"
CARGO_PROFILE_TEST_DEBUG: "1"
# Persist both Zig caches; local build outputs otherwise stay in the uncached vendor tree.
# Persist both Zig caches; build outputs otherwise stay uncached in Cargo's OUT_DIR.
ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache
ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache
# PR caches are visible only to their own PR, and every save pushes the
@@ -164,9 +164,9 @@ jobs:
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target/debug/incremental
key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs') }}-${{ github.sha }}
key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs', 'crates/*/Cargo.toml', 'crates/*/build.rs') }}-${{ github.sha }}
restore-keys: |
ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs') }}-
ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs', 'crates/*/Cargo.toml', 'crates/*/build.rs') }}-
- name: Run Linux lint
if: runner.os == 'Linux'
@@ -185,7 +185,7 @@ jobs:
env:
LIBGHOSTTY_VT_SIMD: "false"
LIBGHOSTTY_VT_OPTIMIZE: ReleaseSafe
run: CARGO_INCREMENTAL=1 just test-one ghostty
run: CARGO_INCREMENTAL=1 cargo nextest run --locked -E 'package(ghostty-vt) | test(ghostty)' --status-level fail --final-status-level fail --failure-output final --success-output never
- name: Run Windows checks
if: matrix.kind == 'windows'
@@ -208,7 +208,7 @@ jobs:
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target/debug/incremental
key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs') }}-${{ github.sha }}
key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs', 'crates/*/Cargo.toml', 'crates/*/build.rs') }}-${{ github.sha }}
- name: Save Zig build cache
if: env.SAVE_CACHES == 'true'
+2
View File
@@ -7,6 +7,7 @@ on:
- ".github/workflows/nix.yml"
- "assets/**"
- "build.rs"
- "crates/**"
- "Cargo.lock"
- "Cargo.toml"
- "flake.lock"
@@ -20,6 +21,7 @@ on:
- ".github/workflows/nix.yml"
- "assets/**"
- "build.rs"
- "crates/**"
- "Cargo.lock"
- "Cargo.toml"
- "flake.lock"
+1 -1
View File
@@ -46,7 +46,7 @@
"exclude": [
"target",
"vendor",
"src/ghostty/bindings.rs"
"crates/ghostty-vt/src/bindings.rs"
]
},
"lru": {
Generated
+11
View File
@@ -743,6 +743,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghostty-vt"
version = "0.0.0"
dependencies = [
"base64",
"libc",
"png",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -781,6 +791,7 @@ dependencies = [
"crossterm",
"ctrlc",
"futures-util",
"ghostty-vt",
"interprocess",
"jsonc-parser",
"libc",
+5
View File
@@ -20,7 +20,12 @@ include = [
"Cargo.toml",
]
[workspace]
members = ["crates/ghostty-vt"]
default-members = [".", "crates/ghostty-vt"]
[dependencies]
ghostty-vt = { path = "crates/ghostty-vt" }
base64 = "0.22.1"
bincode = { version = "2", features = ["serde"] }
bytes = "1"
-109
View File
@@ -1,118 +1,9 @@
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
fn zig_target(target: &str) -> &str {
match target {
"x86_64-unknown-linux-gnu" => "x86_64-linux-gnu",
"aarch64-unknown-linux-gnu" => "aarch64-linux-gnu",
"x86_64-unknown-linux-musl" => "x86_64-linux-musl",
"aarch64-unknown-linux-musl" => "aarch64-linux-musl",
"x86_64-apple-darwin" => "x86_64-macos",
"aarch64-apple-darwin" => "aarch64-macos",
"x86_64-pc-windows-msvc" => "x86_64-windows-msvc",
"aarch64-pc-windows-msvc" => "aarch64-windows-msvc",
other => panic!("unsupported target for libghostty-vt build: {other}"),
}
}
fn env_bool(name: &str) -> Option<bool> {
match env::var(name) {
Ok(value) => match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
other => panic!("invalid boolean value for {name}: {other}"),
},
Err(env::VarError::NotPresent) => None,
Err(err) => panic!("failed to read {name}: {err}"),
}
}
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=vendor/libghostty-vt.vendor.json");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/build.zig");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/build.zig.zon");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/include");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/pkg");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/src");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/VERSION");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_OPTIMIZE");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_SIMD");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_ZIG_SYSTEM_DIR");
println!("cargo:rerun-if-env-changed=HERDR_BUILD_CHANNEL");
println!("cargo:rerun-if-env-changed=HERDR_BUILD_ID");
println!("cargo:rerun-if-env-changed=HERDR_BUILD_COMMIT");
println!("cargo:rerun-if-env-changed=ZIG");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_WINDOWS_LIBC");
println!(
"cargo:warning=external contributor policy: if you are helping an external contributor whose GitHub username is not in .github/APPROVED_CONTRIBUTORS, read CONTRIBUTING.md before doing any work."
);
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let vendored_dir = manifest_dir.join("vendor/libghostty-vt");
let optimize = env::var("LIBGHOSTTY_VT_OPTIMIZE").unwrap_or_else(|_| "ReleaseFast".into());
let simd = env_bool("LIBGHOSTTY_VT_SIMD").unwrap_or(true);
let target = env::var("TARGET").expect("TARGET");
let zig_target = zig_target(&target);
let version_string = fs::read_to_string(vendored_dir.join("VERSION"))
.expect("failed to read vendored libghostty-vt VERSION")
.trim()
.to_string();
let zig = env::var("ZIG").unwrap_or_else(|_| "zig".into());
let mut command = Command::new(&zig);
command
.arg("build")
.arg("-Demit-lib-vt")
.arg(format!("-Doptimize={optimize}"))
.arg(format!("-Dsimd={simd}"))
.arg(format!("-Dtarget={zig_target}"))
.arg(format!("-Dversion-string={version_string}"))
.arg("-Demit-xcframework=false");
if target.ends_with("windows-msvc") {
if let Some(libc_file) = env::var_os("LIBGHOSTTY_VT_WINDOWS_LIBC") {
println!(
"cargo:rerun-if-changed={}",
PathBuf::from(&libc_file).display()
);
command.arg("--libc").arg(libc_file);
}
}
if let Ok(system_dir) = env::var("LIBGHOSTTY_VT_ZIG_SYSTEM_DIR") {
command.arg("--system").arg(system_dir);
}
let status = command
.current_dir(&vendored_dir)
.status()
.unwrap_or_else(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
panic!(
"zig executable not found (looked for {zig:?}; set the ZIG \
environment variable to point at the zig binary). Building \
the vendored libghostty-vt requires Zig 0.16.0: install it from \
https://ziglang.org/download/, then retry the build"
);
}
panic!("failed to execute zig build for vendored libghostty-vt: {err}");
});
assert!(
status.success(),
"zig build for vendored libghostty-vt failed: {status}. \
Building Herdr requires Zig 0.16.0; check `zig version` \
or set ZIG to the path of a Zig 0.16.0 binary, then retry"
);
let lib_dir = vendored_dir.join("zig-out/lib");
println!("cargo:rustc-link-search=native={}", lib_dir.display());
if target.contains("apple-darwin") {
let static_lib = lib_dir.join("libghostty-vt.a");
println!("cargo:rustc-link-arg={}", static_lib.display());
} else if target.contains("windows-msvc") {
println!("cargo:rustc-link-lib=static=ghostty-vt-static");
} else {
println!("cargo:rustc-link-lib=static=ghostty-vt");
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "ghostty-vt"
version = "0.0.0"
edition = "2021"
description = "Herdr's binding to its vendored libghostty-vt"
license = "Apache-2.0"
repository = "https://github.com/herdrdev/herdr"
publish = false
[dependencies]
base64 = "0.22.1"
libc = "0.2"
png = "0.17"
tracing = "0.1.44"
+137
View File
@@ -0,0 +1,137 @@
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
fn zig_target(target: &str) -> &str {
match target {
"x86_64-unknown-linux-gnu" => "x86_64-linux-gnu",
"aarch64-unknown-linux-gnu" => "aarch64-linux-gnu",
"x86_64-unknown-linux-musl" => "x86_64-linux-musl",
"aarch64-unknown-linux-musl" => "aarch64-linux-musl",
"x86_64-apple-darwin" => "x86_64-macos",
"aarch64-apple-darwin" => "aarch64-macos",
"aarch64-apple-ios" => "aarch64-ios",
"aarch64-apple-ios-sim" => "aarch64-ios-simulator",
"x86_64-pc-windows-msvc" => "x86_64-windows-msvc",
"aarch64-pc-windows-msvc" => "aarch64-windows-msvc",
other => panic!("unsupported target for libghostty-vt build: {other}"),
}
}
fn env_bool(name: &str) -> Option<bool> {
match env::var(name) {
Ok(value) => match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
other => panic!("invalid boolean value for {name}: {other}"),
},
Err(env::VarError::NotPresent) => None,
Err(err) => panic!("failed to read {name}: {err}"),
}
}
fn main() {
// The vendored source stays at the repository root, shared with its maintenance scripts.
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let vendor_dir = manifest_dir.join("../../vendor");
let vendored_dir = vendor_dir.join("libghostty-vt");
println!("cargo:rerun-if-changed=build.rs");
for path in [
vendor_dir.join("libghostty-vt.vendor.json"),
vendored_dir.join("build.zig"),
vendored_dir.join("build.zig.zon"),
vendored_dir.join("include"),
vendored_dir.join("pkg"),
vendored_dir.join("src"),
vendored_dir.join("VERSION"),
] {
println!("cargo:rerun-if-changed={}", path.display());
}
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_OPTIMIZE");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_SIMD");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_ZIG_SYSTEM_DIR");
println!("cargo:rerun-if-env-changed=ZIG");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_WINDOWS_LIBC");
let optimize = env::var("LIBGHOSTTY_VT_OPTIMIZE").unwrap_or_else(|_| "ReleaseFast".into());
let simd = env_bool("LIBGHOSTTY_VT_SIMD").unwrap_or(true);
let target = env::var("TARGET").expect("TARGET");
let zig_target = zig_target(&target);
let version_string = fs::read_to_string(vendored_dir.join("VERSION"))
.expect("failed to read vendored libghostty-vt VERSION")
.trim()
.to_string();
// Install into this build's OUT_DIR so concurrent builds for different targets
// from one checkout never link each other's archive.
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let zig_prefix = out_dir.join("zig-out");
let zig = env::var("ZIG").unwrap_or_else(|_| "zig".into());
let mut command = Command::new(&zig);
command
.arg("build")
.arg("--prefix")
.arg(&zig_prefix)
.arg("-Demit-lib-vt")
.arg(format!("-Doptimize={optimize}"))
.arg(format!("-Dsimd={simd}"))
.arg(format!("-Dtarget={zig_target}"))
.arg(format!("-Dversion-string={version_string}"))
.arg("-Demit-xcframework=false");
if target.ends_with("windows-msvc") {
if let Some(libc_file) = env::var_os("LIBGHOSTTY_VT_WINDOWS_LIBC") {
println!(
"cargo:rerun-if-changed={}",
PathBuf::from(&libc_file).display()
);
command.arg("--libc").arg(libc_file);
}
}
if let Ok(system_dir) = env::var("LIBGHOSTTY_VT_ZIG_SYSTEM_DIR") {
command.arg("--system").arg(system_dir);
}
let status = command
.current_dir(&vendored_dir)
.status()
.unwrap_or_else(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
panic!(
"zig executable not found (looked for {zig:?}; set the ZIG \
environment variable to point at the zig binary). Building \
the vendored libghostty-vt requires Zig 0.16.0: install it from \
https://ziglang.org/download/, then retry the build"
);
}
panic!("failed to execute zig build for vendored libghostty-vt: {err}");
});
assert!(
status.success(),
"zig build for vendored libghostty-vt failed: {status}. \
Building Herdr requires Zig 0.16.0; check `zig version` \
or set ZIG to the path of a Zig 0.16.0 binary, then retry"
);
let mut lib_dir = zig_prefix.join("lib");
if target.contains("-apple-") {
// Apple's linker prefers the sibling dylib for `-l`, so search a directory
// holding only the static archive.
let static_dir = out_dir.join("lib");
fs::create_dir_all(&static_dir).expect("failed to create libghostty-vt link directory");
fs::copy(
lib_dir.join("libghostty-vt.a"),
static_dir.join("libghostty-vt.a"),
)
.expect("failed to copy libghostty-vt static archive");
lib_dir = static_dir;
}
println!("cargo:rustc-link-search=native={}", lib_dir.display());
if target.contains("windows-msvc") {
println!("cargo:rustc-link-lib=static=ghostty-vt-static");
} else {
println!("cargo:rustc-link-lib=static=ghostty-vt");
}
}
@@ -25,7 +25,13 @@ use std::slice;
use std::sync::{Arc, Mutex, Once, OnceLock};
use crate::pane_graphics_files::OwnedExport;
#[cfg(target_os = "linux")]
mod native_image_sources;
mod native_source;
pub mod pane_graphics_files;
/// Terminfo entry the terminal emulates; child processes should see it as TERM.
pub const TERM: &str = "xterm-256color";
pub use bindings as ffi;
@@ -77,7 +83,7 @@ pub enum Dirty {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TerminalCompressionResult {
pub enum TerminalCompressionResult {
Unsupported,
Pending,
Complete,
@@ -199,7 +205,7 @@ const TERMINAL_DATA_COLOR_CURSOR: ffi::GhosttyTerminalData = 20;
const KITTY_IMAGE_STORAGE_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
const APC_MAX_BYTES: usize = 16 * 1024 * 1024;
const APC_MAX_BYTES_KITTY: usize = 16 * 1024 * 1024;
pub(crate) const KITTY_UNICODE_PLACEHOLDER: u32 = 0x10EEEE;
pub const KITTY_UNICODE_PLACEHOLDER: u32 = 0x10EEEE;
// The vendored C headers expose these placement fields, but the checked-in
// generated bindings predate the names. Keep the explicit values aligned with
// vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h.
@@ -230,7 +236,7 @@ pub struct KittyImagePlacement {
pub data_len: usize,
pub data_fingerprint: u64,
pub data: Vec<u8>,
pub(crate) source_file: Option<Arc<OwnedExport>>,
pub source_file: Option<Arc<OwnedExport>>,
pub render: KittyPlacementRenderInfo,
}
@@ -463,13 +469,13 @@ pub enum CellWide {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScreenTextCell {
pub struct ScreenTextCell {
pub wide: CellWide,
pub graphemes: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScreenTextRow {
pub struct ScreenTextRow {
pub cells: Vec<ScreenTextCell>,
pub soft_wrapped: bool,
pub wrap_continuation: bool,
@@ -826,11 +832,19 @@ pub fn encode_focus(event: FocusEvent) -> Result<Vec<u8>, Error> {
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum LinkTarget {
pub enum LinkTarget {
Uri(String),
Text { text: String, clicked_byte: usize },
}
/// Inclusive display-cell columns on the current viewport.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LinkRegion {
pub row: u16,
pub start_col: u16,
pub end_col: u16,
}
pub struct Terminal {
raw: ffi::GhosttyTerminal,
max_scrollback: usize,
@@ -870,8 +884,8 @@ impl Terminal {
let userdata = (&mut *terminal.callback_state as *mut TerminalCallbackState).cast();
let glyph_protocol = false;
let terminfo_name = ffi::GhosttyString {
ptr: crate::pane::PANE_TERM.as_ptr().cast(),
len: crate::pane::PANE_TERM.len(),
ptr: TERM.as_ptr().cast(),
len: TERM.len(),
};
let grapheme_default = ffi::GhosttyTerminalModeConfig {
mode: MODE_GRAPHEME_CLUSTER,
@@ -951,7 +965,7 @@ impl Terminal {
}
}
pub(crate) fn compression_activity(&self) -> Result<u64, Error> {
pub fn compression_activity(&self) -> Result<u64, Error> {
let mut activity = 0;
// SAFETY: self.raw is a live terminal handle and activity is a valid out pointer.
unsafe {
@@ -960,7 +974,7 @@ impl Terminal {
Ok(activity)
}
pub(crate) fn compress_incremental(&mut self) -> Result<TerminalCompressionResult, Error> {
pub fn compress_incremental(&mut self) -> Result<TerminalCompressionResult, Error> {
let mut result =
ffi::GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED;
// SAFETY: self.raw is a live terminal handle and result is a valid out pointer.
@@ -1240,7 +1254,7 @@ impl Terminal {
}
#[cfg(windows)]
pub(crate) fn track_row(&mut self, y: u32) -> Option<usize> {
pub fn track_row(&mut self, y: u32) -> Option<usize> {
let mut point = ffi::GhosttyPointCoordinate::default();
let tag = ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN;
let result =
@@ -1262,11 +1276,11 @@ impl Terminal {
Ok((wide, graphemes))
}
pub(crate) fn screen_text_rows(&self) -> Result<Vec<ScreenTextRow>, Error> {
pub fn screen_text_rows(&self) -> Result<Vec<ScreenTextRow>, Error> {
self.screen_text_rows_range(0, usize::MAX)
}
pub(crate) fn screen_text_rows_range(
pub fn screen_text_rows_range(
&self,
start_row: usize,
end_row_exclusive: usize,
@@ -1317,7 +1331,7 @@ impl Terminal {
grid_ref_hyperlink_uri(&grid_ref)
}
pub(crate) fn viewport_link_target(&self, x: u16, y: u32) -> Result<Option<LinkTarget>, Error> {
pub fn viewport_link_target(&self, x: u16, y: u32) -> Result<Option<LinkTarget>, Error> {
Ok(self
.viewport_link_selection(x, y)?
.map(|(target, _)| target))
@@ -1390,12 +1404,12 @@ impl Terminal {
/// Resolve only the bounded plain-text token. OSC 8 regions are resolved by
/// clients from frame hyperlink IDs; their full URI activation path is unchanged.
pub(crate) fn viewport_link_regions(
pub fn viewport_link_regions(
&self,
x: u16,
y: u32,
resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>,
) -> Result<Vec<crate::api::schema::PaneLinkRegion>, Error> {
) -> Result<Vec<LinkRegion>, Error> {
let cols = self.cols()?;
let rows = self.rows()?;
if x >= cols || y >= u32::from(rows) {
@@ -1461,7 +1475,7 @@ impl Terminal {
false,
)?;
let mut byte = prefix.len().saturating_sub(cell_len(&first)?);
let mut regions: Vec<crate::api::schema::PaneLinkRegion> = Vec::new();
let mut regions: Vec<LinkRegion> = Vec::new();
for row in start_row..=end_row {
let mut cell = self.grid_ref(ghostty_viewport_point(0, row))?;
let left = if row == start_row { start_col } else { 0 };
@@ -1485,7 +1499,7 @@ impl Terminal {
{
last.end_col = end;
} else {
regions.push(crate::api::schema::PaneLinkRegion {
regions.push(LinkRegion {
row: row as u16,
start_col: col,
end_col: end,
@@ -1716,11 +1730,11 @@ impl Terminal {
self.get_optional_rgb_color(TERMINAL_DATA_COLOR_CURSOR)
}
pub(crate) fn width_px(&self) -> Result<u32, Error> {
pub fn width_px(&self) -> Result<u32, Error> {
self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_WIDTH_PX)
}
pub(crate) fn height_px(&self) -> Result<u32, Error> {
pub fn height_px(&self) -> Result<u32, Error> {
self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_HEIGHT_PX)
}
@@ -1805,7 +1819,7 @@ impl Terminal {
)
}
pub(crate) fn kitty_graphics_may_have_placements(&self) -> Result<bool, Error> {
pub fn kitty_graphics_may_have_placements(&self) -> Result<bool, Error> {
let generation = self.kitty_graphics_generation()?;
Ok(generation != 0 && self.kitty_empty_generation.get() != Some(generation))
}
@@ -2460,7 +2474,7 @@ fn kitty_placeholder_diacritic_index(codepoint: u32) -> Option<u32> {
// Reuse Ghostty's vendored table so Herdr decodes the same placeholder
// row/column diacritics that libghostty accepts.
let source =
include_str!("../../vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig");
include_str!("../../../vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig");
let mut map = HashMap::new();
let mut in_table = false;
for line in source.lines() {
@@ -3159,6 +3173,8 @@ pub struct RowIter<'a> {
}
impl<'a> RowIter<'a> {
// Advances a native cursor; rows are read in place, so this is not an Iterator.
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> bool {
// SAFETY: iterator handle is valid while self is alive.
unsafe { ffi::ghostty_render_state_row_iterator_next(self.iterator.raw) }
@@ -3327,6 +3343,7 @@ impl Default for CellBasicData {
}
impl<'a> RowCellIter<'a> {
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> bool {
// SAFETY: cells handle is valid while self is alive.
unsafe { ffi::ghostty_render_state_row_cells_next(self.cells.raw) }
@@ -3989,6 +4006,8 @@ mod tests {
#[cfg(unix)]
#[test]
fn kitty_graphics_file_upload_can_be_placed_later() {
use base64::Engine as _;
let dir = std::env::temp_dir().join(format!(
"herdr-kitty-file-upload-test-{}",
std::process::id()
@@ -4000,14 +4019,10 @@ mod tests {
let mut terminal = Terminal::new(10, 5, 0).unwrap();
terminal.enable_kitty_graphics().unwrap();
terminal.resize(10, 5, 8, 16).unwrap();
let mut upload = Vec::new();
crate::kitty_graphics::encode_kitty_regular_file(
&mut upload,
&[],
"a=t,f=32,s=1,v=1,i=10,q=0",
path.to_str().unwrap(),
);
terminal.write(&upload);
let payload =
base64::engine::general_purpose::STANDARD.encode(path.to_str().unwrap().as_bytes());
let upload = format!("\x1b7\x1b_Ga=t,f=32,s=1,v=1,i=10,q=0,t=f;{payload}\x1b\\\x1b8");
terminal.write(upload.as_bytes());
assert!(terminal.kitty_image_placements().unwrap().is_empty());
terminal.write(b"\x1b_Ga=p,i=10,p=5,c=10,r=5,C=1,q=2\x1b\\");
@@ -9,13 +9,32 @@ use std::sync::{Arc, Mutex, OnceLock};
#[cfg(unix)]
use std::os::unix::fs::{DirBuilderExt, FileExt, MetadataExt, OpenOptionsExt, PermissionsExt};
#[cfg(target_os = "linux")]
use crate::native_image_sources::clone_native_image_source;
/// Kernel CoW snapshots are deliberately unsupported outside Linux.
#[cfg(not(target_os = "linux"))]
fn clone_native_image_source(
_source_fd: i64,
_destination: &File,
_expected_len: usize,
) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"native source cloning requires Linux",
))
}
// Herdr's default log filter is `herdr=info`; keep these warnings under its target.
const LOG_TARGET: &str = "herdr::pane_graphics_files";
#[cfg(unix)]
const DIRECTORY_MODE: u32 = 0o700;
#[cfg(unix)]
const FILE_MODE: u32 = 0o600;
#[derive(Debug)]
pub(crate) struct FileStore {
pub struct FileStore {
base: PathBuf,
generation: OnceLock<Arc<Generation>>,
next_fingerprint: AtomicU64,
@@ -30,7 +49,7 @@ struct Generation {
/// Keep this owned snapshot alive until the terminal has consumed its path.
#[derive(Debug)]
pub(crate) struct OwnedExport {
pub struct OwnedExport {
lease: Lease,
_file: ExportFile,
_reservation: Option<Reservation>,
@@ -106,7 +125,7 @@ impl FileStore {
}
}
pub(crate) fn native_sources() -> Self {
pub fn native_sources() -> Self {
let mut store = Self::new(native_base());
store.native_budget = Some(Arc::new(Mutex::new(NativeBudget::default())));
store
@@ -114,7 +133,7 @@ impl FileStore {
/// Kernel-only bounded snapshot; any unsupported clone returns an error.
/// The caller keeps the borrowed descriptor alive throughout this call.
pub(crate) fn snapshot(&self, source_fd: i64, expected_len: usize) -> io::Result<OwnedExport> {
pub fn snapshot(&self, source_fd: i64, expected_len: usize) -> io::Result<OwnedExport> {
if expected_len == 0 || expected_len > MAX_EXPORT_BYTES || !expected_len.is_multiple_of(4) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -133,7 +152,7 @@ impl FileStore {
path,
_generation: generation,
};
crate::platform::clone_native_image_source(source_fd, &destination, expected_len)?;
clone_native_image_source(source_fd, &destination, expected_len)?;
drop(destination);
let lease = self.lease(&file.path, expected_len)?;
Ok(OwnedExport {
@@ -143,8 +162,9 @@ impl FileStore {
})
}
#[cfg(all(test, unix))]
pub(crate) fn source_directory(&self) -> io::Result<PathBuf> {
// Not test-gated: herdr's own tests use it, and a dependency's cfg(test) is off.
#[cfg(unix)]
pub fn source_directory(&self) -> io::Result<PathBuf> {
Ok(self.generation()?.source.clone())
}
@@ -168,7 +188,7 @@ impl FileStore {
/// Snapshot decoded bytes into a private, uniquely named file. Aggregate
/// outstanding-export limits are the caller's responsibility.
pub(crate) fn export(&self, data: &[u8]) -> io::Result<OwnedExport> {
pub fn export(&self, data: &[u8]) -> io::Result<OwnedExport> {
if data.len() > MAX_EXPORT_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -230,7 +250,7 @@ impl PartialEq for OwnedExport {
impl Eq for OwnedExport {}
impl OwnedExport {
pub(crate) fn copy_rgba(&self) -> io::Result<Vec<u8>> {
pub fn copy_rgba(&self) -> io::Result<Vec<u8>> {
self.lease.copy_rgba()
}
@@ -238,15 +258,16 @@ impl OwnedExport {
self.lease.read_into(data)
}
pub(crate) fn path(&self) -> &Path {
pub fn path(&self) -> &Path {
self.lease.path()
}
pub(crate) fn len(&self) -> usize {
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.lease.len()
}
pub(crate) fn fingerprint(&self) -> u64 {
pub fn fingerprint(&self) -> u64 {
self.lease.fingerprint()
}
}
@@ -255,7 +276,7 @@ impl Drop for ExportFile {
fn drop(&mut self) {
if let Err(err) = fs::remove_file(&self.path) {
if err.kind() != io::ErrorKind::NotFound {
tracing::warn!(path = %self.path.display(), err = %err, "failed to remove decoded graphics export");
tracing::warn!(target: LOG_TARGET, path = %self.path.display(), err = %err, "failed to remove decoded graphics export");
}
}
}
@@ -306,14 +327,14 @@ impl Drop for Generation {
fn drop(&mut self) {
if let Err(err) = fs::remove_dir_all(&self.root) {
if err.kind() != io::ErrorKind::NotFound {
tracing::warn!(path = %self.root.display(), err = %err, "failed to remove pane graphics directory");
tracing::warn!(target: LOG_TARGET, path = %self.root.display(), err = %err, "failed to remove pane graphics directory");
}
}
}
}
#[cfg(unix)]
pub(crate) fn validate_direct_source(path: &Path, expected_len: usize) -> io::Result<()> {
pub fn validate_direct_source(path: &Path, expected_len: usize) -> io::Result<()> {
validate_source_under(path, expected_len, runtime_base())
}
@@ -343,7 +364,7 @@ fn validate_source_under(path: &Path, expected_len: usize, base: PathBuf) -> io:
/// Unlike the API validator, accepts only the dedicated native hierarchy.
#[cfg(unix)]
pub(crate) fn validate_native_source(path: &Path, expected_len: usize) -> io::Result<()> {
pub fn validate_native_source(path: &Path, expected_len: usize) -> io::Result<()> {
if expected_len == 0 || expected_len > MAX_EXPORT_BYTES || !expected_len.is_multiple_of(4) {
return Err(invalid_path());
}
@@ -463,7 +484,7 @@ fn remove_stale_generations(base: &Path) {
continue;
}
if let Err(err) = fs::remove_dir_all(entry.path()) {
tracing::warn!(path = %entry.path().display(), err = %err, "failed to remove stale pane graphics directory");
tracing::warn!(target: LOG_TARGET, path = %entry.path().display(), err = %err, "failed to remove stale pane graphics directory");
}
}
}
+1
View File
@@ -40,6 +40,7 @@ rustPlatform.buildRustPackage {
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ./..)) (
lib.fileset.unions [
../assets
../crates
../distribution/install.ps1
../docs/next/api/herdr-api.schema.json
../src
+1 -1
View File
@@ -8,5 +8,5 @@ cd "$(dirname "$0")/.."
--allowlist-function 'ghostty_.*' \
--allowlist-var 'GHOSTTY_.*' \
--with-derive-default \
--output src/ghostty/bindings.rs \
--output crates/ghostty-vt/src/bindings.rs \
-- -Ivendor/libghostty-vt/include "$@"
+2 -2
View File
@@ -2364,7 +2364,7 @@ mod tests {
fn link_resolution_regions_keep_grapheme_byte_offsets() {
let mut terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap();
terminal.write("e\u{301}(https://example.com/路e\u{301}),".as_bytes());
let expected = vec![crate::api::schema::PaneLinkRegion {
let expected = vec![crate::ghostty::LinkRegion {
row: 0,
start_col: 2,
end_col: 24,
@@ -2392,7 +2392,7 @@ mod tests {
.unwrap();
assert_eq!(
regions,
vec![crate::api::schema::PaneLinkRegion {
vec![crate::ghostty::LinkRegion {
row: 0,
start_col: 0,
end_col: 3
+2 -2
View File
@@ -23,7 +23,7 @@ mod config;
mod copy_mode;
mod detect;
mod events;
mod ghostty;
use ghostty_vt as ghostty;
mod handoff_runtime;
mod input;
mod integration;
@@ -34,7 +34,7 @@ mod logging;
mod metadata_tokens;
mod noninteractive_process;
mod pane;
mod pane_graphics_files;
use ghostty_vt::pane_graphics_files;
mod persist;
mod platform;
mod plugin_command;
+1 -1
View File
@@ -65,7 +65,7 @@ pub(crate) struct TerminalDirtyPatchSnapshot {
const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1);
const TERMINAL_COMPRESSION_IDLE: std::time::Duration = std::time::Duration::from_millis(250);
const TERMINAL_COMPRESSION_STEP: std::time::Duration = std::time::Duration::from_millis(1);
pub(crate) const PANE_TERM: &str = "xterm-256color";
pub(crate) const PANE_TERM: &str = crate::ghostty::TERM;
const PANE_COLORTERM: &str = "truecolor";
fn terminal_compression_permits() -> Arc<tokio::sync::Semaphore> {
+7
View File
@@ -2289,6 +2289,13 @@ impl GhosttyPaneTerminal {
.ok()
})
.unwrap_or_default()
.into_iter()
.map(|region| crate::api::schema::PaneLinkRegion {
row: region.row,
start_col: region.start_col,
end_col: region.end_col,
})
.collect()
}
pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option<crate::ghostty::LinkTarget> {
-3
View File
@@ -1,6 +1,3 @@
mod native_image_sources;
pub(crate) use native_image_sources::clone_native_image_source;
use std::{
collections::{HashSet, VecDeque},
io::{Read, Write},
-13
View File
@@ -727,16 +727,3 @@ pub(crate) fn shared_ssh_control_path(
"interactive SSH recovery requires Unix OpenSSH multiplexing",
))
}
/// Kernel CoW snapshots are deliberately unsupported outside Linux.
#[cfg(not(target_os = "linux"))]
pub(crate) fn clone_native_image_source(
_source_fd: i64,
_destination: &std::fs::File,
_expected_len: usize,
) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"native source cloning requires Linux",
))
}
+1 -8
View File
@@ -1,14 +1,7 @@
use std::{mem::size_of, ptr};
// Reuse the generated C API; this test observer needs only terminal parsing and formatting.
#[allow(
dead_code,
non_camel_case_types,
non_snake_case,
non_upper_case_globals
)]
#[path = "../../src/ghostty/bindings.rs"]
mod ffi;
use ghostty_vt::ffi;
struct Screen {
terminal: ffi::GhosttyTerminal,