From fff6c820aa45f4eabb9b2e0456326dc74cca5a25 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Sun, 27 Sep 2026 00:06:29 +0300 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 10 +- .github/workflows/nix.yml | 2 + .zed/settings.json | 2 +- Cargo.lock | 11 ++ Cargo.toml | 5 + build.rs | 109 -------------- crates/ghostty-vt/Cargo.toml | 14 ++ crates/ghostty-vt/build.rs | 137 ++++++++++++++++++ .../ghostty-vt/src}/bindings.rs | 0 .../mod.rs => crates/ghostty-vt/src/lib.rs | 75 ++++++---- .../ghostty-vt/src}/native_image_sources.rs | 0 .../ghostty-vt/src}/native_source.rs | 0 .../ghostty-vt/src}/native_source_tests.rs | 0 .../ghostty-vt/src}/pane_graphics_files.rs | 55 ++++--- .../ghostty-vt/src}/png_forward_tests.rs | 0 nix/package.nix | 1 + scripts/generate_libghostty_bindings.sh | 2 +- src/app/actions.rs | 4 +- src/main.rs | 4 +- src/pane.rs | 2 +- src/pane/terminal.rs | 7 + src/platform/linux.rs | 3 - src/platform/mod.rs | 13 -- tests/support/terminal_screen.rs | 9 +- 24 files changed, 273 insertions(+), 192 deletions(-) create mode 100644 crates/ghostty-vt/Cargo.toml create mode 100644 crates/ghostty-vt/build.rs rename {src/ghostty => crates/ghostty-vt/src}/bindings.rs (100%) rename src/ghostty/mod.rs => crates/ghostty-vt/src/lib.rs (98%) rename {src/platform/linux => crates/ghostty-vt/src}/native_image_sources.rs (100%) rename {src/ghostty => crates/ghostty-vt/src}/native_source.rs (100%) rename {src/ghostty => crates/ghostty-vt/src}/native_source_tests.rs (100%) rename {src => crates/ghostty-vt/src}/pane_graphics_files.rs (94%) rename {src/ghostty => crates/ghostty-vt/src}/png_forward_tests.rs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5471489d..322fe853 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 1a3b0682..8bd166b7 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -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" diff --git a/.zed/settings.json b/.zed/settings.json index c39ed302..ea741556 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -46,7 +46,7 @@ "exclude": [ "target", "vendor", - "src/ghostty/bindings.rs" + "crates/ghostty-vt/src/bindings.rs" ] }, "lru": { diff --git a/Cargo.lock b/Cargo.lock index 7cac0169..0ca58835 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 0c18ec55..c1779763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/build.rs b/build.rs index 1c4c7f74..99596d8e 100644 --- a/build.rs +++ b/build.rs @@ -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 { - 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"); - } } diff --git a/crates/ghostty-vt/Cargo.toml b/crates/ghostty-vt/Cargo.toml new file mode 100644 index 00000000..53c44fda --- /dev/null +++ b/crates/ghostty-vt/Cargo.toml @@ -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" diff --git a/crates/ghostty-vt/build.rs b/crates/ghostty-vt/build.rs new file mode 100644 index 00000000..af02e0a7 --- /dev/null +++ b/crates/ghostty-vt/build.rs @@ -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 { + 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"); + } +} diff --git a/src/ghostty/bindings.rs b/crates/ghostty-vt/src/bindings.rs similarity index 100% rename from src/ghostty/bindings.rs rename to crates/ghostty-vt/src/bindings.rs diff --git a/src/ghostty/mod.rs b/crates/ghostty-vt/src/lib.rs similarity index 98% rename from src/ghostty/mod.rs rename to crates/ghostty-vt/src/lib.rs index 43a70fb1..ef62ef1e 100644 --- a/src/ghostty/mod.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -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, - pub(crate) source_file: Option>, + pub source_file: Option>, 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, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ScreenTextRow { +pub struct ScreenTextRow { pub cells: Vec, pub soft_wrapped: bool, pub wrap_continuation: bool, @@ -826,11 +832,19 @@ pub fn encode_focus(event: FocusEvent) -> Result, 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 { + pub fn compression_activity(&self) -> Result { 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 { + pub fn compress_incremental(&mut self) -> Result { 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 { + pub fn track_row(&mut self, y: u32) -> Option { 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, Error> { + pub fn screen_text_rows(&self) -> Result, 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, Error> { + pub fn viewport_link_target(&self, x: u16, y: u32) -> Result, 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>, - ) -> Result, Error> { + ) -> Result, 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 = Vec::new(); + let mut regions: Vec = 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 { + pub fn width_px(&self) -> Result { self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_WIDTH_PX) } - pub(crate) fn height_px(&self) -> Result { + pub fn height_px(&self) -> Result { 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 { + pub fn kitty_graphics_may_have_placements(&self) -> Result { 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 { // 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\\"); diff --git a/src/platform/linux/native_image_sources.rs b/crates/ghostty-vt/src/native_image_sources.rs similarity index 100% rename from src/platform/linux/native_image_sources.rs rename to crates/ghostty-vt/src/native_image_sources.rs diff --git a/src/ghostty/native_source.rs b/crates/ghostty-vt/src/native_source.rs similarity index 100% rename from src/ghostty/native_source.rs rename to crates/ghostty-vt/src/native_source.rs diff --git a/src/ghostty/native_source_tests.rs b/crates/ghostty-vt/src/native_source_tests.rs similarity index 100% rename from src/ghostty/native_source_tests.rs rename to crates/ghostty-vt/src/native_source_tests.rs diff --git a/src/pane_graphics_files.rs b/crates/ghostty-vt/src/pane_graphics_files.rs similarity index 94% rename from src/pane_graphics_files.rs rename to crates/ghostty-vt/src/pane_graphics_files.rs index 5c272bbe..c5f844d2 100644 --- a/src/pane_graphics_files.rs +++ b/crates/ghostty-vt/src/pane_graphics_files.rs @@ -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>, 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, @@ -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 { + pub fn snapshot(&self, source_fd: i64, expected_len: usize) -> io::Result { 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 { + // 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 { 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 { + pub fn export(&self, data: &[u8]) -> io::Result { 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> { + pub fn copy_rgba(&self) -> io::Result> { 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"); } } } diff --git a/src/ghostty/png_forward_tests.rs b/crates/ghostty-vt/src/png_forward_tests.rs similarity index 100% rename from src/ghostty/png_forward_tests.rs rename to crates/ghostty-vt/src/png_forward_tests.rs diff --git a/nix/package.nix b/nix/package.nix index 2a506c3a..06ea6055 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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 diff --git a/scripts/generate_libghostty_bindings.sh b/scripts/generate_libghostty_bindings.sh index c87efca7..0868b73e 100644 --- a/scripts/generate_libghostty_bindings.sh +++ b/scripts/generate_libghostty_bindings.sh @@ -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 "$@" diff --git a/src/app/actions.rs b/src/app/actions.rs index 26bdf0e4..6a6e99d2 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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 diff --git a/src/main.rs b/src/main.rs index d5275529..45a64095 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; diff --git a/src/pane.rs b/src/pane.rs index 7d42e9c0..35534135 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -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 { diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index d0b9d3ca..5eaddb21 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -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 { diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 9be40488..c73584e5 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -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}, diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 65717228..927cd32b 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -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", - )) -} diff --git a/tests/support/terminal_screen.rs b/tests/support/terminal_screen.rs index 45d7731b..b9d51759 100644 --- a/tests/support/terminal_screen.rs +++ b/tests/support/terminal_screen.rs @@ -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,