From 1bd7c19dac8cfa03b5c6b24bf6c7e6703c30c151 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 4 Apr 2026 15:01:14 -0500 Subject: [PATCH 01/62] nix: add option to disable simd in libghostty-vt package --- .github/workflows/test.yml | 12 ++++++++++++ flake.nix | 3 +++ nix/libghostty-vt.nix | 2 ++ 3 files changed, 17 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e292c078..7c40ad3e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -770,12 +770,24 @@ jobs: - name: Check to see if the library looks sane run: nm result/lib/libghostty-vt.so.0.1.0 2>&1 | grep -q 'ghostty_terminal_new' + - name: Test ReleaseFast (no SIMD) build of libghostty-vt + run: nix build .#libghostty-vt-releasefast-no-simd + + - name: Check to see if the library looks sane + run: nm result/lib/libghostty-vt.so.0.1.0 2>&1 | grep -q 'ghostty_terminal_new' + - name: Test Debug build of libghostty-vt run: nix build .#libghostty-vt-debug - name: Check to see if the library looks sane run: nm result/lib/libghostty-vt.so.0.1.0 2>&1 | grep -q 'ghostty_terminal_new' + - name: Test Debug (no SIMD) build of libghostty-vt + run: nix build .#libghostty-vt-debug-no-simd + + - name: Check to see if the library looks sane + run: nm result/lib/libghostty-vt.so.0.1.0 2>&1 | grep -q 'ghostty_terminal_new' + build-dist: runs-on: namespace-profile-ghostty-sm needs: test diff --git a/flake.nix b/flake.nix index 59ced2def..7d1cb0d31 100644 --- a/flake.nix +++ b/flake.nix @@ -106,6 +106,9 @@ libghostty-vt-debug = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "Debug"); libghostty-vt-releasesafe = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseSafe"); libghostty-vt-releasefast = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseFast"); + libghostty-vt-debug-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "Debug") // {simd = false;}); + libghostty-vt-releasesafe-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseSafe") // {simd = false;}); + libghostty-vt-releasefast-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseFast") // {simd = false;}); libghostty-vt = libghostty-vt-releasefast; }); diff --git a/nix/libghostty-vt.nix b/nix/libghostty-vt.nix index fbe87ef0a..2a80d0af4 100644 --- a/nix/libghostty-vt.nix +++ b/nix/libghostty-vt.nix @@ -7,6 +7,7 @@ zig_0_15, revision ? "dirty", optimize ? "Debug", + simd ? true, }: stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; @@ -55,6 +56,7 @@ stdenv.mkDerivation (finalAttrs: { "-Doptimize=${optimize}" "-Dapp-runtime=none" "-Demit-lib-vt=true" + "-Dsimd=${lib.boolToString simd}" ]; outputs = [ From 06144d30f2541508d1fe8f10083bd87ff422af72 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 4 Apr 2026 17:04:01 -0500 Subject: [PATCH 02/62] libghostty-vt: allow version to be customized from the Zig build command --- build.zig | 3 ++- nix/libghostty-vt.nix | 4 ++-- src/build/Config.zig | 37 ++++++++++++++++++++++++++++------ src/build/GhosttyLibVt.zig | 8 ++++---- src/build/GhosttyZig.zig | 7 ++++++- src/build/SharedDeps.zig | 2 +- src/terminal/build_options.zig | 17 ++++++++-------- 7 files changed, 55 insertions(+), 23 deletions(-) diff --git a/build.zig b/build.zig index 45ad3ba15..78977a8c9 100644 --- a/build.zig +++ b/build.zig @@ -7,7 +7,7 @@ const buildpkg = @import("src/build/main.zig"); const app_zon_version = @import("build.zig.zon").version; /// Libghostty version. We use a separate version from the app. -const lib_version = "0.1.0"; +const lib_version = "0.1.0-dev"; /// Minimum required zig version. const minimum_zig_version = @import("build.zig.zon").minimum_zig_version; @@ -37,6 +37,7 @@ pub fn build(b: *std.Build) !void { const config = try buildpkg.Config.init( b, file_version orelse app_zon_version, + lib_version, ); const test_filters = b.option( [][]const u8, diff --git a/nix/libghostty-vt.nix b/nix/libghostty-vt.nix index 2a80d0af4..bcd229ec2 100644 --- a/nix/libghostty-vt.nix +++ b/nix/libghostty-vt.nix @@ -11,7 +11,7 @@ }: stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "0.1.0-dev"; + version = "0.1.0-dev+${revision}-nix"; # We limit source like this to try and reduce the amount of rebuilds as possible # thus we only provide the source that is needed for the build @@ -51,7 +51,7 @@ stdenv.mkDerivation (finalAttrs: { zigBuildFlags = [ "--system" "${finalAttrs.deps}" - "-Dversion-string=${finalAttrs.version}-${revision}-nix" + "-Dlib-version-string=${finalAttrs.version}" "-Dcpu=baseline" "-Doptimize=${optimize}" "-Dapp-runtime=none" diff --git a/src/build/Config.zig b/src/build/Config.zig index 88968aab7..797a00ddb 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -38,6 +38,7 @@ wasm_shared: bool = true, /// Ghostty exe properties exe_entrypoint: ExeEntrypoint = .ghostty, version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, +lib_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, /// Binary properties pie: bool = false, @@ -68,7 +69,7 @@ is_dep: bool = false, /// Environmental properties env: std.process.EnvMap, -pub fn init(b: *std.Build, appVersion: []const u8) !Config { +pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Config { // Setup our standard Zig target and optimize options, i.e. // `-Doptimize` and `-Dtarget`. const optimize = b.standardOptimizeOption(.{}); @@ -294,6 +295,20 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { }; }; + // libghostty-vt properties + + const lib_version_string = b.option( + []const u8, + "lib-version-string", + "A specific version string to use for the build of libghostty-vt. " ++ + "If not specified, git will be used. This must be a semantic version.", + ); + + config.lib_version = if (lib_version_string) |v| + try std.SemanticVersion.parse(v) + else + try std.SemanticVersion.parse(libVersion); + //--------------------------------------------------------------- // Binary Properties @@ -519,13 +534,20 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { // Our version. We also add the string version so we don't need // to do any allocations at runtime. This has to be long enough to // accommodate realistic large branch names for dev versions. - var buf: [1024]u8 = undefined; + var app_version_buf: [1024]u8 = undefined; step.addOption(std.SemanticVersion, "app_version", self.version); step.addOption([:0]const u8, "app_version_string", try std.fmt.bufPrintZ( - &buf, + &app_version_buf, "{f}", .{self.version}, )); + var lib_version_buf: [1024]u8 = undefined; + step.addOption(std.SemanticVersion, "lib_version", self.lib_version); + step.addOption([:0]const u8, "lib_version_string", try std.fmt.bufPrintZ( + &lib_version_buf, + "{f}", + .{self.lib_version}, + )); step.addOption( ReleaseChannel, "release_channel", @@ -539,13 +561,16 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { /// Returns the build options for the terminal module. This assumes a /// Ghostty executable being built. Callers should modify this as needed. -pub fn terminalOptions(self: *const Config) TerminalBuildOptions { +pub fn terminalOptions(self: *const Config, artifact: TerminalBuildOptions.Artifact) TerminalBuildOptions { return .{ - .artifact = .ghostty, + .artifact = artifact, .simd = self.simd, .oniguruma = true, .c_abi = false, - .version = self.version, + .version = switch (artifact) { + .ghostty => self.version, + .lib => self.lib_version, + }, .slow_runtime_safety = switch (self.optimize) { .Debug => true, .ReleaseSafe, diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 3e1be4777..d5e76b9de 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -40,7 +40,7 @@ pub fn initWasm( const exe = b.addExecutable(.{ .name = "ghostty-vt", .root_module = zig.vt_c, - .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 }, + .version = zig.version, }); // Allow exported symbols to actually be exported. @@ -113,7 +113,7 @@ fn initLib( .name = if (kind == .static) "ghostty-vt-static" else "ghostty-vt", .linkage = linkage, .root_module = zig.vt_c, - .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 }, + .version = zig.version, }); lib.installHeadersDirectory( b.path("include/ghostty"), @@ -184,12 +184,12 @@ fn initLib( \\Name: libghostty-vt \\URL: https://github.com/ghostty-org/ghostty \\Description: Ghostty VT library - \\Version: 0.1.0 + \\Version: {f} \\Cflags: -I${{includedir}} \\Libs: -L${{libdir}} -lghostty-vt \\Libs.private: {s} \\Requires.private: {s} - , .{ b.install_prefix, libsPrivate(zig), requiresPrivate(b) })); + , .{ b.install_prefix, zig.version, libsPrivate(zig), requiresPrivate(b) })); }; // For static libraries with vendored SIMD dependencies, combine diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig index 4901180d1..3f3db95c6 100644 --- a/src/build/GhosttyZig.zig +++ b/src/build/GhosttyZig.zig @@ -11,6 +11,9 @@ const TerminalBuildOptions = @import("../terminal/build_options.zig").Options; vt: *std.Build.Module, vt_c: *std.Build.Module, +/// The libghostty-vt version +version: std.SemanticVersion, + /// Static library paths for vendored SIMD dependencies. Populated /// only when the dependencies are built from source (not provided /// by the system via -Dsystem-integration). Used to produce a @@ -23,7 +26,7 @@ pub fn init( deps: *const SharedDeps, ) !GhosttyZig { // Terminal module build options - var vt_options = cfg.terminalOptions(); + var vt_options = cfg.terminalOptions(.lib); vt_options.artifact = .lib; // We presently don't allow Oniguruma in our Zig module at all. // We should expose this as a build option in the future so we can @@ -55,6 +58,8 @@ pub fn init( &simd_libs, ), + .version = cfg.lib_version, + .simd_libs = simd_libs, }; } diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index aa63c0824..cb4bf7619 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -133,7 +133,7 @@ pub fn add( step.root_module.addOptions("build_options", self.options); // Every exe needs the terminal options - self.config.terminalOptions().add(b, step.root_module); + self.config.terminalOptions(.ghostty).add(b, step.root_module); // C imports for locale constants and functions { diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 6c0a4df63..8b2a70fe5 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -2,6 +2,14 @@ const std = @import("std"); /// Options set by Zig build.zig and exposed via `terminal_options`. pub const Options = struct { + pub const Artifact = enum { + /// Ghostty application + ghostty, + + /// libghostty-vt, Zig module + lib, + }; + /// The target artifact to build. This will gate some functionality. artifact: Artifact, @@ -65,16 +73,9 @@ pub const Options = struct { opts.addOption(usize, "version_major", self.version.major); opts.addOption(usize, "version_minor", self.version.minor); opts.addOption(usize, "version_patch", self.version.patch); + opts.addOption(?[]const u8, "version_pre", self.version.pre); opts.addOption(?[]const u8, "version_build", self.version.build); m.addOptions("terminal_options", opts); } }; - -pub const Artifact = enum { - /// Ghostty application - ghostty, - - /// libghostty-vt, Zig module - lib, -}; From cf8a2407a042a2e407fe58ade93582af6073c49d Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 03:17:59 +0000 Subject: [PATCH 03/62] Update VOUCHED list (#12113) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12098#discussioncomment-16452103) from @mitchellh. Vouch: @fru1tworld Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 1438c2f70..5228426a9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -82,6 +82,7 @@ faukah filip7 flou francescarpi +fru1tworld gagbo ghokun gmile From b9a241d1e237fa97bf8b3b161f253cc2313100f2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Apr 2026 20:23:08 -0700 Subject: [PATCH 04/62] libghostty: add hyperlink URI accessor to grid_ref API Add ghostty_grid_ref_hyperlink_uri to extract the OSC 8 hyperlink URI from a cell at a grid reference position. Follows the same buffer pattern as ghostty_grid_ref_graphemes: callers pass a buffer and get back the byte length, or GHOSTTY_OUT_OF_SPACE with the required size if the buffer is too small. Cells without a hyperlink return success with length 0. --- include/ghostty/vt/grid_ref.h | 26 ++++++++++ src/lib_vt.zig | 1 + src/terminal/c/grid_ref.zig | 94 +++++++++++++++++++++++++++++++++++ src/terminal/c/main.zig | 1 + 4 files changed, 122 insertions(+) diff --git a/include/ghostty/vt/grid_ref.h b/include/ghostty/vt/grid_ref.h index d3489ea73..1f9f52b9b 100644 --- a/include/ghostty/vt/grid_ref.h +++ b/include/ghostty/vt/grid_ref.h @@ -109,6 +109,32 @@ GHOSTTY_API GhosttyResult ghostty_grid_ref_graphemes(const GhosttyGridRef *ref, size_t buf_len, size_t *out_len); +/** + * Get the hyperlink URI for the cell at the grid reference's position. + * + * Writes the URI bytes into the provided buffer. If the cell has no + * hyperlink, out_len is set to 0 and GHOSTTY_SUCCESS is returned. + * + * If the buffer is too small (or NULL), the function returns + * GHOSTTY_OUT_OF_SPACE and writes the required number of bytes to + * out_len. The caller can then retry with a sufficiently sized buffer. + * + * @param ref Pointer to the grid reference + * @param buf Output buffer for the URI bytes (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_len On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size in bytes. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_hyperlink_uri( + const GhosttyGridRef *ref, + uint8_t *buf, + size_t buf_len, + size_t *out_len); + /** * Get the style of the cell at the grid reference's position. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index adfb11478..3edef835a 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -217,6 +217,7 @@ comptime { @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); + @export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" }); @export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" }); @export(&c.build_info, .{ .name = "ghostty_build_info" }); @export(&c.type_json, .{ .name = "ghostty_type_json" }); diff --git a/src/terminal/c/grid_ref.zig b/src/terminal/c/grid_ref.zig index d029c5951..c7e86c29f 100644 --- a/src/terminal/c/grid_ref.zig +++ b/src/terminal/c/grid_ref.zig @@ -3,11 +3,13 @@ const testing = std.testing; const lib = @import("../lib.zig"); const page = @import("../page.zig"); const PageList = @import("../PageList.zig"); +const point = @import("../point.zig"); const size = @import("../size.zig"); const stylepkg = @import("../style.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const style_c = @import("style.zig"); +const terminal_c = @import("terminal.zig"); const Result = @import("result.zig").Result; /// C: GhosttyGridRef @@ -89,6 +91,38 @@ pub fn grid_ref_graphemes( return .success; } +pub fn grid_ref_hyperlink_uri( + ref: *const CGridRef, + out_buf: ?[*]u8, + buf_len: usize, + out_len: *usize, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + const rac = p.node.data.getRowAndCell(p.x, p.y); + const cell = rac.cell; + + if (!cell.hyperlink) { + out_len.* = 0; + return .success; + } + + const link_id = p.node.data.lookupHyperlink(cell) orelse { + out_len.* = 0; + return .success; + }; + const entry = p.node.data.hyperlink_set.get(p.node.data.memory, link_id); + const uri = entry.uri.slice(p.node.data.memory); + + if (out_buf == null or buf_len < uri.len) { + out_len.* = uri.len; + return .out_of_space; + } + + @memcpy(out_buf.?[0..uri.len], uri); + out_len.* = uri.len; + return .success; +} + pub fn grid_ref_style( ref: *const CGridRef, out: ?*style_c.Style, @@ -154,3 +188,63 @@ test "grid_ref_style null out" { const ref = CGridRef{}; try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, null)); } + +test "grid_ref_hyperlink_uri null node" { + const ref = CGridRef{}; + var len: usize = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_hyperlink_uri(&ref, null, 0, &len)); +} + +test "grid_ref_hyperlink_uri no hyperlink" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "hello", 5); + + var ref: CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + + var len: usize = undefined; + try testing.expectEqual(Result.success, grid_ref_hyperlink_uri(&ref, null, 0, &len)); + try testing.expectEqual(@as(usize, 0), len); +} + +test "grid_ref_hyperlink_uri with hyperlink" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + // Write OSC 8 hyperlink: \e]8;;uri\e\\text\e]8;;\e\\ + const seq = "\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"; + terminal_c.vt_write(terminal, seq, seq.len); + + var ref: CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + + // First query length with null buf + var len: usize = undefined; + try testing.expectEqual(Result.out_of_space, grid_ref_hyperlink_uri(&ref, null, 0, &len)); + try testing.expectEqual(@as(usize, 19), len); // "https://example.com" + + // Now read with a properly sized buffer + var buf: [256]u8 = undefined; + try testing.expectEqual(Result.success, grid_ref_hyperlink_uri(&ref, &buf, buf.len, &len)); + try testing.expectEqualStrings("https://example.com", buf[0..len]); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 170567796..699ae5ade 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -148,6 +148,7 @@ pub const type_json = types.get_json; pub const grid_ref_cell = grid_ref.grid_ref_cell; pub const grid_ref_row = grid_ref.grid_ref_row; pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; +pub const grid_ref_hyperlink_uri = grid_ref.grid_ref_hyperlink_uri; pub const grid_ref_style = grid_ref.grid_ref_style; test { From 757eff5881b9afb811a99497fb5c231cc3677a6b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Apr 2026 20:35:40 -0700 Subject: [PATCH 05/62] libghostty: add GhosttySelection type and selection support to formatter Add a new GhosttySelection C API type (selection.h / c/selection.zig) that pairs two GhosttyGridRef endpoints with a rectangle flag. This maps directly to the internal Selection type using untracked pins. The formatter terminal options gain an optional selection pointer. When non-null the formatter restricts output to the specified range instead of emitting the entire screen. When null the existing behavior of formatting the full screen is preserved. --- include/ghostty/vt.h | 1 + include/ghostty/vt/formatter.h | 5 +++ include/ghostty/vt/selection.h | 53 +++++++++++++++++++++++++++++++ src/terminal/c/formatter.zig | 58 ++++++++++++++++++++++++++++++++++ src/terminal/c/grid_ref.zig | 2 +- src/terminal/c/main.zig | 2 ++ src/terminal/c/selection.zig | 16 ++++++++++ src/terminal/c/types.zig | 2 ++ 8 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 include/ghostty/vt/selection.h create mode 100644 src/terminal/c/selection.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 2a52f4b08..6a943350c 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -123,6 +123,7 @@ extern "C" { #include #include #include +#include #include #include diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h index 81efdb27c..19f6664c3 100644 --- a/include/ghostty/vt/formatter.h +++ b/include/ghostty/vt/formatter.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -133,6 +134,10 @@ typedef struct { /** Extra terminal state to include in styled output. */ GhosttyFormatterTerminalExtra extra; + + /** Optional selection to restrict output to a range. + * If NULL, the entire screen is formatted. */ + const GhosttySelection *selection; } GhosttyFormatterTerminalOptions; /** diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h new file mode 100644 index 000000000..9f878fadc --- /dev/null +++ b/include/ghostty/vt/selection.h @@ -0,0 +1,53 @@ +/** + * @file selection.h + * + * Selection range type for specifying a region of terminal content. + */ + +#ifndef GHOSTTY_VT_SELECTION_H +#define GHOSTTY_VT_SELECTION_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup selection Selection + * + * A selection range defined by two grid references that identifies a + * contiguous or rectangular region of terminal content. + * + * @{ + */ + +/** + * A selection range defined by two grid references. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttySelection). */ + size_t size; + + /** Start of the selection range (inclusive). */ + GhosttyGridRef start; + + /** End of the selection range (inclusive). */ + GhosttyGridRef end; + + /** Whether the selection is rectangular (block) rather than linear. */ + bool rectangle; +} GhosttySelection; + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SELECTION_H */ diff --git a/src/terminal/c/formatter.zig b/src/terminal/c/formatter.zig index 11717bc22..5b4504c8c 100644 --- a/src/terminal/c/formatter.zig +++ b/src/terminal/c/formatter.zig @@ -3,6 +3,8 @@ const testing = std.testing; const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; const terminal_c = @import("terminal.zig"); +const grid_ref = @import("grid_ref.zig"); +const selection_c = @import("selection.zig"); const ZigTerminal = @import("../Terminal.zig"); const formatterpkg = @import("../formatter.zig"); const Result = @import("result.zig").Result; @@ -23,6 +25,8 @@ pub const Formatter = ?*FormatterWrapper; /// C: GhosttyFormatterFormat pub const Format = formatterpkg.Format; +const CSelection = selection_c.CSelection; + /// C: GhosttyFormatterScreenOptions pub const ScreenOptions = extern struct { /// C: GhosttyFormatterScreenExtra @@ -63,6 +67,10 @@ pub const TerminalOptions = extern struct { trim: bool, extra: Extra, + /// Optional selection to restrict output to a range. + /// If null, the entire screen is formatted. + selection: ?*const CSelection = null, + /// C: GhosttyFormatterTerminalExtra pub const Extra = extern struct { size: usize = @sizeOf(Extra), @@ -138,6 +146,12 @@ fn terminal_new_( }); formatter.extra = opts.extra.toZig(); + // Setup the content that we're formatting + if (opts.selection) |sel| formatter.content = .{ + .selection = sel.toZig() orelse + return error.InvalidValue, + }; + ptr.* = .{ .kind = .{ .terminal = formatter }, .alloc = alloc, @@ -389,6 +403,50 @@ test "format vt" { try testing.expect(std.mem.indexOf(u8, buf[0..written], "Test") != null); } +test "format plain with selection" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello World", 11); + + // Get grid refs for "World" (columns 6..10 on row 0) + var start_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 6, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 10, .y = 0 } }, + }, &end_ref)); + + const sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .selection = &sel, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("World", buf[0..written]); +} + test "format html" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/src/terminal/c/grid_ref.zig b/src/terminal/c/grid_ref.zig index d029c5951..f4996f4a1 100644 --- a/src/terminal/c/grid_ref.zig +++ b/src/terminal/c/grid_ref.zig @@ -29,7 +29,7 @@ pub const CGridRef = extern struct { }; } - fn toPin(self: CGridRef) ?PageList.Pin { + pub fn toPin(self: CGridRef) ?PageList.Pin { return .{ .node = self.node orelse return null, .x = self.x, diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 170567796..11dea34c5 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -12,6 +12,7 @@ pub const types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); pub const render = @import("render.zig"); +pub const selection = @import("selection.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); pub const mouse_event = @import("mouse_event.zig"); @@ -162,6 +163,7 @@ test { _ = modes; _ = osc; _ = render; + _ = selection; _ = key_event; _ = key_encode; _ = mouse_event; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig new file mode 100644 index 000000000..74e96598f --- /dev/null +++ b/src/terminal/c/selection.zig @@ -0,0 +1,16 @@ +const grid_ref = @import("grid_ref.zig"); +const Selection = @import("../Selection.zig"); + +/// C: GhosttySelection +pub const CSelection = extern struct { + size: usize = @sizeOf(CSelection), + start: grid_ref.CGridRef, + end: grid_ref.CGridRef, + rectangle: bool = false, + + pub fn toZig(self: CSelection) ?Selection { + const start_pin = self.start.toPin() orelse return null; + const end_pin = self.end.toPin() orelse return null; + return Selection.init(start_pin, end_pin, self.rectangle); + } +}; diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index b808bf38b..500809d9c 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -13,6 +13,7 @@ const size_report = @import("size_report.zig"); const terminal = @import("terminal.zig"); const formatter = @import("formatter.zig"); +const selection = @import("selection.zig"); const render = @import("render.zig"); const style_c = @import("style.zig"); const mouse_encode = @import("mouse_encode.zig"); @@ -26,6 +27,7 @@ pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{ .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, + .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, From a8e92c9c53e5c6018507c6f1e06af4f3b3e4f49c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Apr 2026 20:57:04 -0700 Subject: [PATCH 06/62] terminal: add APC handler to stream_terminal Wire up the APC handler to `terminal.TerminalStream` to process APC sequences, enabling support for kitty graphics commands in libghostty, in theory. The "in theory" is because we still don't export a way to actually enable Kitty graphics in libghostty because we have some other things in the way: PNG decoding and OS filesystem access that need to be more conditionally compiled before we can enable the feature. However, this is a step in the right direction, and we can at least verify that the APC handler works via a test in Ghostty GUI. --- src/terminal/apc.zig | 10 ++-- src/terminal/stream_terminal.zig | 100 +++++++++++++++++++++++++++---- 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 3ebacbbff..3cbadb8e0 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -10,7 +10,7 @@ const log = std.log.scoped(.terminal_apc); /// The start/feed/end functions are meant to be called from the terminal.Stream /// apcStart, apcPut, and apcEnd functions, respectively. pub const Handler = struct { - state: State = .{ .inactive = {} }, + state: State = .inactive, pub fn deinit(self: *Handler) void { self.state.deinit(); @@ -36,17 +36,17 @@ pub const Handler = struct { 'G' => self.state = if (comptime build_options.kitty_graphics) .{ .kitty = kitty_gfx.CommandParser.init(alloc) } else - .{ .ignore = {} }, + .ignore, // Unknown - else => self.state = .{ .ignore = {} }, + else => self.state = .ignore, } }, .kitty => |*p| if (comptime build_options.kitty_graphics) { p.feed(byte) catch |err| { log.warn("kitty graphics protocol error: {}", .{err}); - self.state = .{ .ignore = {} }; + self.state = .ignore; }; } else unreachable, } @@ -55,7 +55,7 @@ pub const Handler = struct { pub fn end(self: *Handler) ?Command { defer { self.state.deinit(); - self.state = .{ .inactive = {} }; + self.state = .inactive; } return switch (self.state) { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 8e0f91110..f68f088bf 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const build_options = @import("terminal_options"); const testing = std.testing; +const apc = @import("apc.zig"); const csi = @import("csi.zig"); const device_attributes = @import("device_attributes.zig"); const device_status = @import("device_status.zig"); @@ -35,6 +37,12 @@ pub const Handler = struct { /// effects. effects: Effects = .readonly, + /// The APC command handler maintains the APC state. APC is like + /// CSI or OSC, but it is a private escape sequence that is used + /// to send commands to the terminal emulator. This is used by + /// the kitty graphics protocol. + apc_handler: apc.Handler = .{}, + pub const Effects = struct { /// Called when the terminal needs to write data back to the pty, /// e.g. in response to a DECRQM query. The data is only valid @@ -98,9 +106,7 @@ pub const Handler = struct { } pub fn deinit(self: *Handler) void { - // Currently does nothing but may in the future so callers should - // call this. - _ = self; + self.apc_handler.deinit(); } pub fn vt( @@ -230,6 +236,11 @@ pub const Handler = struct { .color_operation => try self.colorOperation(value.op, &value.requests), .kitty_color_report => try self.kittyColorOperation(value), + // APC + .apc_start => self.apc_handler.start(), + .apc_put => self.apc_handler.feed(self.terminal.gpa(), value), + .apc_end => self.apcEnd(), + // Effect-based handlers .bell => self.bell(), .device_attributes => self.reportDeviceAttributes(value), @@ -249,13 +260,6 @@ pub const Handler = struct { .dcs_unhook, => {}, - // APC can modify terminal state (Kitty graphics) but we don't - // currently support it in the readonly stream. - .apc_start, - .apc_end, - .apc_put, - => {}, - // Have no terminal-modifying effect .report_pwd, .show_desktop_notification, @@ -650,6 +654,33 @@ pub const Handler = struct { } } } + + fn apcEnd(self: *Handler) void { + const alloc = self.terminal.gpa(); + var cmd = self.apc_handler.end() orelse return; + defer cmd.deinit(alloc); + + switch (cmd) { + .kitty => |*kitty_cmd| if (comptime build_options.kitty_graphics) { + if (self.terminal.kittyGraphics( + alloc, + kitty_cmd, + )) |resp| resp: { + // Don't waste time encoding if we can't write responses + // anyways. + if (self.effects.write_pty == null) break :resp; + + // Encode and write the response if we have one. + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + resp.encode(&writer) catch return; + writer.writeByte(0) catch return; + const final = writer.buffered(); + if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]); + } + }, + } + } }; test "basic print" { @@ -2069,3 +2100,52 @@ test "device attributes: custom response" { s.nextSlice("\x1B[>c"); try testing.expectEqualStrings("\x1b[>41;100;0c", S.written.?); } + +test "kitty graphics APC response" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Send a kitty graphics transmit command with image id 1 + s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\"); + + // Should have written a response back + try testing.expectEqualStrings("\x1b_Gi=1;OK\x1b\\", S.written.?); +} + +test "kitty graphics via APC" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const handler: Handler = .init(&t); + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Send a kitty graphics transmit command via APC: + // ESC _ G ESC \ + // a=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;//////// (1x2 RGB direct) + s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\"); + + const storage = &t.screens.active.kitty_images; + const img = storage.imageById(1).?; + try testing.expectEqual(.rgb, img.format); +} From 29e3de737e9cc4c4d6a3ac9624bbd26c87bf0eb2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Apr 2026 21:36:54 -0700 Subject: [PATCH 07/62] terminal: make wuffs runtime-swappable, enable Kitty graphics for libvt Introduce terminal/sys.zig which provides runtime-swappable function pointers for operations that depend on external implementations. This allows embedders of the terminal package to swap out implementations at startup without hard dependencies on specific libraries. The first function exposed is decode_png, which defaults to a wuffs implementation. The kitty graphics image loader now calls through sys.decode_png instead of importing wuffs directly. This allows us to enable Kitty graphics support in libghostty-vt for all targets except wasm32-freestanding. --- src/lib_vt.zig | 18 +++++++++ src/terminal/build_options.zig | 17 ++++++++- src/terminal/kitty/graphics_image.zig | 11 ++++-- src/terminal/main.zig | 1 + src/terminal/sys.zig | 54 +++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 src/terminal/sys.zig diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 3edef835a..665058b68 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -19,6 +19,24 @@ const builtin = @import("builtin"); // or are too Ghostty-internal. const terminal = @import("terminal/main.zig"); +/// System interface for the terminal package. +/// +/// This module provides runtime-swappable function pointers for operations +/// that depend on external implementations. Embedders can use this to +/// provide or override default behaviors. These must be set at startup +/// before any terminal functionality is used. +/// +/// This lets libghostty-vt have no runtime dependencies on external +/// libraries, while still allowing rich functionality that may require +/// external libraries (e.g. image decoding or regular expresssions). +/// +/// Setting these will enable various features of the terminal package. +/// For example, setting a PNG decoder will enable support for PNG images in +/// the Kitty Graphics Protocol. +/// +/// Additional functionality will be added here over time as needed. +pub const sys = terminal.sys; + pub const apc = terminal.apc; pub const dcs = terminal.dcs; pub const osc = terminal.osc; diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 6c0a4df63..5f851c55c 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -47,8 +47,23 @@ pub const Options = struct { opts.addOption(bool, "simd", self.simd); opts.addOption(bool, "slow_runtime_safety", self.slow_runtime_safety); + // Kitty graphics is almost always true. This used to be conditional on + // some other factors but we've since generalized the implementation + // to support optional PNG decoding, OS capabilities like filesystems, + // etc. So its safe to always enable it and just have the + // implementation deal with unsupported features as needed. + // + // We disable it on wasm32-freestanding because we at the least + // require the ability to get timestamps and there is no way to + // do that with freestanding targets. + const target = m.resolved_target.?.result; + opts.addOption( + bool, + "kitty_graphics", + !(target.cpu.arch == .wasm32 and target.os.tag == .freestanding), + ); + // These are synthesized based on other options. - opts.addOption(bool, "kitty_graphics", self.oniguruma); opts.addOption(bool, "tmux_control_mode", self.oniguruma); // Version information. diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index d2877cfc2..bf11507b4 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -8,7 +8,7 @@ const posix = std.posix; const fastmem = @import("../../fastmem.zig"); const command = @import("graphics_command.zig"); const PageList = @import("../PageList.zig"); -const wuffs = @import("wuffs"); +const sys = @import("../sys.zig"); const temp_dir = struct { const TempDir = @import("../../os/TempDir.zig"); @@ -426,13 +426,14 @@ pub const LoadingImage = struct { fn decodePng(self: *LoadingImage, alloc: Allocator) !void { assert(self.image.format == .png); - const result = wuffs.png.decode( + const decode_png_fn = sys.decode_png orelse + return error.UnsupportedFormat; + const result = decode_png_fn( alloc, self.data.items, ) catch |err| switch (err) { - error.WuffsError => return error.InvalidData, + error.InvalidData => return error.InvalidData, error.OutOfMemory => return error.OutOfMemory, - error.Overflow => return error.InvalidData, }; defer alloc.free(result.data); @@ -799,6 +800,8 @@ test "image load: rgb, not compressed, regular file" { } test "image load: png, not compressed, regular file" { + if (sys.decode_png == null) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 9f5b65e34..87a9aded9 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -23,6 +23,7 @@ pub const search = @import("search.zig"); pub const sgr = @import("sgr.zig"); pub const size = @import("size.zig"); pub const size_report = @import("size_report.zig"); +pub const sys = @import("sys.zig"); pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {}; pub const x11_color = @import("x11_color.zig"); diff --git a/src/terminal/sys.zig b/src/terminal/sys.zig new file mode 100644 index 000000000..f0c64da50 --- /dev/null +++ b/src/terminal/sys.zig @@ -0,0 +1,54 @@ +//! System interface for the terminal package. +//! +//! This provides runtime-swappable function pointers for operations that +//! depend on external implementations (e.g. image decoding). Each function +//! pointer is initialized with a default implementation if available. +//! +//! This exists so that the terminal package doesn't have hard dependencies +//! on specific libraries and enables embedders of the terminal package to +//! swap out implementations as needed at startup to provide their own +//! implementations. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const build_options = @import("terminal_options"); + +/// Decode PNG data into RGBA pixels. If null, PNG decoding is unsupported +/// and the exact semantics are up to callers. For example, the Kitty Graphics +/// Protocol will work but cannot accept PNG images. +pub var decode_png: ?DecodePngFn = png: { + if (build_options.artifact == .lib) break :png null; + break :png &decodePngWuffs; +}; + +pub const DecodeError = Allocator.Error || error{InvalidData}; +pub const DecodePngFn = *const fn (Allocator, []const u8) DecodeError!Image; + +/// The result of decoding an image. The caller owns the returned data +/// and must free it with the same allocator that was passed to the +/// decode function. +pub const Image = struct { + width: u32, + height: u32, + data: []u8, +}; + +fn decodePngWuffs( + alloc: Allocator, + data: []const u8, +) DecodeError!Image { + const wuffs = @import("wuffs"); + const result = wuffs.png.decode( + alloc, + data, + ) catch |err| switch (err) { + error.WuffsError => return error.InvalidData, + error.OutOfMemory => return error.OutOfMemory, + error.Overflow => return error.InvalidData, + }; + + return .{ + .width = result.width, + .height = result.height, + .data = result.data, + }; +} From 355aecb6ba26584c4430377dc0f6e9a0b0d59fe0 Mon Sep 17 00:00:00 2001 From: jamylak Date: Fri, 3 Apr 2026 20:07:25 +1100 Subject: [PATCH 08/62] macos: cancel deferred tab presentation on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 👻 Ghost Tab Issue Previous failure scenario (User perspective): 1. Open a new tab 2. Instantly trigger close other tabs (eg. through custom user keyboard shortcut) 3. Now you will see an empty Ghost Tab (Only a window bar with empty content) The previous failure mode is: 1. Create a tab or window now in `newTab(...)` / `newWindow(...)`. 2. Queue its initial show/focus work with `DispatchQueue.main.async`. 3. Close that tab or window with `closeTabImmediately()` / `closeWindowImmediately()` before the queued callback runs. 4. The queued callback still runs anyway and calls `showWindow(...)` / `makeKeyAndOrderFront(...)` on stale state. 5. The tab can be resurrected as a half-closed blank ghost tab. The fix: - Store deferred presentation work in a cancellable DispatchWorkItem and cancel it from the close paths before AppKit finishes tearing down the tab or window. - This prevents the stale show/focus callback from running after close. --- .../Terminal/TerminalController.swift | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 56b0b40ad..9866e0deb 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -46,6 +46,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// changes in the list. private var tabWindowsHash: Int = 0 + /// The initial window presentation is deferred by one runloop turn in a few places so + /// AppKit can settle tab/window state first. Close actions must cancel it to avoid + /// re-showing a tab that was already closed. + private var pendingInitialPresentation: DispatchWorkItem? + /// This is set to false by init if the window managed by this controller should not be restorable. /// For example, terminals executing custom scripts are not restorable. private var restorable: Bool = true @@ -140,6 +145,27 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr center.removeObserver(self) } + private func cancelPendingInitialPresentation() { + pendingInitialPresentation?.cancel() + pendingInitialPresentation = nil + } + + private func scheduleInitialPresentation(_ block: @escaping () -> Void) { + cancelPendingInitialPresentation() + + var scheduledWorkItem: DispatchWorkItem? + scheduledWorkItem = DispatchWorkItem { [weak self] in + guard let self else { return } + defer { self.pendingInitialPresentation = nil } + guard scheduledWorkItem?.isCancelled == false else { return } + block() + } + + let workItem = scheduledWorkItem! + pendingInitialPresentation = workItem + DispatchQueue.main.async(execute: workItem) + } + // MARK: Base Controller Overrides override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { @@ -257,7 +283,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We're dispatching this async because otherwise the lastCascadePoint doesn't // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. - DispatchQueue.main.async { + c.scheduleInitialPresentation { c.showWindow(self) // Only cascade if we aren't fullscreen. @@ -319,7 +345,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Calculate the target frame based on the tree's view bounds let treeSize: CGSize? = tree.root?.viewBounds() - DispatchQueue.main.async { + c.scheduleInitialPresentation { c.showWindow(self) if let window = c.window { // If we have a tree size, resize the window's content to match @@ -434,7 +460,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We're dispatching this async because otherwise the lastCascadePoint doesn't // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. - DispatchQueue.main.async { + controller.scheduleInitialPresentation { // Only cascade if we aren't fullscreen and are alone in the tab group. if !window.styleMask.contains(.fullScreen) && window.tabGroup?.windows.count ?? 1 == 1 { @@ -650,6 +676,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return } + cancelPendingInitialPresentation() + // Undo if let undoManager, let undoState { // Register undo action to restore the tab @@ -768,6 +796,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr func closeWindowImmediately() { guard let window = window else { return } + cancelPendingInitialPresentation() + registerUndoForCloseWindow() if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { @@ -776,6 +806,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // This prevents unnecessary undos registered since AppKit may // process them on later ticks so we can't just disable undo registration. if let controller = window.windowController as? TerminalController { + controller.cancelPendingInitialPresentation() controller.surfaceTree = .init() } @@ -1142,6 +1173,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) + cancelPendingInitialPresentation() self.relabelTabs() // If we remove a window, we reset the cascade point to the key window so that From ba398dfff3e30ff83da07140981ca138410cf608 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:38:47 +0000 Subject: [PATCH 09/62] Update VOUCHED list (#12123) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12119#issuecomment-4188681042) from @bo2themax. Vouch: @jamylak Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 5228426a9..d16aeab47 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -103,6 +103,7 @@ icodesign j0hnm4r5 jacobsandlund jake-stewart +jamylak jarred-sumner jcollie jesusvazquez From 6a99c248d0c2a952bf0ba1333247f3fa4e381184 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Apr 2026 07:05:51 -0700 Subject: [PATCH 10/62] terminal/kitty: add Limits to restrict capabilities of image transfer --- src/terminal/kitty/graphics_exec.zig | 4 +- src/terminal/kitty/graphics_image.zig | 230 ++++++++++++++++++++++++-- 2 files changed, 220 insertions(+), 14 deletions(-) diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 5b3ab915d..faac9ab75 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -112,7 +112,7 @@ fn query(alloc: Allocator, cmd: *const Command) Response { }; // Attempt to load the image. If we cannot, then set an appropriate error. - var loading = LoadingImage.init(alloc, cmd) catch |err| { + var loading = LoadingImage.init(alloc, cmd, .all) catch |err| { encodeError(&result, err); return result; }; @@ -322,7 +322,7 @@ fn loadAndAddImage( } break :loading loading.*; - } else try .init(alloc, cmd); + } else try .init(alloc, cmd, .all); // We only want to deinit on error. If we're chunking, then we don't // want to deinit at all. If we're not chunking, then we'll deinit diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index bf11507b4..0c9e618f5 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -44,10 +44,38 @@ pub const LoadingImage = struct { /// used if q isn't set on subsequent chunks. quiet: command.Command.Quiet, + /// The limits of the Kitty Graphics protocol we should allow. + /// + /// This can be used to restrict the type of images and other + /// parameters for resource or security reasons. Note that depending + /// on how libghostty is compiled, some of these may be fully unsupported + /// and ignored (e.g. "file" on wasm32-freestanding). + pub const Limits = packed struct { + file: bool, + temporary_file: bool, + shared_memory: bool, + + pub const all: Limits = .{ + .file = true, + .temporary_file = true, + .shared_memory = true, + }; + + pub const direct: Limits = .{ + .file = false, + .temporary_file = false, + .shared_memory = false, + }; + }; + /// Initialize a chunked immage from the first image transmission. /// If this is a multi-chunk image, this should only be the FIRST /// chunk. - pub fn init(alloc: Allocator, cmd: *const command.Command) !LoadingImage { + pub fn init( + alloc: Allocator, + cmd: *const command.Command, + limits: Limits, + ) !LoadingImage { // Build our initial image from the properties sent via the control. // These can be overwritten by the data loading process. For example, // PNG loading sets the width/height from the data. @@ -72,6 +100,26 @@ pub const LoadingImage = struct { return result; } + // Verify our capabilities and limits allow this. + { + // Special case if we don't support decoding PNGs and the format + // is a PNG we can save a lot of memory/effort buffering the + // data but failing up front. + if (t.format == .png and + sys.decode_png == null) + { + return error.UnsupportedMedium; + } + + // Verify the medium is allowed + switch (t.medium) { + .direct => unreachable, + .file => if (!limits.file) return error.UnsupportedMedium, + .temporary_file => if (!limits.temporary_file) return error.UnsupportedMedium, + .shared_memory => if (!limits.shared_memory) return error.UnsupportedMedium, + } + } + // Otherwise, the payload data is guaranteed to be a path. if (comptime builtin.os.tag != .windows) { @@ -523,7 +571,7 @@ test "image load with invalid RGB data" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); } @@ -541,7 +589,7 @@ test "image load with image too wide" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -560,7 +608,7 @@ test "image load with image too tall" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -584,7 +632,7 @@ test "image load: rgb, zlib compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -612,7 +660,7 @@ test "image load: rgb, not compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -641,7 +689,7 @@ test "image load: rgb, zlib compressed, direct, chunked" { .data = try alloc.dupe(u8, data[0..1024]), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); // Read our remaining chunks @@ -677,7 +725,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" } }, }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); // Read our remaining chunks @@ -721,7 +769,7 @@ test "image load: temporary file without correct path" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd)); + try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd, .all)); // Temporary file should still be there try tmp_dir.dir.access(path, .{}); @@ -754,7 +802,7 @@ test "image load: rgb, not compressed, temporary file" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -791,7 +839,7 @@ test "image load: rgb, not compressed, regular file" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -828,7 +876,7 @@ test "image load: png, not compressed, regular file" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -836,3 +884,161 @@ test "image load: png, not compressed, regular file" { try testing.expect(img.format == .rgba); try tmp_dir.dir.access(path, .{}); } + +test "limits: direct medium always allowed" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .width = 1, + .height = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "AAAA"), + }; + defer cmd.deinit(alloc); + + // Direct medium should work even with the most restrictive limits + var loading = try LoadingImage.init(alloc, &cmd, .direct); + defer loading.deinit(alloc); +} + +test "limits: file medium blocked by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.UnsupportedMedium, LoadingImage.init(alloc, &cmd, .direct)); +} + +test "limits: file medium allowed by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd, .{ + .file = true, + .temporary_file = false, + .shared_memory = false, + }); + defer loading.deinit(alloc); +} + +test "limits: temporary file medium blocked by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.UnsupportedMedium, LoadingImage.init(alloc, &cmd, .{ + .file = true, + .temporary_file = false, + .shared_memory = true, + })); + + // File should still exist since we blocked before reading + try tmp_dir.dir.access("tty-graphics-protocol-image.data", .{}); +} + +test "limits: temporary file medium allowed by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd, .{ + .file = false, + .temporary_file = true, + .shared_memory = false, + }); + defer loading.deinit(alloc); +} From 64dcb91c1f3f1122706f70b888948d19fb1d7c42 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Apr 2026 07:11:45 -0700 Subject: [PATCH 11/62] terminal/kitty: add loading limits to kitty graphics protocol Add a Limits type to LoadingImage that controls which transmission mediums (file, temporary_file, shared_memory) are allowed when loading images. This defaults to "direct" (most restrictive) on ImageStorage and is set to "all" by Termio, allowing apprt embedders like libghostty to restrict medium types for resource or security reasons. The limits are stored on ImageStorage, plumbed through Screen.Options for screen initialization and inheritance, and enforced in graphics_exec during both query and transmit. Two new Terminal methods (setKittyGraphicsSizeLimit, setKittyGraphicsLoadingLimits) centralize updating all screens, replacing the manual iteration previously done in Termio. --- src/terminal/Screen.zig | 7 ++++++ src/terminal/Terminal.zig | 33 ++++++++++++++++++++++++- src/terminal/kitty/graphics.zig | 1 + src/terminal/kitty/graphics_exec.zig | 13 +++++++--- src/terminal/kitty/graphics_storage.zig | 6 ++++- src/termio/Termio.zig | 26 +++++-------------- 6 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 77e05b092..f93ec999c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -257,6 +257,12 @@ pub const Options = struct { /// screen. Kitty image storage is per-screen. kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + /// The limits for what medium types are allowed for Kitty image loading. + kitty_image_loading_limits: if (build_options.kitty_graphics) + kitty.graphics.LoadingImage.Limits + else + void = if (build_options.kitty_graphics) .direct else {}, + /// A simple, default terminal. If you rely on specific dimensions or /// scrollback (or lack of) then do not use this directly. This is just /// for callers that need some defaults. @@ -313,6 +319,7 @@ pub fn init( &result, opts.kitty_image_storage_limit, ) catch unreachable; + result.kitty_images.image_limits = opts.kitty_image_loading_limits; } return result; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 99536e7ab..0dfde8236 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2693,6 +2693,34 @@ pub fn kittyGraphics( return kitty.graphics.execute(alloc, self, cmd); } +/// Set the storage size limit for Kitty graphics across all screens. +pub fn setKittyGraphicsSizeLimit( + self: *Terminal, + alloc: Allocator, + limit: usize, +) !void { + if (comptime !build_options.kitty_graphics) return; + var it = self.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *Screen = entry.value.*; + try screen.kitty_images.setLimit(alloc, screen, limit); + } +} + +/// Set the allowed medium types for Kitty graphics image loading +/// across all screens. +pub fn setKittyGraphicsLoadingLimits( + self: *Terminal, + limits: kitty.graphics.LoadingImage.Limits, +) void { + if (comptime !build_options.kitty_graphics) return; + var it = self.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *Screen = entry.value.*; + screen.kitty_images.image_limits = limits; + } +} + /// Set a style attribute. pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { try self.screens.active.setAttribute(attr); @@ -2941,12 +2969,15 @@ pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen { .alternate => 0, }, - // Inherit our Kitty image storage limit from the primary + // Inherit our Kitty image settings from the primary // screen if we have to initialize. .kitty_image_storage_limit = if (comptime build_options.kitty_graphics) primary.kitty_images.total_limit else 0, + .kitty_image_loading_limits = if (comptime build_options.kitty_graphics) + primary.kitty_images.image_limits + else {}, }, ); }; diff --git a/src/terminal/kitty/graphics.zig b/src/terminal/kitty/graphics.zig index c710f81a1..6659cd310 100644 --- a/src/terminal/kitty/graphics.zig +++ b/src/terminal/kitty/graphics.zig @@ -25,6 +25,7 @@ pub const unicode = @import("graphics_unicode.zig"); pub const Command = command.Command; pub const CommandParser = command.Parser; pub const Image = image.Image; +pub const LoadingImage = image.LoadingImage; pub const ImageStorage = storage.ImageStorage; pub const RenderPlacement = render.Placement; pub const Response = command.Response; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index faac9ab75..a6a879e58 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -44,7 +44,7 @@ pub fn execute( var quiet = cmd.quiet; const resp_: ?Response = switch (cmd.control) { - .query => query(alloc, cmd), + .query => query(alloc, terminal, cmd), .display => display(alloc, terminal, cmd), .delete => delete(alloc, terminal, cmd), @@ -94,7 +94,11 @@ pub fn execute( /// This command is used to attempt to load an image and respond with /// success/error but does not persist any of the command to the terminal /// state. -fn query(alloc: Allocator, cmd: *const Command) Response { +fn query( + alloc: Allocator, + terminal: *const Terminal, + cmd: *const Command, +) Response { const t = cmd.control.query; // Query requires image ID. We can't actually send a response without @@ -112,7 +116,8 @@ fn query(alloc: Allocator, cmd: *const Command) Response { }; // Attempt to load the image. If we cannot, then set an appropriate error. - var loading = LoadingImage.init(alloc, cmd, .all) catch |err| { + const storage = &terminal.screens.active.kitty_images; + var loading = LoadingImage.init(alloc, cmd, storage.image_limits) catch |err| { encodeError(&result, err); return result; }; @@ -322,7 +327,7 @@ fn loadAndAddImage( } break :loading loading.*; - } else try .init(alloc, cmd, .all); + } else try .init(alloc, cmd, storage.image_limits); // We only want to deinit on error. If we're chunking, then we don't // want to deinit at all. If we're not chunking, then we'll deinit diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 8ff68e3fa..65c26dc85 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -51,6 +51,9 @@ pub const ImageStorage = struct { /// Non-null if there is an in-progress loading image. loading: ?*LoadingImage = null, + /// The limits of what medium types are allowed for image loading. + image_limits: LoadingImage.Limits = .direct, + /// The total bytes of image data that have been loaded and the limit. /// If the limit is reached, the oldest images will be evicted to make /// space. Unused images take priority. @@ -89,8 +92,9 @@ pub const ImageStorage = struct { ) !void { // Special case disabling by quickly deleting all if (limit == 0) { + const image_limits = self.image_limits; self.deinit(alloc, s); - self.* = .{}; + self.* = .{ .image_limits = image_limits }; } // If we re lowering our limit, check if we need to evict. diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 75ccb94b5..1b446e268 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -259,16 +259,9 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }); errdefer term.deinit(alloc); - // Set the image size limits - var it = term.screens.all.iterator(); - while (it.next()) |entry| { - const screen: *terminalpkg.Screen = entry.value.*; - try screen.kitty_images.setLimit( - alloc, - screen, - opts.config.image_storage_limit, - ); - } + // Set the Kitty image settings + try term.setKittyGraphicsSizeLimit(alloc, opts.config.image_storage_limit); + term.setKittyGraphicsLoadingLimits(.all); // Set our default cursor style term.screens.active.cursor.cursor_style = opts.config.cursor_style; @@ -463,16 +456,9 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi break :cursor color.toTerminalRGB() orelse break :cursor null; }; - // Set the image size limits - var it = self.terminal.screens.all.iterator(); - while (it.next()) |entry| { - const screen: *terminalpkg.Screen = entry.value.*; - try screen.kitty_images.setLimit( - self.alloc, - screen, - config.image_storage_limit, - ); - } + // Set the image limits + try self.terminal.setKittyGraphicsSizeLimit(self.alloc, config.image_storage_limit); + self.terminal.setKittyGraphicsLoadingLimits(.all); } /// Resize the terminal. From 935d37fbf1eea969245e144757116e8fbe93192a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Apr 2026 07:20:07 -0700 Subject: [PATCH 12/62] terminal: add kitty image limits to Terminal.Options Move kitty_image_storage_limit and kitty_image_loading_limits into Terminal.Options so callers can set them at construction time rather than calling setter functions after init. The values flow through to Screen.Options during ScreenSet initialization. Termio now passes both at construction, keeping the setter functions for the updateConfig path. --- src/terminal/Terminal.zig | 15 +++++++++++++++ src/termio/Termio.zig | 6 ++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0dfde8236..b128cdd3d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -191,6 +191,19 @@ pub const Options = struct { /// The default mode state. When the terminal gets a reset, it /// will revert back to this state. default_modes: modespkg.ModePacked = .{}, + + /// The total storage limit for Kitty images in bytes. Has no effect + /// if kitty images are disabled at build-time. + kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + + /// The limits for what medium types are allowed for Kitty image loading. + /// Has no effect if kitty images are disabled otherwise. For example, + // if no `sys.decode_png` hook is specified, png formats are disabled + // no matter what. + kitty_image_loading_limits: if (build_options.kitty_graphics) + kitty.graphics.LoadingImage.Limits + else + void = if (build_options.kitty_graphics) .direct else {}, }; /// Initialize a new terminal. @@ -205,6 +218,8 @@ pub fn init( .cols = cols, .rows = rows, .max_scrollback = opts.max_scrollback, + .kitty_image_storage_limit = opts.kitty_image_storage_limit, + .kitty_image_loading_limits = opts.kitty_image_loading_limits, }); errdefer screen_set.deinit(alloc); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 1b446e268..1d1bfe25a 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -255,14 +255,12 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }, .palette = .init(opts.config.palette), }, + .kitty_image_storage_limit = opts.config.image_storage_limit, + .kitty_image_loading_limits = .all, }; }); errdefer term.deinit(alloc); - // Set the Kitty image settings - try term.setKittyGraphicsSizeLimit(alloc, opts.config.image_storage_limit); - term.setKittyGraphicsLoadingLimits(.all); - // Set our default cursor style term.screens.active.cursor.cursor_style = opts.config.cursor_style; From 306acc494128e54e1702e872d15cbf661b3c9e0a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Apr 2026 07:22:43 -0700 Subject: [PATCH 13/62] terminal/kitty: use direct medium for tests if we're not using files --- src/terminal/kitty/graphics_image.zig | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 0c9e618f5..f1f055fa0 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -571,7 +571,7 @@ test "image load with invalid RGB data" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); } @@ -589,7 +589,7 @@ test "image load with image too wide" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -608,7 +608,7 @@ test "image load with image too tall" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -632,7 +632,7 @@ test "image load: rgb, zlib compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -660,7 +660,7 @@ test "image load: rgb, not compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -689,7 +689,7 @@ test "image load: rgb, zlib compressed, direct, chunked" { .data = try alloc.dupe(u8, data[0..1024]), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); // Read our remaining chunks @@ -725,7 +725,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" } }, }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); // Read our remaining chunks From e390937867b99efce6f8ac27a033088500fe6201 Mon Sep 17 00:00:00 2001 From: Kay Leung Date: Mon, 6 Apr 2026 05:19:25 +0800 Subject: [PATCH 14/62] macos: fix badge permission The previous version requested general notification permissions but omitted the `.badge` option. Because the initial request was granted, `settings.authorizationStatus` returns `.authorized`, leading the app to believe it has full notification privileges when it actually lacks the authority to update the dock icon badge. --- macos/Sources/App/macOS/AppDelegate.swift | 35 ++++++++++++++--------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 505d74f7e..f85f7ddf2 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -673,6 +673,22 @@ class AppDelegate: NSObject, syncDockBadge() } + private func requestBadgeAuthorizationAndSet(_ center: UNUserNotificationCenter) { + center.requestAuthorization(options: [.badge]) { granted, error in + if let error = error { + Self.logger.warning("Error requesting badge authorization: \(error)") + return + } + + // Permission granted, set the badge + if granted { + DispatchQueue.main.async { + self.setDockBadge() + } + } + } + } + private func syncDockBadge() { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in @@ -683,23 +699,16 @@ class AppDelegate: NSObject, DispatchQueue.main.async { self.setDockBadge() } + } else if settings.badgeSetting == .notSupported { + // If badge setting is not supported, we may be in a sandbox that doesn't allow it. + // We can still attempt to set the badge and hope for the best, but we should also + // request authorization just in case it is a permissions issue. + self.requestBadgeAuthorizationAndSet(center) } case .notDetermined: // Not determined yet, request authorization for badge - center.requestAuthorization(options: [.badge]) { granted, error in - if let error = error { - Self.logger.warning("Error requesting badge authorization: \(error)") - return - } - - if granted { - // Permission granted, set the badge - DispatchQueue.main.async { - self.setDockBadge() - } - } - } + self.requestBadgeAuthorizationAndSet(center) case .denied, .provisional, .ephemeral: // In these known non-authorized states, do not attempt to set the badge. From 4f543ff3d80fc33eaab2740b60356cf8ef96eed9 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:54:29 +0000 Subject: [PATCH 15/62] Update VOUCHED list (#12135) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12133#issuecomment-4189589541) from @jcollie. Vouch: @KayLeung Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index d16aeab47..2f79f8951 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -117,6 +117,7 @@ juniqlim justonia karesansui-u kawarimidoll +kayleung kenvandine khipp kirwiisp From 841a49ae1a25cda91a50e4f8ebac4811503081fa Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 04:31:17 +0000 Subject: [PATCH 16/62] Update VOUCHED list (#12138) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12137#discussioncomment-16460337) from @rhodes-b. Vouch: @neurosnap Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 2f79f8951..ba872e57b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -157,6 +157,7 @@ mrmage mtak natesmyth neo773 +neurosnap nicholas-ochoa nicosuave nmggithub From 13f7d23145891fbe3a99c268e7df388a3c9e52fc Mon Sep 17 00:00:00 2001 From: fru1tworld Date: Mon, 6 Apr 2026 19:22:47 +0900 Subject: [PATCH 17/62] macOS: force layout sync when frame size mismatches GeometryReader --- macos/Sources/Ghostty/Surface View/SurfaceView.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 6323e6af6..a6ddf4219 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -621,8 +621,13 @@ extension Ghostty { } func updateOSView(_ scrollView: SurfaceScrollView, context: Context) { - // Nothing to do: SwiftUI automatically updates the frame size, and - // SurfaceScrollView handles the rest in response to that + // SwiftUI may defer frame updates under system load (e.g., memory + // pressure, heavy I/O) or when external window managers trigger rapid + // layout changes. When that happens, the scroll view's bounds can + // fall out of sync with the size reported by GeometryReader, causing + // the surface to render at stale dimensions. + guard scrollView.bounds.size != size else { return } + scrollView.needsLayout = true } #else func makeOSView(context: Context) -> SurfaceView { From 810ebae8e8eca363b46553b62db7fc7bfe69e24b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 06:56:03 -0700 Subject: [PATCH 18/62] terminal: lower default kitty image storage limit for libghostty The default kitty image storage limit was 320 MB for all build artifacts. For libghostty, this is overly generous since it is an embedded library where conservative memory usage is preferred. Lower the default to 10 MB when building as the lib artifact while keeping the 320 MB default for the full Ghostty application. --- src/terminal/Screen.zig | 5 ++++- src/terminal/Terminal.zig | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index f93ec999c..b56701838 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -255,7 +255,10 @@ pub const Options = struct { /// The total storage limit for Kitty images in bytes for this /// screen. Kitty image storage is per-screen. - kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + kitty_image_storage_limit: usize = switch (build_options.artifact) { + .ghostty => 320 * 1000 * 1000, // 320MB + .lib => 10 * 1000 * 1000, // 10MB + }, /// The limits for what medium types are allowed for Kitty image loading. kitty_image_loading_limits: if (build_options.kitty_graphics) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b128cdd3d..f6268c719 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -194,7 +194,14 @@ pub const Options = struct { /// The total storage limit for Kitty images in bytes. Has no effect /// if kitty images are disabled at build-time. - kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + kitty_image_storage_limit: usize = switch (build_options.artifact) { + .ghostty => 320 * 1000 * 1000, // 320MB + + // libghostty we start with a much lower limit since this is an + // embedded library and we want to be more conservative with memory + // usage by default. + .lib => 10 * 1000 * 1000, // 10MB + }, /// The limits for what medium types are allowed for Kitty image loading. /// Has no effect if kitty images are disabled otherwise. For example, From 3a52e0e3bdba98b5372cf0f2d5ca5f150b8c09d7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 07:13:48 -0700 Subject: [PATCH 19/62] libghostty: expose kitty image options via terminal set/get Add four new terminal options for configuring Kitty graphics at runtime through the C API: storage limit, and the three loading medium flags (file, temporary file, shared memory). The storage limit setter propagates to all initialized screens and uses setLimit which handles eviction when lowering the limit. The medium setters similarly propagate to all screens. Getters read from the active screen. All options compile to no-ops or return no_value when kitty graphics are disabled at build time. --- include/ghostty/vt/terminal.h | 83 +++++++++++++++++++++++++++++++++++ src/terminal/c/terminal.zig | 61 +++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b43e37cf4..c243fa25c 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -534,6 +534,49 @@ typedef enum { * Input type: GhosttyColorRgb[256]* */ GHOSTTY_TERMINAL_OPT_COLOR_PALETTE = 14, + + /** + * Set the Kitty image storage limit in bytes. + * + * Applied to all initialized screens (primary and alternate). + * A value of zero disables the Kitty graphics protocol entirely, + * deleting all stored images and placements. A NULL value pointer + * is equivalent to zero (disables). Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: uint64_t* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT = 15, + + /** + * Enable or disable Kitty image loading via the file medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE = 16, + + /** + * Enable or disable Kitty image loading via the temporary file medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE = 17, + + /** + * Enable or disable Kitty image loading via the shared memory medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM = 18, } GhosttyTerminalOption; /** @@ -756,6 +799,46 @@ typedef enum { * Output type: GhosttyColorRgb[256] * */ GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT = 25, + + /** + * The Kitty image storage limit in bytes for the active screen. + * + * A value of zero means the Kitty graphics protocol is disabled. + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: uint64_t * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT = 26, + + /** + * Whether the file medium is enabled for Kitty image loading on the + * active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE = 27, + + /** + * Whether the temporary file medium is enabled for Kitty image loading + * on the active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE = 28, + + /** + * Whether the shared memory medium is enabled for Kitty image loading + * on the active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM = 29, } GhosttyTerminalData; /** diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ec749ffae..a2b0d1092 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1,5 +1,6 @@ const std = @import("std"); const testing = std.testing; +const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; const ZigTerminal = @import("../Terminal.zig"); @@ -304,6 +305,10 @@ pub const Option = enum(c_int) { color_background = 12, color_cursor = 13, color_palette = 14, + kitty_image_storage_limit = 15, + kitty_image_medium_file = 16, + kitty_image_medium_temp_file = 17, + kitty_image_medium_shared_mem = 18, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -320,6 +325,11 @@ pub const Option = enum(c_int) { .title, .pwd => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, + .kitty_image_storage_limit => ?*const u64, + .kitty_image_medium_file, + .kitty_image_medium_temp_file, + .kitty_image_medium_shared_mem, + => ?*const bool, }; } }; @@ -388,6 +398,32 @@ fn setTyped( ); wrapper.terminal.flags.dirty.palette = true; }, + .kitty_image_storage_limit => { + if (comptime !build_options.kitty_graphics) return .success; + const limit: usize = if (value) |v| @intCast(v.*) else 0; + var it = wrapper.terminal.screens.all.iterator(); + while (it.next()) |entry| { + const screen = entry.value.*; + screen.kitty_images.setLimit(screen.alloc, screen, limit) catch return .out_of_memory; + } + }, + .kitty_image_medium_file, + .kitty_image_medium_temp_file, + .kitty_image_medium_shared_mem, + => { + if (comptime !build_options.kitty_graphics) return .success; + const val = (value orelse return .success).*; + var it = wrapper.terminal.screens.all.iterator(); + while (it.next()) |entry| { + const screen = entry.value.*; + switch (option) { + .kitty_image_medium_file => screen.kitty_images.image_limits.file = val, + .kitty_image_medium_temp_file => screen.kitty_images.image_limits.temporary_file = val, + .kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val, + else => unreachable, + } + } + }, } return .success; } @@ -513,6 +549,10 @@ pub const TerminalData = enum(c_int) { color_background_default = 23, color_cursor_default = 24, color_palette_default = 25, + kitty_image_storage_limit = 26, + kitty_image_medium_file = 27, + kitty_image_medium_temp_file = 28, + kitty_image_medium_shared_mem = 29, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { @@ -535,6 +575,11 @@ pub const TerminalData = enum(c_int) { .color_cursor_default, => color.RGB.C, .color_palette, .color_palette_default => color.PaletteC, + .kitty_image_storage_limit => u64, + .kitty_image_medium_file, + .kitty_image_medium_temp_file, + .kitty_image_medium_shared_mem, + => bool, }; } }; @@ -603,6 +648,22 @@ fn getTyped( .color_cursor_default => out.* = (t.colors.cursor.default orelse return .no_value).cval(), .color_palette => out.* = color.paletteCval(&t.colors.palette.current), .color_palette_default => out.* = color.paletteCval(&t.colors.palette.original), + .kitty_image_storage_limit => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = @intCast(t.screens.active.kitty_images.total_limit); + }, + .kitty_image_medium_file => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = t.screens.active.kitty_images.image_limits.file; + }, + .kitty_image_medium_temp_file => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = t.screens.active.kitty_images.image_limits.temporary_file; + }, + .kitty_image_medium_shared_mem => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = t.screens.active.kitty_images.image_limits.shared_memory; + }, } return .success; From d7fa92088c0e50d02d97190973b91d49d0c39d6a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 07:39:31 -0700 Subject: [PATCH 20/62] libghostty: expose sys interface to C API The terminal sys module provides runtime-swappable function pointers for operations that depend on external implementations (e.g. PNG decoding). This exposes that functionality through the C API via a ghostty_sys_set() function, modeled after the ghostty_terminal_set() enum-based option pattern. Embedders can install a PNG decode callback to enable Kitty Graphics Protocol PNG support. The callback receives a userdata pointer (set via GHOSTTY_SYS_OPT_USERDATA) and a GhosttyAllocator that must be used to allocate the returned pixel data, since the library takes ownership of the buffer. Passing NULL clears the callback and disables the feature. --- include/ghostty/vt.h | 1 + include/ghostty/vt/sys.h | 125 +++++++++++++++++++++++++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 4 ++ src/terminal/c/sys.zig | 137 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+) create mode 100644 include/ghostty/vt/sys.h create mode 100644 src/terminal/c/sys.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 6a943350c..0d54e2d2f 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -118,6 +118,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/sys.h b/include/ghostty/vt/sys.h new file mode 100644 index 000000000..7c9a366bb --- /dev/null +++ b/include/ghostty/vt/sys.h @@ -0,0 +1,125 @@ +/** + * @file sys.h + * + * System interface - runtime-swappable implementations for external dependencies. + */ + +#ifndef GHOSTTY_VT_SYS_H +#define GHOSTTY_VT_SYS_H + +#include +#include +#include +#include +#include + +/** @defgroup sys System Interface + * + * Runtime-swappable function pointers for operations that depend on + * external implementations (e.g. image decoding). + * + * These are process-global settings that must be configured at startup + * before any terminal functionality that depends on them is used. + * Setting these enables various optional features of the terminal. For + * example, setting a PNG decoder enables PNG image support in the Kitty + * Graphics Protocol. + * + * Use ghostty_sys_set() with a `GhosttySysOption` to install or clear + * an implementation. Passing NULL as the value clears the implementation + * and disables the corresponding feature. + * + * @{ + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Result of decoding an image. + * + * The `data` buffer must be allocated through the allocator provided to + * the decode callback. The library takes ownership and will free it + * with the same allocator. + */ +typedef struct { + /** Image width in pixels. */ + uint32_t width; + + /** Image height in pixels. */ + uint32_t height; + + /** Pointer to the decoded RGBA pixel data. */ + uint8_t* data; + + /** Length of the pixel data in bytes. */ + size_t data_len; +} GhosttySysImage; + +/** + * Callback type for PNG decoding. + * + * Decodes raw PNG data into RGBA pixels. The output pixel data must be + * allocated through the provided allocator. The library takes ownership + * of the buffer and will free it with the same allocator. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param allocator The allocator to use for the output pixel buffer + * @param data Pointer to the raw PNG data + * @param data_len Length of the raw PNG data in bytes + * @param[out] out On success, filled with the decoded image + * @return true on success, false on failure + */ +typedef bool (*GhosttySysDecodePngFn)( + void* userdata, + const GhosttyAllocator* allocator, + const uint8_t* data, + size_t data_len, + GhosttySysImage* out); + +/** + * System option identifiers for ghostty_sys_set(). + */ +typedef enum { + /** + * Set the userdata pointer passed to all sys callbacks. + * + * Input type: void* (or NULL) + */ + GHOSTTY_SYS_OPT_USERDATA = 0, + + /** + * Set the PNG decode function. + * + * When set, the terminal can accept PNG images via the Kitty + * Graphics Protocol. When cleared (NULL value), PNG decoding is + * unsupported and PNG image data will be rejected. + * + * Input type: GhosttySysDecodePngFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_DECODE_PNG = 1, +} GhosttySysOption; + +/** + * Set a system-level option. + * + * Configures a process-global implementation function. These should be + * set once at startup before using any terminal functionality that + * depends on them. + * + * @param option The option to set + * @param value Pointer to the value (type depends on the option), + * or NULL to clear it + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * option is not recognized + */ +GHOSTTY_API GhosttyResult ghostty_sys_set(GhosttySysOption option, + const void* value); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SYS_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 665058b68..deee9633c 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -189,6 +189,7 @@ comptime { @export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" }); @export(&c.style_default, .{ .name = "ghostty_style_default" }); @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); + @export(&c.sys_set, .{ .name = "ghostty_sys_set" }); @export(&c.cell_get, .{ .name = "ghostty_cell_get" }); @export(&c.row_get, .{ .name = "ghostty_row_get" }); @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index dc3b7e7ce..997a8e2c8 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -22,6 +22,7 @@ pub const row = @import("row.zig"); pub const sgr = @import("sgr.zig"); pub const size_report = @import("size_report.zig"); pub const style = @import("style.zig"); +pub const sys = @import("sys.zig"); pub const terminal = @import("terminal.zig"); // The full C API, unexported. @@ -132,6 +133,8 @@ pub const row_get = row.get; pub const style_default = style.default_style; pub const style_is_default = style.style_is_default; +pub const sys_set = sys.set; + pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_reset = terminal.reset; @@ -173,6 +176,7 @@ test { _ = sgr; _ = size_report; _ = style; + _ = sys; _ = terminal; _ = types; diff --git a/src/terminal/c/sys.zig b/src/terminal/c/sys.zig new file mode 100644 index 000000000..9677c8794 --- /dev/null +++ b/src/terminal/c/sys.zig @@ -0,0 +1,137 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const terminal_sys = @import("../sys.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttySysImage +pub const Image = extern struct { + width: u32, + height: u32, + data: ?[*]u8, + data_len: usize, +}; + +/// C: GhosttySysDecodePngFn +pub const DecodePngFn = *const fn ( + ?*anyopaque, + *const CAllocator, + [*]const u8, + usize, + *Image, +) callconv(lib.calling_conv) bool; + +/// C: GhosttySysOption +pub const Option = enum(c_int) { + userdata = 0, + decode_png = 1, + + pub fn InType(comptime self: Option) type { + return switch (self) { + .userdata => ?*const anyopaque, + .decode_png => ?DecodePngFn, + }; + } +}; + +/// Global state for the sys interface so we can call through to the C +/// callbacks from Zig. +const Global = struct { + userdata: ?*anyopaque = null, + decode_png: ?DecodePngFn = null, +}; + +/// Global state for the C sys interface. +var global: Global = .{}; + +/// Zig-compatible wrapper that calls through to the stored C callback. +/// The C callback allocates the pixel data through the provided allocator, +/// so we can take ownership directly. +fn decodePngWrapper( + alloc: std.mem.Allocator, + data: []const u8, +) terminal_sys.DecodeError!terminal_sys.Image { + const func = global.decode_png orelse return error.InvalidData; + + const c_alloc = CAllocator.fromZig(&alloc); + var out: Image = undefined; + if (!func(global.userdata, &c_alloc, data.ptr, data.len, &out)) return error.InvalidData; + + const result_data = out.data orelse return error.InvalidData; + + return .{ + .width = out.width, + .height = out.height, + .data = result_data[0..out.data_len], + }; +} + +pub fn set( + option: Option, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| setTyped( + comptime_option, + @ptrCast(@alignCast(value)), + ), + }; +} + +fn setTyped( + comptime option: Option, + value: option.InType(), +) Result { + switch (option) { + .userdata => global.userdata = @constCast(value), + .decode_png => { + global.decode_png = value; + terminal_sys.decode_png = if (value != null) &decodePngWrapper else null; + }, + } + return .success; +} + +test "set decode_png with null clears" { + // Start from a known state. + global.decode_png = null; + terminal_sys.decode_png = null; + + try std.testing.expectEqual(Result.success, set(.decode_png, null)); + try std.testing.expect(terminal_sys.decode_png == null); +} + +test "set decode_png installs wrapper" { + const S = struct { + fn decode(_: ?*anyopaque, _: *const CAllocator, _: [*]const u8, _: usize, out: *Image) callconv(lib.calling_conv) bool { + out.* = .{ .width = 1, .height = 1, .data = null, .data_len = 0 }; + return true; + } + }; + + try std.testing.expectEqual(Result.success, set( + .decode_png, + @ptrCast(&S.decode), + )); + try std.testing.expect(terminal_sys.decode_png != null); + + // Clear it again. + try std.testing.expectEqual(Result.success, set(.decode_png, null)); + try std.testing.expect(terminal_sys.decode_png == null); +} + +test "set userdata" { + var data: u32 = 42; + try std.testing.expectEqual(Result.success, set(.userdata, @ptrCast(&data))); + try std.testing.expect(global.userdata == @as(?*anyopaque, @ptrCast(&data))); + + // Clear it. + try std.testing.expectEqual(Result.success, set(.userdata, null)); + try std.testing.expect(global.userdata == null); +} From 64340c62bfab76147d6fa4aec4d4979d3c4d2e33 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 07:49:49 -0700 Subject: [PATCH 21/62] example: add c-vt-kitty-graphics Demonstrates the sys interface for Kitty Graphics Protocol PNG support. The example installs a PNG decode callback via ghostty_sys_set, creates a terminal with image storage enabled, and sends an inline 1x1 PNG image through vt_write. Snippet markers are wired up to the sys.h doxygen docs. --- example/c-vt-kitty-graphics/README.md | 18 ++++ example/c-vt-kitty-graphics/build.zig | 42 ++++++++ example/c-vt-kitty-graphics/build.zig.zon | 24 +++++ example/c-vt-kitty-graphics/src/main.c | 125 ++++++++++++++++++++++ include/ghostty/vt.h | 5 + include/ghostty/vt/sys.h | 8 ++ 6 files changed, 222 insertions(+) create mode 100644 example/c-vt-kitty-graphics/README.md create mode 100644 example/c-vt-kitty-graphics/build.zig create mode 100644 example/c-vt-kitty-graphics/build.zig.zon create mode 100644 example/c-vt-kitty-graphics/src/main.c diff --git a/example/c-vt-kitty-graphics/README.md b/example/c-vt-kitty-graphics/README.md new file mode 100644 index 000000000..cbeb67476 --- /dev/null +++ b/example/c-vt-kitty-graphics/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Kitty Graphics Protocol + +This contains a simple example of how to use the system interface +(`ghostty_sys_set`) to install a PNG decoder callback, then send +a Kitty Graphics Protocol image via `ghostty_terminal_vt_write`. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-kitty-graphics/build.zig b/example/c-vt-kitty-graphics/build.zig new file mode 100644 index 000000000..4bbf9e3ff --- /dev/null +++ b/example/c-vt-kitty-graphics/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_kitty_graphics", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-kitty-graphics/build.zig.zon b/example/c-vt-kitty-graphics/build.zig.zon new file mode 100644 index 000000000..fce0e5906 --- /dev/null +++ b/example/c-vt-kitty-graphics/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_kitty_graphics, + .version = "0.0.0", + .fingerprint = 0x432d40ecc8f15589, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-kitty-graphics/src/main.c b/example/c-vt-kitty-graphics/src/main.c new file mode 100644 index 000000000..f3478811b --- /dev/null +++ b/example/c-vt-kitty-graphics/src/main.c @@ -0,0 +1,125 @@ +#include +#include +#include +#include +#include +#include + +//! [kitty-graphics-decode-png] +/** + * Minimal PNG decoder callback for the sys interface. + * + * A real implementation would use a PNG library (libpng, stb_image, etc.) + * to decode the PNG data. This example uses a hardcoded 1x1 red pixel + * since we know exactly what image we're sending. + * + * WARNING: This is only an example for providing a callback, it DOES NOT + * actually decode the PNG it is passed. It hardcodes a response. + */ +bool decode_png(void* userdata, + const GhosttyAllocator* allocator, + const uint8_t* data, + size_t data_len, + GhosttySysImage* out) { + int* count = (int*)userdata; + (*count)++; + printf(" decode_png called (size=%zu, call #%d)\n", data_len, *count); + + /* Allocate RGBA pixel data through the provided allocator. */ + const size_t pixel_len = 4; /* 1x1 RGBA */ + uint8_t* pixels = ghostty_alloc(allocator, pixel_len); + if (!pixels) return false; + + /* Fill with red (R=255, G=0, B=0, A=255). */ + pixels[0] = 255; + pixels[1] = 0; + pixels[2] = 0; + pixels[3] = 255; + + out->width = 1; + out->height = 1; + out->data = pixels; + out->data_len = pixel_len; + return true; +} +//! [kitty-graphics-decode-png] + +//! [kitty-graphics-write-pty] +/** + * write_pty callback to capture terminal responses. + * + * The Kitty graphics protocol sends an APC response back to the pty + * when an image is loaded (unless suppressed with q=2). + */ +void on_write_pty(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len) { + (void)terminal; + (void)userdata; + printf(" response (%zu bytes): ", len); + fwrite(data, 1, len, stdout); + printf("\n"); +} +//! [kitty-graphics-write-pty] + +//! [kitty-graphics-main] +int main() { + /* Install the PNG decoder via the sys interface. */ + int decode_count = 0; + ghostty_sys_set(GHOSTTY_SYS_OPT_USERDATA, &decode_count); + ghostty_sys_set(GHOSTTY_SYS_OPT_DECODE_PNG, (const void*)decode_png); + + /* Create a terminal with Kitty graphics enabled. */ + GhosttyTerminal terminal = NULL; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; + if (ghostty_terminal_new(NULL, &terminal, opts) != GHOSTTY_SUCCESS) { + fprintf(stderr, "Failed to create terminal\n"); + return 1; + } + + /* Set a storage limit to enable Kitty graphics. */ + uint64_t storage_limit = 64 * 1024 * 1024; /* 64 MiB */ + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, + &storage_limit); + + /* Install write_pty to see the protocol response. */ + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY, + (const void*)on_write_pty); + + /* + * Send a Kitty graphics command with an inline 1x1 PNG image. + * + * The escape sequence is: + * ESC _G a=T,f=100,q=1; ESC \ + * + * Where: + * a=T — transmit and display + * f=100 — PNG format + * q=1 — request a response (q=0 would suppress it) + */ + printf("Sending Kitty graphics PNG image:\n"); + const char* kitty_cmd = + "\x1b_Ga=T,f=100,q=1;" + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA" + "DUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" + "\x1b\\"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)kitty_cmd, + strlen(kitty_cmd)); + + printf("PNG decode calls: %d\n", decode_count); + + /* Clean up. */ + ghostty_terminal_free(terminal); + + /* Clear the sys callbacks. */ + ghostty_sys_set(GHOSTTY_SYS_OPT_DECODE_PNG, NULL); + ghostty_sys_set(GHOSTTY_SYS_OPT_USERDATA, NULL); + + return 0; +} +//! [kitty-graphics-main] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 0d54e2d2f..5dd06521c 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -98,6 +98,11 @@ * grid refs to inspect cell codepoints, row wrap state, and cell styles. */ +/** @example c-vt-kitty-graphics/src/main.c + * This example demonstrates how to use the system interface to install a + * PNG decoder callback and send a Kitty Graphics Protocol image. + */ + #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H diff --git a/include/ghostty/vt/sys.h b/include/ghostty/vt/sys.h index 7c9a366bb..0634f5ac8 100644 --- a/include/ghostty/vt/sys.h +++ b/include/ghostty/vt/sys.h @@ -28,6 +28,14 @@ * an implementation. Passing NULL as the value clears the implementation * and disables the corresponding feature. * + * ## Example + * + * ### Defining a PNG decode callback + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png + * + * ### Installing the callback and sending a PNG image + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main + * * @{ */ From e89b2c88f3a07956bd02bbd8279ead3bcbdd03a4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 08:49:40 -0700 Subject: [PATCH 22/62] libghostty: introduce the kitty graphics opaque type --- include/ghostty/vt.h | 1 + include/ghostty/vt/kitty_graphics.h | 40 +++++++++++++++++++++++++++++ include/ghostty/vt/terminal.h | 14 ++++++++++ src/terminal/c/kitty_graphics.zig | 8 ++++++ src/terminal/c/main.zig | 2 ++ src/terminal/c/terminal.zig | 10 ++++++++ 6 files changed, 75 insertions(+) create mode 100644 include/ghostty/vt/kitty_graphics.h create mode 100644 src/terminal/c/kitty_graphics.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 5dd06521c..649ab1d4d 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -125,6 +125,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h new file mode 100644 index 000000000..aaacb5330 --- /dev/null +++ b/include/ghostty/vt/kitty_graphics.h @@ -0,0 +1,40 @@ +/** + * @file kitty_graphics.h + * + * Kitty graphics protocol image storage. + */ + +#ifndef GHOSTTY_VT_KITTY_GRAPHICS_H +#define GHOSTTY_VT_KITTY_GRAPHICS_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup kitty_graphics Kitty Graphics + * + * Opaque handle to the Kitty graphics image storage associated with a + * terminal screen. + * + * @{ + */ + +/** + * Opaque handle to a Kitty graphics image storage. + * + * Obtained via ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. The pointer is borrowed from + * the terminal and remains valid until the next mutating terminal call + * (e.g. ghostty_terminal_vt_write() or ghostty_terminal_reset()). + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics; + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_KITTY_GRAPHICS_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index c243fa25c..ff3f60ae1 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -839,6 +840,19 @@ typedef enum { * Output type: bool * */ GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM = 29, + + /** + * The Kitty graphics image storage for the active screen. + * + * Returns a borrowed pointer to the image storage. The pointer is valid + * until the next mutating terminal call (e.g. ghostty_terminal_vt_write() + * or ghostty_terminal_reset()). + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: GhosttyKittyGraphics * + */ + GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30, } GhosttyTerminalData; /** diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig new file mode 100644 index 000000000..cc0834859 --- /dev/null +++ b/src/terminal/c/kitty_graphics.zig @@ -0,0 +1,8 @@ +const build_options = @import("terminal_options"); +const kitty_gfx = @import("../kitty/graphics_storage.zig"); + +/// C: GhosttyKittyGraphics +pub const KittyGraphics = if (build_options.kitty_graphics) + *kitty_gfx.ImageStorage +else + *anyopaque; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 997a8e2c8..ef678e438 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -8,6 +8,7 @@ pub const color = @import("color.zig"); pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); +pub const kitty_graphics = @import("kitty_graphics.zig"); pub const types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); @@ -161,6 +162,7 @@ test { _ = cell; _ = color; _ = grid_ref; + _ = kitty_graphics; _ = row; _ = focus; _ = formatter; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index a2b0d1092..32bc0311a 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -8,6 +8,7 @@ const Stream = @import("../stream_terminal.zig").Stream; const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); const kitty = @import("../kitty/key.zig"); +const kitty_gfx_c = @import("kitty_graphics.zig"); const modes = @import("../modes.zig"); const point = @import("../point.zig"); const size = @import("../size.zig"); @@ -515,6 +516,9 @@ pub fn mode_set( return .success; } +/// C: GhosttyKittyGraphics +pub const KittyGraphics = kitty_gfx_c.KittyGraphics; + /// C: GhosttyTerminalScreen pub const TerminalScreen = ScreenSet.Key; @@ -553,6 +557,7 @@ pub const TerminalData = enum(c_int) { kitty_image_medium_file = 27, kitty_image_medium_temp_file = 28, kitty_image_medium_shared_mem = 29, + kitty_graphics = 30, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { @@ -580,6 +585,7 @@ pub const TerminalData = enum(c_int) { .kitty_image_medium_temp_file, .kitty_image_medium_shared_mem, => bool, + .kitty_graphics => KittyGraphics, }; } }; @@ -664,6 +670,10 @@ fn getTyped( if (comptime !build_options.kitty_graphics) return .no_value; out.* = t.screens.active.kitty_images.image_limits.shared_memory; }, + .kitty_graphics => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = &t.screens.active.kitty_images; + }, } return .success; From 9033f6f8ce2bc50fb2529616764fcb2325ae67b2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 09:06:06 -0700 Subject: [PATCH 23/62] libghostty: add kitty graphics placement iterator API Add a C API for iterating over Kitty graphics placements via the new GhosttyKittyGraphics opaque handle. The API follows the same pattern as the render state row iterator: allocate an iterator with ghostty_kitty_graphics_placement_iterator_new, populate it from a graphics handle via ghostty_kitty_graphics_get with the PLACEMENT_ITERATOR data kind, advance with ghostty_kitty_graphics_placement_next, and query per-placement fields with ghostty_kitty_graphics_placement_get. --- include/ghostty/vt/kitty_graphics.h | 205 +++++++++++++++- src/lib_vt.zig | 5 + src/terminal/c/AGENTS.md | 7 +- src/terminal/c/kitty_graphics.zig | 356 ++++++++++++++++++++++++++++ src/terminal/c/main.zig | 5 + 5 files changed, 576 insertions(+), 2 deletions(-) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index aaacb5330..df95b3a09 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -7,6 +7,11 @@ #ifndef GHOSTTY_VT_KITTY_GRAPHICS_H #define GHOSTTY_VT_KITTY_GRAPHICS_H +#include +#include +#include +#include + #ifdef __cplusplus extern "C" { #endif @@ -14,7 +19,7 @@ extern "C" { /** @defgroup kitty_graphics Kitty Graphics * * Opaque handle to the Kitty graphics image storage associated with a - * terminal screen. + * terminal screen, and an iterator for inspecting placements. * * @{ */ @@ -31,6 +36,204 @@ extern "C" { */ typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics; +/** + * Opaque handle to a Kitty graphics placement iterator. + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsPlacementIteratorImpl* GhosttyKittyGraphicsPlacementIterator; + +/** + * Queryable data kinds for ghostty_kitty_graphics_get(). + * + * @ingroup kitty_graphics + */ +typedef enum { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_GRAPHICS_DATA_INVALID = 0, + + /** + * Populate a pre-allocated placement iterator with placement data from + * the storage. Iterator data is only valid as long as the underlying + * terminal is not mutated. + * + * Output type: GhosttyKittyGraphicsPlacementIterator * + */ + GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1, +} GhosttyKittyGraphicsData; + +/** + * Queryable data kinds for ghostty_kitty_graphics_placement_get(). + * + * @ingroup kitty_graphics + */ +typedef enum { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_INVALID = 0, + + /** + * The image ID this placement belongs to. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID = 1, + + /** + * The placement ID. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID = 2, + + /** + * Whether this is a virtual placement (unicode placeholder). + * + * Output type: bool * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL = 3, + + /** + * Pixel offset from the left edge of the cell. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET = 4, + + /** + * Pixel offset from the top edge of the cell. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET = 5, + + /** + * Source rectangle x origin in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_X = 6, + + /** + * Source rectangle y origin in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_Y = 7, + + /** + * Source rectangle width in pixels (0 = full image width). + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_WIDTH = 8, + + /** + * Source rectangle height in pixels (0 = full image height). + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_HEIGHT = 9, + + /** + * Number of columns this placement occupies. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_COLUMNS = 10, + + /** + * Number of rows this placement occupies. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_ROWS = 11, + + /** + * Z-index for this placement. + * + * Output type: int32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12, +} GhosttyKittyGraphicsPlacementData; + +/** + * Get data from a kitty graphics storage instance. + * + * The output pointer must be of the appropriate type for the requested + * data kind. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * @param graphics The kitty graphics handle + * @param data The type of data to extract + * @param[out] out Pointer to store the extracted data + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_get( + GhosttyKittyGraphics graphics, + GhosttyKittyGraphicsData data, + void* out); + +/** + * Create a new placement iterator instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_kitty_graphics_get() with + * GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param[out] out_iterator On success, receives the created iterator handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_new( + const GhosttyAllocator* allocator, + GhosttyKittyGraphicsPlacementIterator* out_iterator); + +/** + * Free a placement iterator. + * + * @param iterator The iterator handle to free (may be NULL) + * + * @ingroup kitty_graphics + */ +GHOSTTY_API void ghostty_kitty_graphics_placement_iterator_free( + GhosttyKittyGraphicsPlacementIterator iterator); + +/** + * Advance the placement iterator to the next placement. + * + * @param iterator The iterator handle (may be NULL) + * @return true if advanced to the next placement, false if at the end + * + * @ingroup kitty_graphics + */ +GHOSTTY_API bool ghostty_kitty_graphics_placement_next( + GhosttyKittyGraphicsPlacementIterator iterator); + +/** + * Get data from the current placement in a placement iterator. + * + * Call ghostty_kitty_graphics_placement_next() at least once before + * calling this function. + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * iterator is NULL or not positioned on a placement + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsPlacementData data, + void* out); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index deee9633c..a009da01e 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -233,6 +233,11 @@ comptime { @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); + @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); + @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" }); + @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); + @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); + @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" }); @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); diff --git a/src/terminal/c/AGENTS.md b/src/terminal/c/AGENTS.md index 63f7fc6cc..c7e9068a8 100644 --- a/src/terminal/c/AGENTS.md +++ b/src/terminal/c/AGENTS.md @@ -5,7 +5,12 @@ via `lib.TaggedUnion`. - Any functions must be updated all the way through from here to `src/terminal/c/main.zig` to `src/lib_vt.zig` and the headers - in `include/ghostty/vt.h`. + in `include/ghostty/vt.h`. Specifically: + 1. Define the function in `src/terminal/c/.zig`. + 2. Re-export it via a `pub const` in `src/terminal/c/main.zig`. + 3. Add an `@export` call in `src/lib_vt.zig` with the + `ghostty_` prefixed symbol name. + 4. Declare it in the corresponding header under `include/ghostty/vt/`. - In `include/ghostty/vt.h`, always sort the header contents by: (1) macros, (2) forward declarations, (3) types, (4) functions diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index cc0834859..39f6c1f8f 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -1,8 +1,364 @@ +const std = @import("std"); const build_options = @import("terminal_options"); +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; const kitty_gfx = @import("../kitty/graphics_storage.zig"); +const Result = @import("result.zig").Result; /// C: GhosttyKittyGraphics pub const KittyGraphics = if (build_options.kitty_graphics) *kitty_gfx.ImageStorage else *anyopaque; + +/// C: GhosttyKittyGraphicsPlacementIterator +pub const PlacementIterator = ?*PlacementIteratorWrapper; + +const PlacementMap = std.AutoHashMapUnmanaged( + kitty_gfx.ImageStorage.PlacementKey, + kitty_gfx.ImageStorage.Placement, +); + +const PlacementIteratorWrapper = struct { + alloc: std.mem.Allocator, + inner: PlacementMap.Iterator = undefined, + entry: ?PlacementMap.Entry = null, +}; + +/// C: GhosttyKittyGraphicsData +pub const Data = enum(c_int) { + invalid = 0, + placement_iterator = 1, + + pub fn OutType(comptime self: Data) type { + return switch (self) { + .invalid => void, + .placement_iterator => PlacementIterator, + }; + } +}; + +/// C: GhosttyKittyGraphicsPlacementData +pub const PlacementData = enum(c_int) { + invalid = 0, + image_id = 1, + placement_id = 2, + is_virtual = 3, + x_offset = 4, + y_offset = 5, + source_x = 6, + source_y = 7, + source_width = 8, + source_height = 9, + columns = 10, + rows = 11, + z = 12, + + pub fn OutType(comptime self: PlacementData) type { + return switch (self) { + .invalid => void, + .image_id, .placement_id => u32, + .is_virtual => bool, + .x_offset, + .y_offset, + .source_x, + .source_y, + .source_width, + .source_height, + .columns, + .rows, + => u32, + .z => i32, + }; + } +}; + +pub fn get( + graphics_: KittyGraphics, + data: Data, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + graphics_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + graphics_: KittyGraphics, + comptime data: Data, + out: *data.OutType(), +) Result { + const storage = graphics_; + switch (data) { + .invalid => return .invalid_value, + .placement_iterator => { + const it = out.* orelse return .invalid_value; + it.* = .{ + .alloc = it.alloc, + .inner = storage.placements.iterator(), + }; + }, + } + return .success; +} + +pub fn placement_iterator_new( + alloc_: ?*const CAllocator, + out: *PlacementIterator, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(PlacementIteratorWrapper) catch { + out.* = null; + return .out_of_memory; + }; + ptr.* = .{ .alloc = alloc }; + out.* = ptr; + return .success; +} + +pub fn placement_iterator_free(iter_: PlacementIterator) callconv(lib.calling_conv) void { + const iter = iter_ orelse return; + iter.alloc.destroy(iter); +} + +pub fn placement_iterator_next(iter_: PlacementIterator) callconv(lib.calling_conv) bool { + if (comptime !build_options.kitty_graphics) return false; + + const iter = iter_ orelse return false; + iter.entry = iter.inner.next() orelse return false; + return true; +} + +pub fn placement_get( + iter_: PlacementIterator, + data: PlacementData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| placementGetTyped( + iter_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn placementGetTyped( + iter_: PlacementIterator, + comptime data: PlacementData, + out: *data.OutType(), +) Result { + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + + switch (data) { + .invalid => return .invalid_value, + .image_id => out.* = entry.key_ptr.image_id, + .placement_id => out.* = entry.key_ptr.placement_id.id, + .is_virtual => out.* = entry.value_ptr.location == .virtual, + .x_offset => out.* = entry.value_ptr.x_offset, + .y_offset => out.* = entry.value_ptr.y_offset, + .source_x => out.* = entry.value_ptr.source_x, + .source_y => out.* = entry.value_ptr.source_y, + .source_width => out.* = entry.value_ptr.source_width, + .source_height => out.* = entry.value_ptr.source_height, + .columns => out.* = entry.value_ptr.columns, + .rows => out.* = entry.value_ptr.rows, + .z => out.* = entry.value_ptr.z, + } + + return .success; +} + +const testing = std.testing; +const terminal_c = @import("terminal.zig"); + +test "placement_iterator new/free" { + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + try testing.expect(iter != null); + placement_iterator_free(iter); +} + +test "placement_iterator free null" { + placement_iterator_free(null); +} + +test "placement_iterator next on empty storage" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(!placement_iterator_next(iter)); +} + +test "placement_iterator get before next returns invalid" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + var image_id: u32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_get(iter, .image_id, @ptrCast(&image_id))); +} + +test "placement_iterator with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit and display a 1x2 RGB image (image_id=1, placement_id=1). + // a=T (transmit+display), t=d (direct), f=24 (RGB), i=1, p=1 + // s=1,v=2 (1x2 pixels), c=10,r=1 (10 cols, 1 row) + // //////// = 8 base64 chars = 6 bytes = 1*2*3 RGB bytes + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + // Should have exactly one placement. + try testing.expect(placement_iterator_next(iter)); + + var image_id: u32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .image_id, @ptrCast(&image_id))); + try testing.expectEqual(1, image_id); + + var placement_id: u32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .placement_id, @ptrCast(&placement_id))); + try testing.expectEqual(1, placement_id); + + var is_virtual: bool = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .is_virtual, @ptrCast(&is_virtual))); + try testing.expect(!is_virtual); + + // No more placements. + try testing.expect(!placement_iterator_next(iter)); +} + +test "placement_iterator with multiple placements" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit image 1 then display it twice with different placement IDs. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display1 = "\x1b_Ga=p,i=1,p=1,c=10,r=1;\x1b\\"; + const display2 = "\x1b_Ga=p,i=1,p=2,c=5,r=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display1.ptr, display1.len); + terminal_c.vt_write(t, display2.ptr, display2.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + // Count placements and collect image IDs. + var count: usize = 0; + var seen_p1 = false; + var seen_p2 = false; + while (placement_iterator_next(iter)) { + count += 1; + + var image_id: u32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .image_id, @ptrCast(&image_id))); + try testing.expectEqual(1, image_id); + + var placement_id: u32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .placement_id, @ptrCast(&placement_id))); + if (placement_id == 1) seen_p1 = true; + if (placement_id == 2) seen_p2 = true; + } + + try testing.expectEqual(2, count); + try testing.expect(seen_p1); + try testing.expect(seen_p2); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index ef678e438..802ab72c3 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -9,6 +9,11 @@ pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); pub const kitty_graphics = @import("kitty_graphics.zig"); +pub const kitty_graphics_get = kitty_graphics.get; +pub const kitty_graphics_placement_iterator_new = kitty_graphics.placement_iterator_new; +pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iterator_free; +pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next; +pub const kitty_graphics_placement_get = kitty_graphics.placement_get; pub const types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); From 46a69ea63d2891eca2e404eddd1bfbd84c66de0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 09:24:52 -0700 Subject: [PATCH 24/62] libghostty: add kitty graphics image lookup API Add a GhosttyKittyGraphicsImage opaque type and API for looking up images by ID and querying their properties. This complements the existing placement iterator by allowing direct image introspection. The new ghostty_kitty_graphics_image() function looks up an image by its ID from the storage, returning a borrowed opaque handle. Properties are queried via ghostty_kitty_image_get() using the new GhosttyKittyGraphicsImageData enum, which exposes id, number, width, height, format, compression, and a borrowed data pointer with length. Format and compression are exposed as their own C enum types (GhosttyKittyImageFormat and GhosttyKittyImageCompression) rather than raw integers. --- include/ghostty/vt/kitty_graphics.h | 135 +++++++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/c/kitty_graphics.zig | 180 ++++++++++++++++++++++++++++ src/terminal/c/main.zig | 2 + 4 files changed, 319 insertions(+) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index df95b3a09..359e17ddc 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -36,6 +36,17 @@ extern "C" { */ typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics; +/** + * Opaque handle to a Kitty graphics image. + * + * Obtained via ghostty_kitty_graphics_image() with an image ID. The + * pointer is borrowed from the storage and remains valid until the next + * mutating terminal call. + * + * @ingroup kitty_graphics + */ +typedef const struct GhosttyKittyGraphicsImageImpl* GhosttyKittyGraphicsImage; + /** * Opaque handle to a Kitty graphics placement iterator. * @@ -156,6 +167,96 @@ typedef enum { GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12, } GhosttyKittyGraphicsPlacementData; +/** + * Pixel format of a Kitty graphics image. + * + * @ingroup kitty_graphics + */ +typedef enum { + GHOSTTY_KITTY_IMAGE_FORMAT_RGB = 0, + GHOSTTY_KITTY_IMAGE_FORMAT_RGBA = 1, + GHOSTTY_KITTY_IMAGE_FORMAT_PNG = 2, + GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA = 3, + GHOSTTY_KITTY_IMAGE_FORMAT_GRAY = 4, +} GhosttyKittyImageFormat; + +/** + * Compression of a Kitty graphics image. + * + * @ingroup kitty_graphics + */ +typedef enum { + GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE = 0, + GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE = 1, +} GhosttyKittyImageCompression; + +/** + * Queryable data kinds for ghostty_kitty_image_get(). + * + * @ingroup kitty_graphics + */ +typedef enum { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_IMAGE_DATA_INVALID = 0, + + /** + * The image ID. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_ID = 1, + + /** + * The image number. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_NUMBER = 2, + + /** + * Image width in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_WIDTH = 3, + + /** + * Image height in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_HEIGHT = 4, + + /** + * Pixel format of the image. + * + * Output type: GhosttyKittyImageFormat * + */ + GHOSTTY_KITTY_IMAGE_DATA_FORMAT = 5, + + /** + * Compression of the image. + * + * Output type: GhosttyKittyImageCompression * + */ + GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION = 6, + + /** + * Borrowed pointer to the raw pixel data. Valid as long as the + * underlying terminal is not mutated. + * + * Output type: const uint8_t ** + */ + GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7, + + /** + * Length of the raw pixel data in bytes. + * + * Output type: size_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, +} GhosttyKittyGraphicsImageData; + /** * Get data from a kitty graphics storage instance. * @@ -176,6 +277,40 @@ GHOSTTY_API GhosttyResult ghostty_kitty_graphics_get( GhosttyKittyGraphicsData data, void* out); +/** + * Look up a Kitty graphics image by its image ID. + * + * Returns NULL if no image with the given ID exists or if Kitty graphics + * are disabled at build time. + * + * @param graphics The kitty graphics handle + * @param image_id The image ID to look up + * @return An opaque image handle, or NULL if not found + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyKittyGraphicsImage ghostty_kitty_graphics_image( + GhosttyKittyGraphics graphics, + uint32_t image_id); + +/** + * Get data from a Kitty graphics image. + * + * The output pointer must be of the appropriate type for the requested + * data kind. + * + * @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_image_get( + GhosttyKittyGraphicsImage image, + GhosttyKittyGraphicsImageData data, + void* out); + /** * Create a new placement iterator instance. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index a009da01e..da113e4f7 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -234,6 +234,8 @@ comptime { @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); + @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); + @export(&c.kitty_image_get, .{ .name = "ghostty_kitty_image_get" }); @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" }); @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 39f6c1f8f..bad8b6130 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -3,6 +3,7 @@ const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; const kitty_gfx = @import("../kitty/graphics_storage.zig"); +const Image = @import("../kitty/graphics_image.zig").Image; const Result = @import("result.zig").Result; /// C: GhosttyKittyGraphics @@ -11,6 +12,12 @@ pub const KittyGraphics = if (build_options.kitty_graphics) else *anyopaque; +/// C: GhosttyKittyGraphicsImage +pub const ImageHandle = if (build_options.kitty_graphics) + ?*const Image +else + ?*const anyopaque; + /// C: GhosttyKittyGraphicsPlacementIterator pub const PlacementIterator = ?*PlacementIteratorWrapper; @@ -109,6 +116,103 @@ fn getTyped( return .success; } +/// C: GhosttyKittyImageFormat +pub const ImageFormat = enum(c_int) { + rgb = 0, + rgba = 1, + png = 2, + gray_alpha = 3, + gray = 4, +}; + +/// C: GhosttyKittyImageCompression +pub const ImageCompression = enum(c_int) { + none = 0, + zlib_deflate = 1, +}; + +/// C: GhosttyKittyGraphicsImageData +pub const ImageData = enum(c_int) { + invalid = 0, + id = 1, + number = 2, + width = 3, + height = 4, + format = 5, + compression = 6, + data_ptr = 7, + data_len = 8, + + pub fn OutType(comptime self: ImageData) type { + return switch (self) { + .invalid => void, + .id, .number, .width, .height => u32, + .format => ImageFormat, + .compression => ImageCompression, + .data_ptr => [*]const u8, + .data_len => usize, + }; + } +}; + +pub fn image_get_handle( + graphics_: KittyGraphics, + image_id: u32, +) callconv(lib.calling_conv) ImageHandle { + if (comptime !build_options.kitty_graphics) return null; + + const storage = graphics_; + return storage.images.getPtr(image_id); +} + +pub fn image_get( + image_: ImageHandle, + data: ImageData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| imageGetTyped( + image_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn imageGetTyped( + image_: ImageHandle, + comptime data: ImageData, + out: *data.OutType(), +) Result { + const image = image_ orelse return .invalid_value; + + switch (data) { + .invalid => return .invalid_value, + .id => out.* = image.id, + .number => out.* = image.number, + .width => out.* = image.width, + .height => out.* = image.height, + .format => out.* = switch (image.format) { + .rgb => .rgb, + .rgba => .rgba, + .png => .png, + .gray_alpha => .gray_alpha, + .gray => .gray, + }, + .compression => out.* = switch (image.compression) { + .none => .none, + .zlib_deflate => .zlib_deflate, + }, + .data_ptr => out.* = image.data.ptr, + .data_len => out.* = image.data.len, + } + + return .success; +} + pub fn placement_iterator_new( alloc_: ?*const CAllocator, out: *PlacementIterator, @@ -362,3 +466,79 @@ test "placement_iterator with multiple placements" { try testing.expect(seen_p1); try testing.expect(seen_p2); } + +test "image_get_handle returns null for missing id" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + try testing.expectEqual(@as(ImageHandle, null), image_get_handle(graphics, 999)); +} + +test "image_get_handle and image_get with transmitted image" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit a 1x2 RGB image with image_id=1. + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var id: u32 = undefined; + try testing.expectEqual(Result.success, image_get(img, .id, @ptrCast(&id))); + try testing.expectEqual(1, id); + + var w: u32 = undefined; + try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w))); + try testing.expectEqual(1, w); + + var h: u32 = undefined; + try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h))); + try testing.expectEqual(2, h); + + var fmt: ImageFormat = undefined; + try testing.expectEqual(Result.success, image_get(img, .format, @ptrCast(&fmt))); + try testing.expectEqual(.rgba, fmt); + + var comp: ImageCompression = undefined; + try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp))); + try testing.expectEqual(.none, comp); + + var data_len: usize = undefined; + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len))); + try testing.expect(data_len > 0); +} + +test "image_get on null returns invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var id: u32 = undefined; + try testing.expectEqual(Result.invalid_value, image_get(null, .id, @ptrCast(&id))); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 802ab72c3..c6e11c5e8 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -10,6 +10,8 @@ pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); pub const kitty_graphics = @import("kitty_graphics.zig"); pub const kitty_graphics_get = kitty_graphics.get; +pub const kitty_graphics_image = kitty_graphics.image_get_handle; +pub const kitty_image_get = kitty_graphics.image_get; pub const kitty_graphics_placement_iterator_new = kitty_graphics.placement_iterator_new; pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iterator_free; pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next; From 9ff4bb2df5d2542f7f4e189aebe309d907e3449e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 09:31:05 -0700 Subject: [PATCH 25/62] terminal/kitty: convert Format, Medium, Compression to lib.Enum Convert the Transmission.Format, Transmission.Medium, and Transmission.Compression types from plain Zig enums to lib.Enum so they get a C-compatible backing type when building with c_abi. This lets the C API layer reuse the types directly instead of maintaining separate mirror enums. Move Format.bpp() to a standalone Transmission.formatBpp() function since lib.Enum types cannot have decls. In the C API layer, rename kitty_gfx to kitty_storage and command to kitty_cmd for clarity, and simplify the format/compression getters to direct assignment now that the types are shared. --- src/terminal/c/kitty_graphics.zig | 53 ++++++++++-------------- src/terminal/kitty/graphics_command.zig | 54 ++++++++++++------------- src/terminal/kitty/graphics_image.zig | 6 +-- 3 files changed, 51 insertions(+), 62 deletions(-) diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index bad8b6130..ecfc574c2 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -2,13 +2,14 @@ const std = @import("std"); const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; -const kitty_gfx = @import("../kitty/graphics_storage.zig"); +const kitty_storage = @import("../kitty/graphics_storage.zig"); +const kitty_cmd = @import("../kitty/graphics_command.zig"); const Image = @import("../kitty/graphics_image.zig").Image; const Result = @import("result.zig").Result; /// C: GhosttyKittyGraphics pub const KittyGraphics = if (build_options.kitty_graphics) - *kitty_gfx.ImageStorage + *kitty_storage.ImageStorage else *anyopaque; @@ -22,8 +23,8 @@ else pub const PlacementIterator = ?*PlacementIteratorWrapper; const PlacementMap = std.AutoHashMapUnmanaged( - kitty_gfx.ImageStorage.PlacementKey, - kitty_gfx.ImageStorage.Placement, + kitty_storage.ImageStorage.PlacementKey, + kitty_storage.ImageStorage.Placement, ); const PlacementIteratorWrapper = struct { @@ -117,19 +118,10 @@ fn getTyped( } /// C: GhosttyKittyImageFormat -pub const ImageFormat = enum(c_int) { - rgb = 0, - rgba = 1, - png = 2, - gray_alpha = 3, - gray = 4, -}; +pub const ImageFormat = kitty_cmd.Transmission.Format; /// C: GhosttyKittyImageCompression -pub const ImageCompression = enum(c_int) { - none = 0, - zlib_deflate = 1, -}; +pub const ImageCompression = kitty_cmd.Transmission.Compression; /// C: GhosttyKittyGraphicsImageData pub const ImageData = enum(c_int) { @@ -195,17 +187,8 @@ fn imageGetTyped( .number => out.* = image.number, .width => out.* = image.width, .height => out.* = image.height, - .format => out.* = switch (image.format) { - .rgb => .rgb, - .rgba => .rgba, - .png => .png, - .gray_alpha => .gray_alpha, - .gray => .gray, - }, - .compression => out.* = switch (image.compression) { - .none => .none, - .zlib_deflate => .zlib_deflate, - }, + .format => out.* = image.format, + .compression => out.* = image.compression, .data_ptr => out.* = image.data.ptr, .data_len => out.* = image.data.len, } @@ -306,7 +289,8 @@ test "placement_iterator next on empty storage" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( - &lib.alloc.test_allocator, &t, + &lib.alloc.test_allocator, + &t, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, )); defer terminal_c.free(t); @@ -334,7 +318,8 @@ test "placement_iterator get before next returns invalid" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( - &lib.alloc.test_allocator, &t, + &lib.alloc.test_allocator, + &t, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, )); defer terminal_c.free(t); @@ -364,7 +349,8 @@ test "placement_iterator with transmit and display" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( - &lib.alloc.test_allocator, &t, + &lib.alloc.test_allocator, + &t, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, )); defer terminal_c.free(t); @@ -416,7 +402,8 @@ test "placement_iterator with multiple placements" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( - &lib.alloc.test_allocator, &t, + &lib.alloc.test_allocator, + &t, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, )); defer terminal_c.free(t); @@ -472,7 +459,8 @@ test "image_get_handle returns null for missing id" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( - &lib.alloc.test_allocator, &t, + &lib.alloc.test_allocator, + &t, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, )); defer terminal_c.free(t); @@ -492,7 +480,8 @@ test "image_get_handle and image_get with transmitted image" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( - &lib.alloc.test_allocator, &t, + &lib.alloc.test_allocator, + &t, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, )); defer terminal_c.free(t); diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index dfce56e35..d1f0e6b63 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -3,6 +3,7 @@ const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const simd = @import("../../simd/main.zig"); +const lib = @import("../lib.zig"); const log = std.log.scoped(.kitty_gfx); @@ -394,39 +395,38 @@ pub const Transmission = struct { compression: Compression = .none, // o more_chunks: bool = false, // m - pub const Format = enum { - rgb, // 24 - rgba, // 32 - png, // 100 - + pub const Format = lib.Enum(lib.target, &.{ + "rgb", // 24 + "rgba", // 32 + "png", // 100 // The following are not supported directly via the protocol // but they are formats that a png may decode to that we // support. - gray_alpha, - gray, + "gray_alpha", + "gray", + }); - pub fn bpp(self: Format) u8 { - return switch (self) { - .gray => 1, - .gray_alpha => 2, - .rgb => 3, - .rgba => 4, - .png => unreachable, // Must be validated before - }; - } - }; + pub const Medium = lib.Enum(lib.target, &.{ + "direct", // d + "file", // f + "temporary_file", // t + "shared_memory", // s + }); - pub const Medium = enum { - direct, // d - file, // f - temporary_file, // t - shared_memory, // s - }; + pub const Compression = lib.Enum(lib.target, &.{ + "none", + "zlib_deflate", // z + }); - pub const Compression = enum { - none, - zlib_deflate, // z - }; + pub fn formatBpp(format: Format) u8 { + return switch (format) { + .gray => 1, + .gray_alpha => 2, + .rgb => 3, + .rgba => 4, + .png => unreachable, // Must be validated before + }; + } fn parse(kv: KV) !Transmission { var result: Transmission = .{}; diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index f1f055fa0..bddc5c5b2 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -202,8 +202,8 @@ pub const LoadingImage = struct { .png => stat_size, // For these formats we have a size we must have. - .gray, .gray_alpha, .rgb, .rgba => |f| size: { - const bpp = f.bpp(); + .gray, .gray_alpha, .rgb, .rgba => size: { + const bpp = command.Transmission.formatBpp(self.image.format); break :size self.image.width * self.image.height * bpp; }, }; @@ -390,7 +390,7 @@ pub const LoadingImage = struct { if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge; // Data length must be what we expect - const bpp = img.format.bpp(); + const bpp = command.Transmission.formatBpp(img.format); const expected_len = img.width * img.height * bpp; const actual_len = self.data.items.len; if (actual_len != expected_len) { From 714420409be233bb0acacdc60f6d15f6822de8e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 09:52:49 -0700 Subject: [PATCH 26/62] libghostty: add placement_rect and centralize opaque typedefs Expose Placement.rect() from the Zig kitty graphics storage as a new C API function ghostty_kitty_graphics_placement_rect(). It takes the terminal, image handle, and a positioned placement iterator, and writes the bounding grid rectangle into a GhosttySelection out param. Virtual placements return GHOSTTY_NO_VALUE. Move all opaque handle typedefs (GhosttyTerminal, GhosttyKittyGraphics, GhosttyRenderState, GhosttySgrParser, GhosttyFormatter, GhosttyOsc*) into types.h so they are available everywhere without circular includes and Doxygen renders them in the correct @ingroup sections. --- include/ghostty/vt/formatter.h | 7 -- include/ghostty/vt/kitty_graphics.h | 55 ++++++++-------- include/ghostty/vt/osc.h | 20 ------ include/ghostty/vt/render.h | 21 ------ include/ghostty/vt/sgr.h | 10 --- include/ghostty/vt/terminal.h | 7 -- include/ghostty/vt/types.h | 99 +++++++++++++++++++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/kitty_graphics.zig | 92 ++++++++++++++++++++++++++- src/terminal/c/main.zig | 1 + 10 files changed, 216 insertions(+), 97 deletions(-) diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h index 19f6664c3..9eacc6409 100644 --- a/include/ghostty/vt/formatter.h +++ b/include/ghostty/vt/formatter.h @@ -107,13 +107,6 @@ typedef struct { GhosttyFormatterScreenExtra screen; } GhosttyFormatterTerminalExtra; -/** - * Opaque handle to a formatter instance. - * - * @ingroup formatter - */ -typedef struct GhosttyFormatterImpl* GhosttyFormatter; - /** * Options for creating a terminal formatter. * diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index 359e17ddc..25f3128e1 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #ifdef __cplusplus @@ -24,36 +25,6 @@ extern "C" { * @{ */ -/** - * Opaque handle to a Kitty graphics image storage. - * - * Obtained via ghostty_terminal_get() with - * GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. The pointer is borrowed from - * the terminal and remains valid until the next mutating terminal call - * (e.g. ghostty_terminal_vt_write() or ghostty_terminal_reset()). - * - * @ingroup kitty_graphics - */ -typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics; - -/** - * Opaque handle to a Kitty graphics image. - * - * Obtained via ghostty_kitty_graphics_image() with an image ID. The - * pointer is borrowed from the storage and remains valid until the next - * mutating terminal call. - * - * @ingroup kitty_graphics - */ -typedef const struct GhosttyKittyGraphicsImageImpl* GhosttyKittyGraphicsImage; - -/** - * Opaque handle to a Kitty graphics placement iterator. - * - * @ingroup kitty_graphics - */ -typedef struct GhosttyKittyGraphicsPlacementIteratorImpl* GhosttyKittyGraphicsPlacementIterator; - /** * Queryable data kinds for ghostty_kitty_graphics_get(). * @@ -369,6 +340,30 @@ GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get( GhosttyKittyGraphicsPlacementData data, void* out); +/** + * Compute the grid rectangle occupied by the current placement. + * + * Uses the placement's pin, the image dimensions, and the terminal's + * cell/pixel geometry to calculate the bounding rectangle. Virtual + * placements (unicode placeholders) return GHOSTTY_NO_VALUE. + * + * @param terminal The terminal handle + * @param image The image handle for this placement's image + * @param iterator The placement iterator positioned on a placement + * @param[out] out_selection On success, receives the bounding rectangle + * as a selection with rectangle=true + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE for + * virtual placements or when Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_rect( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + GhosttySelection* out_selection); + /** @} */ #ifdef __cplusplus diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h index c86498090..e17a8a182 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -13,26 +13,6 @@ #include #include -/** - * Opaque handle to an OSC parser instance. - * - * This handle represents an OSC (Operating System Command) parser that can - * be used to parse the contents of OSC sequences. - * - * @ingroup osc - */ -typedef struct GhosttyOscParserImpl *GhosttyOscParser; - -/** - * Opaque handle to a single OSC command. - * - * This handle represents a parsed OSC (Operating System Command) command. - * The command can be queried for its type and associated data. - * - * @ingroup osc - */ -typedef struct GhosttyOscCommandImpl *GhosttyOscCommand; - /** @defgroup osc OSC Parser * * OSC (Operating System Command) sequence parser and command handling. diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index 163a4e1d4..b15be4902 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -81,27 +81,6 @@ extern "C" { * @{ */ -/** - * Opaque handle to a render state instance. - * - * @ingroup render - */ -typedef struct GhosttyRenderStateImpl* GhosttyRenderState; - -/** - * Opaque handle to a render-state row iterator. - * - * @ingroup render - */ -typedef struct GhosttyRenderStateRowIteratorImpl* GhosttyRenderStateRowIterator; - -/** - * Opaque handle to render-state row cells. - * - * @ingroup render - */ -typedef struct GhosttyRenderStateRowCellsImpl* GhosttyRenderStateRowCells; - /** * Dirty state of a render state after update. * diff --git a/include/ghostty/vt/sgr.h b/include/ghostty/vt/sgr.h index 01ea3a359..b093bc9ff 100644 --- a/include/ghostty/vt/sgr.h +++ b/include/ghostty/vt/sgr.h @@ -47,16 +47,6 @@ extern "C" { #endif -/** - * Opaque handle to an SGR parser instance. - * - * This handle represents an SGR (Select Graphic Rendition) parser that can - * be used to parse SGR sequences and extract individual text attributes. - * - * @ingroup sgr - */ -typedef struct GhosttySgrParserImpl* GhosttySgrParser; - /** * SGR attribute tags. * diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index ff3f60ae1..73db8d6d1 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -155,13 +155,6 @@ extern "C" { * @{ */ -/** - * Opaque handle to a terminal instance. - * - * @ingroup terminal - */ -typedef struct GhosttyTerminalImpl* GhosttyTerminal; - /** * Terminal initialization options. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 8f0be7760..0fe37e3b2 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -48,6 +48,105 @@ typedef enum { GHOSTTY_NO_VALUE = -4, } GhosttyResult; +/* ---- Opaque handles ---- */ + +/** + * Opaque handle to a terminal instance. + * + * @ingroup terminal + */ +typedef struct GhosttyTerminalImpl* GhosttyTerminal; + +/** + * Opaque handle to a Kitty graphics image storage. + * + * Obtained via ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. The pointer is borrowed from + * the terminal and remains valid until the next mutating terminal call + * (e.g. ghostty_terminal_vt_write() or ghostty_terminal_reset()). + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics; + +/** + * Opaque handle to a Kitty graphics image. + * + * Obtained via ghostty_kitty_graphics_image() with an image ID. The + * pointer is borrowed from the storage and remains valid until the next + * mutating terminal call. + * + * @ingroup kitty_graphics + */ +typedef const struct GhosttyKittyGraphicsImageImpl* GhosttyKittyGraphicsImage; + +/** + * Opaque handle to a Kitty graphics placement iterator. + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsPlacementIteratorImpl* GhosttyKittyGraphicsPlacementIterator; + +/** + * Opaque handle to a render state instance. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateImpl* GhosttyRenderState; + +/** + * Opaque handle to a render-state row iterator. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowIteratorImpl* GhosttyRenderStateRowIterator; + +/** + * Opaque handle to render-state row cells. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowCellsImpl* GhosttyRenderStateRowCells; + +/** + * Opaque handle to an SGR parser instance. + * + * This handle represents an SGR (Select Graphic Rendition) parser that can + * be used to parse SGR sequences and extract individual text attributes. + * + * @ingroup sgr + */ +typedef struct GhosttySgrParserImpl* GhosttySgrParser; + +/** + * Opaque handle to a formatter instance. + * + * @ingroup formatter + */ +typedef struct GhosttyFormatterImpl* GhosttyFormatter; + +/** + * Opaque handle to an OSC parser instance. + * + * This handle represents an OSC (Operating System Command) parser that can + * be used to parse the contents of OSC sequences. + * + * @ingroup osc + */ +typedef struct GhosttyOscParserImpl* GhosttyOscParser; + +/** + * Opaque handle to a single OSC command. + * + * This handle represents a parsed OSC (Operating System Command) command. + * The command can be queried for its type and associated data. + * + * @ingroup osc + */ +typedef struct GhosttyOscCommandImpl* GhosttyOscCommand; + +/* ---- Common value types ---- */ + /** * A borrowed byte string (pointer + length). * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index da113e4f7..9098f9dbc 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -240,6 +240,7 @@ comptime { @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" }); + @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" }); @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index ecfc574c2..70ad5f818 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -1,10 +1,14 @@ const std = @import("std"); +const testing = std.testing; const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; const kitty_storage = @import("../kitty/graphics_storage.zig"); const kitty_cmd = @import("../kitty/graphics_command.zig"); const Image = @import("../kitty/graphics_image.zig").Image; +const grid_ref = @import("grid_ref.zig"); +const selection_c = @import("selection.zig"); +const terminal_c = @import("terminal.zig"); const Result = @import("result.zig").Result; /// C: GhosttyKittyGraphics @@ -267,8 +271,31 @@ fn placementGetTyped( return .success; } -const testing = std.testing; -const terminal_c = @import("terminal.zig"); +pub fn placement_rect( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out: *selection_c.CSelection, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const r = entry.value_ptr.rect( + image.*, + wrapper.terminal, + ) orelse return .no_value; + + out.* = .{ + .start = grid_ref.CGridRef.fromPin(r.top_left), + .end = grid_ref.CGridRef.fromPin(r.bottom_right), + .rectangle = true, + }; + + return .success; +} test "placement_iterator new/free" { var iter: PlacementIterator = null; @@ -525,6 +552,67 @@ test "image_get_handle and image_get with transmitted image" { try testing.expect(data_len > 0); } +test "placement_rect with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Set cell size so grid calculations are deterministic. + // 80 cols * 10px = 800px, 24 rows * 20px = 480px. + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display a 1x2 RGB image at cursor (0,0). + // c=10,r=1 => 10 columns, 1 row. + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, placement_rect(iter, img, t, &sel)); + + // Placement starts at cursor origin (0,0). + try testing.expectEqual(0, sel.start.x); + try testing.expectEqual(0, sel.start.y); + + // 10 columns wide, 1 row tall => bottom-right is (9, 0). + try testing.expectEqual(9, sel.end.x); + try testing.expectEqual(0, sel.end.y); + + try testing.expect(sel.rectangle); +} + +test "placement_rect null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.invalid_value, placement_rect(null, null, null, &sel)); +} + test "image_get on null returns invalid_value" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index c6e11c5e8..76c471dd0 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -16,6 +16,7 @@ pub const kitty_graphics_placement_iterator_new = kitty_graphics.placement_itera pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iterator_free; pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next; pub const kitty_graphics_placement_get = kitty_graphics.placement_get; +pub const kitty_graphics_placement_rect = kitty_graphics.placement_rect; pub const types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); From 03a6eeda1de9b00164c97960b176fe2b8457acb9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 10:01:43 -0700 Subject: [PATCH 27/62] libghostty: add placement pixel_size and grid_size, rename calculatedSize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose Placement.pixelSize() and Placement.gridSize() as new C API functions ghostty_kitty_graphics_placement_pixel_size() and ghostty_kitty_graphics_placement_grid_size(). Both take the placement iterator, image handle, and terminal, returning their results via out params. Rename the internal Zig method from calculatedSize to pixelSize to pair naturally with gridSize — one returns pixels, the other grid cells. Updated all callers including the renderer. --- include/ghostty/vt/kitty_graphics.h | 51 ++++++++ src/lib_vt.zig | 2 + src/renderer/image.zig | 2 +- src/terminal/c/kitty_graphics.zig | 151 ++++++++++++++++++++++++ src/terminal/c/main.zig | 2 + src/terminal/kitty/graphics_storage.zig | 13 +- 6 files changed, 214 insertions(+), 7 deletions(-) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index 25f3128e1..124dc4265 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -364,6 +364,57 @@ GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_rect( GhosttyTerminal terminal, GhosttySelection* out_selection); +/** + * Compute the rendered pixel size of the current placement. + * + * Takes into account the placement's source rectangle, specified + * columns/rows, and aspect ratio to calculate the final rendered + * pixel dimensions. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_width On success, receives the width in pixels + * @param[out] out_height On success, receives the height in pixels + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when + * Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_pixel_size( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + uint32_t* out_width, + uint32_t* out_height); + +/** + * Compute the grid cell size of the current placement. + * + * Returns the number of columns and rows that the placement occupies + * in the terminal grid. If the placement specifies explicit columns + * and rows, those are returned directly; otherwise they are calculated + * from the pixel size and cell dimensions. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_cols On success, receives the number of columns + * @param[out] out_rows On success, receives the number of rows + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when + * Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_grid_size( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + uint32_t* out_cols, + uint32_t* out_rows); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 9098f9dbc..dcdd1f1b8 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -241,6 +241,8 @@ comptime { @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" }); @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" }); + @export(&c.kitty_graphics_placement_pixel_size, .{ .name = "ghostty_kitty_graphics_placement_pixel_size" }); + @export(&c.kitty_graphics_placement_grid_size, .{ .name = "ghostty_kitty_graphics_placement_grid_size" }); @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); diff --git a/src/renderer/image.zig b/src/renderer/image.zig index c43d27981..442b7543f 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -426,7 +426,7 @@ pub const State = struct { // Calculate the dimensions of our image, taking in to // account the rows / columns specified by the placement. - const dest_size = p.calculatedSize(image.*, t); + const dest_size = p.pixelSize(image.*, t); // Calculate the source rectangle const source_x = @min(image.width, p.source_x); diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 70ad5f818..9ed58e308 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -297,6 +297,48 @@ pub fn placement_rect( return .success; } +pub fn placement_pixel_size( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out_width: *u32, + out_height: *u32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const s = entry.value_ptr.pixelSize(image.*, wrapper.terminal); + + out_width.* = s.width; + out_height.* = s.height; + + return .success; +} + +pub fn placement_grid_size( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out_cols: *u32, + out_rows: *u32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const s = entry.value_ptr.gridSize(image.*, wrapper.terminal); + + out_cols.* = s.cols; + out_rows.* = s.rows; + + return .success; +} + test "placement_iterator new/free" { var iter: PlacementIterator = null; try testing.expectEqual(Result.success, placement_iterator_new( @@ -613,6 +655,115 @@ test "placement_rect null args return invalid_value" { try testing.expectEqual(Result.invalid_value, placement_rect(null, null, null, &sel)); } +test "placement_pixel_size with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // 80 cols * 10px = 800px, 24 rows * 20px = 480px. + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display a 1x2 RGB image with c=10,r=1. + // 10 cols * 10px = 100px width, 1 row * 20px = 20px height. + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_pixel_size(iter, img, t, &w, &h)); + + try testing.expectEqual(100, w); + try testing.expectEqual(20, h); +} + +test "placement_pixel_size null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_pixel_size(null, null, null, &w, &h)); +} + +test "placement_grid_size with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // 80 cols * 10px = 800px, 24 rows * 20px = 480px. + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display a 1x2 RGB image with c=10,r=1. + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var cols: u32 = undefined; + var rows: u32 = undefined; + try testing.expectEqual(Result.success, placement_grid_size(iter, img, t, &cols, &rows)); + + try testing.expectEqual(10, cols); + try testing.expectEqual(1, rows); +} + +test "placement_grid_size null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var cols: u32 = undefined; + var rows: u32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_grid_size(null, null, null, &cols, &rows)); +} + test "image_get on null returns invalid_value" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 76c471dd0..2599bb971 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -17,6 +17,8 @@ pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iter pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next; pub const kitty_graphics_placement_get = kitty_graphics.placement_get; pub const kitty_graphics_placement_rect = kitty_graphics.placement_rect; +pub const kitty_graphics_placement_pixel_size = kitty_graphics.placement_pixel_size; +pub const kitty_graphics_placement_grid_size = kitty_graphics.placement_grid_size; pub const types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 65c26dc85..e017d5f79 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -662,9 +662,10 @@ pub const ImageStorage = struct { } } - /// Calculates the size of this placement's image in pixels, - /// taking in to account the specified rows and columns. - pub fn calculatedSize( + /// Returns the size of this placement's image in pixels, + /// taking into account the source rectangle, specified + /// rows/columns, and aspect ratio. + pub fn pixelSize( self: Placement, image: Image, t: *const terminal.Terminal, @@ -759,7 +760,7 @@ pub const ImageStorage = struct { // Otherwise we calculate the pixel size, divide by // cell size, and round up to the nearest integer. - const calc_size = self.calculatedSize(image, t); + const calc_size = self.pixelSize(image, t); return .{ .cols = std.math.divCeil( u32, @@ -1338,7 +1339,7 @@ test "storage: aspect ratio calculation when only columns or rows specified" { // that's 100px width. 100px * (9 / 16) = 56.25, which should round // to a height of 56px. - const calc_size = placement.calculatedSize(image, &t); + const calc_size = placement.pixelSize(image, &t); try testing.expectEqual(@as(u32, 100), calc_size.width); try testing.expectEqual(@as(u32, 56), calc_size.height); } @@ -1356,7 +1357,7 @@ test "storage: aspect ratio calculation when only columns or rows specified" { // 100px height. 100px * (16 / 9) = 177.77..., which should round to // a width of 178px. - const calc_size = placement.calculatedSize(image, &t); + const calc_size = placement.pixelSize(image, &t); try testing.expectEqual(@as(u32, 178), calc_size.width); try testing.expectEqual(@as(u32, 100), calc_size.height); } From 426dc40799407b3ec564324438b73cce03f79835 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 10:07:09 -0700 Subject: [PATCH 28/62] example: update c-vt-kitty-graphics to use new APIs --- example/c-vt-kitty-graphics/src/main.c | 80 ++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/example/c-vt-kitty-graphics/src/main.c b/example/c-vt-kitty-graphics/src/main.c index f3478811b..fb8087697 100644 --- a/example/c-vt-kitty-graphics/src/main.c +++ b/example/c-vt-kitty-graphics/src/main.c @@ -82,6 +82,9 @@ int main() { return 1; } + /* Set cell pixel dimensions so kitty graphics can compute grid sizes. */ + ghostty_terminal_resize(terminal, 80, 24, 8, 16); + /* Set a storage limit to enable Kitty graphics. */ uint64_t storage_limit = 64 * 1024 * 1024; /* 64 MiB */ ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, @@ -113,6 +116,83 @@ int main() { printf("PNG decode calls: %d\n", decode_count); + /* Query the kitty graphics storage to verify the image was stored. */ + GhosttyKittyGraphics graphics = NULL; + if (ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS, + &graphics) != GHOSTTY_SUCCESS || !graphics) { + fprintf(stderr, "Failed to get kitty graphics storage\n"); + return 1; + } + printf("\nKitty graphics storage is available.\n"); + + /* Iterate placements to find the image ID. */ + GhosttyKittyGraphicsPlacementIterator iter = NULL; + if (ghostty_kitty_graphics_placement_iterator_new(NULL, &iter) != GHOSTTY_SUCCESS) { + fprintf(stderr, "Failed to create placement iterator\n"); + return 1; + } + if (ghostty_kitty_graphics_get(graphics, + GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR, &iter) != GHOSTTY_SUCCESS) { + fprintf(stderr, "Failed to get placement iterator\n"); + return 1; + } + + int placement_count = 0; + while (ghostty_kitty_graphics_placement_next(iter)) { + placement_count++; + uint32_t image_id = 0; + uint32_t placement_id = 0; + bool is_virtual = false; + int32_t z = 0; + + ghostty_kitty_graphics_placement_get(iter, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID, &image_id); + ghostty_kitty_graphics_placement_get(iter, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID, &placement_id); + ghostty_kitty_graphics_placement_get(iter, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL, &is_virtual); + ghostty_kitty_graphics_placement_get(iter, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z, &z); + + printf(" placement #%d: image_id=%u placement_id=%u virtual=%s z=%d\n", + placement_count, image_id, placement_id, + is_virtual ? "true" : "false", z); + + /* Look up the image and print its properties. */ + GhosttyKittyGraphicsImage image = + ghostty_kitty_graphics_image(graphics, image_id); + if (!image) { + fprintf(stderr, "Failed to look up image %u\n", image_id); + return 1; + } + + uint32_t width = 0, height = 0, number = 0; + GhosttyKittyImageFormat format = 0; + size_t data_len = 0; + + ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_NUMBER, &number); + ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_WIDTH, &width); + ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_HEIGHT, &height); + ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_FORMAT, &format); + ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, &data_len); + + printf(" image: number=%u size=%ux%u format=%d data_len=%zu\n", + number, width, height, format, data_len); + + /* Compute the rendered pixel size and grid size. */ + uint32_t px_w = 0, px_h = 0, cols = 0, rows = 0; + if (ghostty_kitty_graphics_placement_pixel_size(iter, image, terminal, + &px_w, &px_h) == GHOSTTY_SUCCESS) { + printf(" rendered pixel size: %ux%u\n", px_w, px_h); + } + if (ghostty_kitty_graphics_placement_grid_size(iter, image, terminal, + &cols, &rows) == GHOSTTY_SUCCESS) { + printf(" grid size: %u cols x %u rows\n", cols, rows); + } + } + printf("Total placements: %d\n", placement_count); + ghostty_kitty_graphics_placement_iterator_free(iter); + /* Clean up. */ ghostty_terminal_free(terminal); From 68a8cbb065028b8de4f3b9e0d1676891e8018bbe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 10:09:06 -0700 Subject: [PATCH 29/62] libghostty: fix expected format in image_get test The test transmits an image with f=24 (24-bit RGB) but was asserting that the format field equals .rgba (32-bit). Corrected the expectation to .rgb to match the transmitted pixel format. --- src/terminal/c/kitty_graphics.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 9ed58e308..5444b8bab 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -583,7 +583,7 @@ test "image_get_handle and image_get with transmitted image" { var fmt: ImageFormat = undefined; try testing.expectEqual(Result.success, image_get(img, .format, @ptrCast(&fmt))); - try testing.expectEqual(.rgba, fmt); + try testing.expectEqual(.rgb, fmt); var comp: ImageCompression = undefined; try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp))); From fc9299a41df3c2cb7c987350e9a5cb67433e2835 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 10:14:24 -0700 Subject: [PATCH 30/62] libghostty: rename ghostty_kitty_image_get to ghostty_kitty_graphics_image_get Rename the public API function to follow the consistent ghostty_kitty_graphics_* naming convention used by the other kitty graphics API symbols. --- example/c-vt-kitty-graphics/src/main.c | 10 +++++----- include/ghostty/vt/kitty_graphics.h | 4 ++-- src/lib_vt.zig | 2 +- src/terminal/c/main.zig | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/example/c-vt-kitty-graphics/src/main.c b/example/c-vt-kitty-graphics/src/main.c index fb8087697..5001c3707 100644 --- a/example/c-vt-kitty-graphics/src/main.c +++ b/example/c-vt-kitty-graphics/src/main.c @@ -170,11 +170,11 @@ int main() { GhosttyKittyImageFormat format = 0; size_t data_len = 0; - ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_NUMBER, &number); - ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_WIDTH, &width); - ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_HEIGHT, &height); - ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_FORMAT, &format); - ghostty_kitty_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, &data_len); + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_NUMBER, &number); + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_WIDTH, &width); + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_HEIGHT, &height); + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_FORMAT, &format); + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, &data_len); printf(" image: number=%u size=%ux%u format=%d data_len=%zu\n", number, width, height, format, data_len); diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index 124dc4265..e31bb2c65 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -162,7 +162,7 @@ typedef enum { } GhosttyKittyImageCompression; /** - * Queryable data kinds for ghostty_kitty_image_get(). + * Queryable data kinds for ghostty_kitty_graphics_image_get(). * * @ingroup kitty_graphics */ @@ -277,7 +277,7 @@ GHOSTTY_API GhosttyKittyGraphicsImage ghostty_kitty_graphics_image( * * @ingroup kitty_graphics */ -GHOSTTY_API GhosttyResult ghostty_kitty_image_get( +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_image_get( GhosttyKittyGraphicsImage image, GhosttyKittyGraphicsImageData data, void* out); diff --git a/src/lib_vt.zig b/src/lib_vt.zig index dcdd1f1b8..ce2e4d5b6 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -235,7 +235,7 @@ comptime { @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); - @export(&c.kitty_image_get, .{ .name = "ghostty_kitty_image_get" }); + @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" }); @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 2599bb971..ebfe2571d 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -11,7 +11,7 @@ pub const grid_ref = @import("grid_ref.zig"); pub const kitty_graphics = @import("kitty_graphics.zig"); pub const kitty_graphics_get = kitty_graphics.get; pub const kitty_graphics_image = kitty_graphics.image_get_handle; -pub const kitty_image_get = kitty_graphics.image_get; +pub const kitty_graphics_image_get = kitty_graphics.image_get; pub const kitty_graphics_placement_iterator_new = kitty_graphics.placement_iterator_new; pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iterator_free; pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next; From 20b7fe0e1dd485af1f64ff5ce5d08135274896e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 10:30:00 -0700 Subject: [PATCH 31/62] libghostty: gate kitty graphics placement types on build option The PlacementIterator, PlacementMap, and PlacementIteratorWrapper types in the C API were unconditionally referencing kitty_storage.ImageStorage, which transitively pulled in Image.transmit_time (std.time.Instant). On wasm32-freestanding, std.time.Instant requires posix.timespec which does not exist, causing a compilation error. Gate these types behind build_options.kitty_graphics, matching the existing pattern used for KittyGraphics and ImageHandle. When kitty graphics is disabled, they fall back to opaque/void types. Add early-return guards to placement_iterator_new and placement_iterator_free which directly operate on the wrapper struct. --- src/terminal/c/kitty_graphics.zig | 34 ++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 5444b8bab..f5811a024 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -24,18 +24,27 @@ else ?*const anyopaque; /// C: GhosttyKittyGraphicsPlacementIterator -pub const PlacementIterator = ?*PlacementIteratorWrapper; +pub const PlacementIterator = if (build_options.kitty_graphics) + ?*PlacementIteratorWrapper +else + ?*anyopaque; -const PlacementMap = std.AutoHashMapUnmanaged( - kitty_storage.ImageStorage.PlacementKey, - kitty_storage.ImageStorage.Placement, -); +const PlacementMap = if (build_options.kitty_graphics) + std.AutoHashMapUnmanaged( + kitty_storage.ImageStorage.PlacementKey, + kitty_storage.ImageStorage.Placement, + ) +else + void; -const PlacementIteratorWrapper = struct { - alloc: std.mem.Allocator, - inner: PlacementMap.Iterator = undefined, - entry: ?PlacementMap.Entry = null, -}; +const PlacementIteratorWrapper = if (build_options.kitty_graphics) + struct { + alloc: std.mem.Allocator, + inner: PlacementMap.Iterator = undefined, + entry: ?PlacementMap.Entry = null, + } +else + void; /// C: GhosttyKittyGraphicsData pub const Data = enum(c_int) { @@ -204,6 +213,10 @@ pub fn placement_iterator_new( alloc_: ?*const CAllocator, out: *PlacementIterator, ) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) { + out.* = null; + return .no_value; + } const alloc = lib.alloc.default(alloc_); const ptr = alloc.create(PlacementIteratorWrapper) catch { out.* = null; @@ -215,6 +228,7 @@ pub fn placement_iterator_new( } pub fn placement_iterator_free(iter_: PlacementIterator) callconv(lib.calling_conv) void { + if (comptime !build_options.kitty_graphics) return; const iter = iter_ orelse return; iter.alloc.destroy(iter); } From 6b94c2da26653cc8feeaee3ef90166b3ad1e3aee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 10:45:03 -0700 Subject: [PATCH 32/62] libghostty: add ghostty_terminal_point_from_grid_ref Add the inverse of ghostty_terminal_grid_ref(), converting a grid reference back to coordinates in a requested coordinate system (active, viewport, screen, or history). This wraps the existing internal PageList.pointFromPin and is placed on the terminal API since it requires terminal-owned PageList state to resolve the top-left anchor for each coordinate system. Returns GHOSTTY_NO_VALUE when the ref falls outside the requested range, e.g. a scrollback ref cannot be expressed in active coordinates. --- include/ghostty/vt/terminal.h | 33 ++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 110 ++++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 73db8d6d1..a229dd700 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1064,6 +1064,39 @@ GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal, GhosttyPoint point, GhosttyGridRef *out_ref); +/** + * Convert a grid reference back to a point in the given coordinate system. + * + * This is the inverse of ghostty_terminal_grid_ref(): given a grid reference, + * it returns the x/y coordinates in the requested coordinate system (active, + * viewport, screen, or history). + * + * The grid reference must have been obtained from the same terminal instance. + * Like all grid references, it is only valid until the next mutating terminal + * call. + * + * Not every grid reference is representable in every coordinate system. For + * example, a cell in scrollback history cannot be expressed in active + * coordinates, and a cell that has scrolled off the visible area cannot be + * expressed in viewport coordinates. In these cases, the function returns + * GHOSTTY_NO_VALUE. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param ref Pointer to the grid reference to convert + * @param tag The target coordinate system + * @param[out] out On success, set to the coordinate in the requested system (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * or ref is NULL/invalid, GHOSTTY_NO_VALUE if the ref falls outside + * the requested coordinate system + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_point_from_grid_ref( + GhosttyTerminal terminal, + const GhosttyGridRef *ref, + GhosttyPointTag tag, + GhosttyPointCoordinate *out); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index ce2e4d5b6..3799fbe66 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -233,6 +233,7 @@ comptime { @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); + @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index ebfe2571d..3f5f65f49 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -157,6 +157,7 @@ pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_grid_ref = terminal.grid_ref; +pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; pub const type_json = types.get_json; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 32bc0311a..8a2a3d40b 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -697,6 +697,20 @@ pub fn grid_ref( return .success; } +pub fn point_from_grid_ref( + terminal_: Terminal, + ref: *const grid_ref_c.CGridRef, + tag: point.Tag, + out: ?*point.Coordinate, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const p = ref.toPin() orelse return .invalid_value; + const pt = t.screens.active.pages.pointFromPin(tag, p) orelse + return .no_value; + if (out) |o| o.* = pt.coord(); + return .success; +} + pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void { const wrapper = terminal_ orelse return; const t = wrapper.terminal; @@ -1261,6 +1275,102 @@ test "grid_ref null terminal" { }, &out_ref)); } +test "point_from_grid_ref roundtrip active" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + // Get a grid ref at (2, 0) in active coords + var ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &ref)); + + // Convert back to active coords + var coord: point.Coordinate = undefined; + try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .active, &coord)); + try testing.expectEqual(@as(size.CellCountInt, 2), coord.x); + try testing.expectEqual(@as(u32, 0), coord.y); +} + +test "point_from_grid_ref roundtrip viewport" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .viewport, + .value = .{ .viewport = .{ .x = 0, .y = 0 } }, + }, &ref)); + + var coord: point.Coordinate = undefined; + try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .viewport, &coord)); + try testing.expectEqual(@as(size.CellCountInt, 0), coord.x); + try testing.expectEqual(@as(u32, 0), coord.y); +} + +test "point_from_grid_ref history ref to active returns no_value" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 4, .max_scrollback = 10_000 }, + )); + defer free(t); + + // Write enough lines to push content into scrollback + for (0..10) |_| { + vt_write(t, "line\n", 5); + } + + // Get a ref to the first line (now in scrollback) + var ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .screen, + .value = .{ .screen = .{ .x = 0, .y = 0 } }, + }, &ref)); + + // Should succeed for screen coords + var coord: point.Coordinate = undefined; + try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .screen, &coord)); + try testing.expectEqual(@as(u32, 0), coord.y); + + // Should fail for active coords (it's in scrollback) + try testing.expectEqual(Result.no_value, point_from_grid_ref(t, &ref, .active, &coord)); +} + +test "point_from_grid_ref null terminal" { + var ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.invalid_value, point_from_grid_ref(null, &ref, .active, null)); +} + +test "point_from_grid_ref null node" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer free(t); + + const ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.invalid_value, point_from_grid_ref(t, &ref, .active, null)); +} + test "set write_pty callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 66bfdf8e7a2662d9a10c702edd69bc14cc0886a6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 12:22:23 -0700 Subject: [PATCH 33/62] libghostty: add z-layer filtered placement iterator Add a placement_iterator_set function that configures iterator properties via an enum, following the same pattern as other set functions in the C API (e.g. render_state_set). The first settable option is a z-layer filter. The GhosttyKittyPlacementLayer enum classifies placements into three layers based on kitty protocol z-index conventions: below background (z < INT32_MIN/2), below text (INT32_MIN/2 <= z < 0), and above text (z >= 0). The default is ALL which preserves existing behavior. When a layer filter is set, placement_iterator_next automatically skips non-matching placements, so embedders no longer need to reimplement the z-index bucketing logic or iterate all placements three times per frame just to filter by layer. --- include/ghostty/vt/kitty_graphics.h | 59 ++++++++++ src/lib_vt.zig | 1 + src/terminal/c/kitty_graphics.zig | 162 +++++++++++++++++++++++++++- src/terminal/c/main.zig | 1 + 4 files changed, 221 insertions(+), 2 deletions(-) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index e31bb2c65..4f81598a4 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -138,6 +138,38 @@ typedef enum { GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12, } GhosttyKittyGraphicsPlacementData; +/** + * Z-layer classification for kitty graphics placements. + * + * Based on the kitty protocol z-index conventions: + * - BELOW_BG: z < INT32_MIN/2 (drawn below cell background) + * - BELOW_TEXT: INT32_MIN/2 <= z < 0 (above background, below text) + * - ABOVE_TEXT: z >= 0 (above text) + * - ALL: no filtering (current behavior) + * + * @ingroup kitty_graphics + */ +typedef enum { + GHOSTTY_KITTY_PLACEMENT_LAYER_ALL = 0, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_BG = 1, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT = 2, + GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT = 3, +} GhosttyKittyPlacementLayer; + +/** + * Settable options for ghostty_kitty_graphics_placement_iterator_set(). + * + * @ingroup kitty_graphics + */ +typedef enum { + /** + * Set the z-layer filter for the iterator. + * + * Input type: GhosttyKittyPlacementLayer * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER = 0, +} GhosttyKittyGraphicsPlacementIteratorOption; + /** * Pixel format of a Kitty graphics image. * @@ -310,9 +342,36 @@ GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_new( GHOSTTY_API void ghostty_kitty_graphics_placement_iterator_free( GhosttyKittyGraphicsPlacementIterator iterator); +/** + * Set an option on a placement iterator. + * + * Use GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER with a + * GhosttyKittyPlacementLayer value to filter placements by z-layer. + * The filter is applied during iteration: ghostty_kitty_graphics_placement_next() + * will skip placements that do not match the configured layer. + * + * The default layer is GHOSTTY_KITTY_PLACEMENT_LAYER_ALL (no filtering). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param value Pointer to the value (type depends on option; NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_set( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsPlacementIteratorOption option, + const void* value); + /** * Advance the placement iterator to the next placement. * + * If a layer filter has been set via + * ghostty_kitty_graphics_placement_iterator_set(), only placements + * matching that layer are returned. + * * @param iterator The iterator handle (may be NULL) * @return true if advanced to the next placement, false if at the end * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 3799fbe66..1dbd321c4 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -239,6 +239,7 @@ comptime { @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" }); @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); + @export(&c.kitty_graphics_placement_iterator_set, .{ .name = "ghostty_kitty_graphics_placement_iterator_set" }); @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" }); @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" }); diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index f5811a024..b3095bf36 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -42,6 +42,7 @@ const PlacementIteratorWrapper = if (build_options.kitty_graphics) alloc: std.mem.Allocator, inner: PlacementMap.Iterator = undefined, entry: ?PlacementMap.Entry = null, + layer_filter: PlacementLayer = .all, } else void; @@ -130,6 +131,34 @@ fn getTyped( return .success; } +/// C: GhosttyKittyPlacementLayer +pub const PlacementLayer = enum(c_int) { + all = 0, + below_bg = 1, + below_text = 2, + above_text = 3, + + fn matches(self: PlacementLayer, z: i32) bool { + return switch (self) { + .all => true, + .below_bg => z < std.math.minInt(i32) / 2, + .below_text => z >= std.math.minInt(i32) / 2 and z < 0, + .above_text => z >= 0, + }; + } +}; + +/// C: GhosttyKittyGraphicsPlacementIteratorOption +pub const PlacementIteratorOption = enum(c_int) { + layer = 0, + + pub fn InType(comptime self: PlacementIteratorOption) type { + return switch (self) { + .layer => PlacementLayer, + }; + } +}; + /// C: GhosttyKittyImageFormat pub const ImageFormat = kitty_cmd.Transmission.Format; @@ -233,12 +262,51 @@ pub fn placement_iterator_free(iter_: PlacementIterator) callconv(lib.calling_co iter.alloc.destroy(iter); } +pub fn placement_iterator_set( + iter_: PlacementIterator, + option: PlacementIteratorOption, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(PlacementIteratorOption, @intFromEnum(option)) catch { + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| placementIteratorSetTyped( + iter_, + comptime_option, + @ptrCast(@alignCast(value orelse return .invalid_value)), + ), + }; +} + +fn placementIteratorSetTyped( + iter_: PlacementIterator, + comptime option: PlacementIteratorOption, + value: *const option.InType(), +) Result { + const iter = iter_ orelse return .invalid_value; + switch (option) { + .layer => iter.layer_filter = value.*, + } + return .success; +} + pub fn placement_iterator_next(iter_: PlacementIterator) callconv(lib.calling_conv) bool { if (comptime !build_options.kitty_graphics) return false; const iter = iter_ orelse return false; - iter.entry = iter.inner.next() orelse return false; - return true; + while (iter.inner.next()) |entry| { + if (iter.layer_filter.matches(entry.value_ptr.z)) { + iter.entry = entry; + return true; + } + } + return false; } pub fn placement_get( @@ -537,6 +605,96 @@ test "placement_iterator with multiple placements" { try testing.expect(seen_p2); } +test "placement_iterator_set layer filter" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit image 1. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + + // Display with z=5 (above text), z=-1 (below text), z=-1073741825 (below bg). + // INT32_MIN/2 = -1073741824, so -1073741825 < INT32_MIN/2. + const d1 = "\x1b_Ga=p,i=1,p=1,z=5;\x1b\\"; + const d2 = "\x1b_Ga=p,i=1,p=2,z=-1;\x1b\\"; + const d3 = "\x1b_Ga=p,i=1,p=3,z=-1073741825;\x1b\\"; + terminal_c.vt_write(t, d1.ptr, d1.len); + terminal_c.vt_write(t, d2.ptr, d2.len); + terminal_c.vt_write(t, d3.ptr, d3.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + // Filter: above_text (z >= 0) — should yield only p=1. + var layer = PlacementLayer.above_text; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + var count: u32 = 0; + while (placement_iterator_next(iter)) { + var z: i32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z))); + try testing.expect(z >= 0); + count += 1; + } + try testing.expectEqual(1, count); + + // Filter: below_text (INT32_MIN/2 <= z < 0) — should yield only p=2. + layer = .below_text; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + count = 0; + while (placement_iterator_next(iter)) { + var z: i32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z))); + try testing.expect(z >= std.math.minInt(i32) / 2 and z < 0); + count += 1; + } + try testing.expectEqual(1, count); + + // Filter: below_bg (z < INT32_MIN/2) — should yield only p=3. + layer = .below_bg; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + count = 0; + while (placement_iterator_next(iter)) { + var z: i32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z))); + try testing.expect(z < std.math.minInt(i32) / 2); + count += 1; + } + try testing.expectEqual(1, count); + + // Filter: all — should yield all 3. + layer = .all; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + count = 0; + while (placement_iterator_next(iter)) count += 1; + try testing.expectEqual(3, count); +} + test "image_get_handle returns null for missing id" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 3f5f65f49..c0027d4e3 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -14,6 +14,7 @@ pub const kitty_graphics_image = kitty_graphics.image_get_handle; pub const kitty_graphics_image_get = kitty_graphics.image_get; pub const kitty_graphics_placement_iterator_new = kitty_graphics.placement_iterator_new; pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iterator_free; +pub const kitty_graphics_placement_iterator_set = kitty_graphics.placement_iterator_set; pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next; pub const kitty_graphics_placement_get = kitty_graphics.placement_get; pub const kitty_graphics_placement_rect = kitty_graphics.placement_rect; From b43d35b4d3c147b637fed085fca4d4dad277fc80 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 12:27:17 -0700 Subject: [PATCH 34/62] libghostty: add viewport-relative placement positioning Add ghostty_kitty_graphics_placement_viewport_pos which converts a placement's internal pin to viewport-relative grid coordinates. The returned row can be negative when the placement's origin has scrolled above the viewport, allowing embedders to compute the correct destination rectangle for partially visible images. Returns GHOSTTY_NO_VALUE only when the placement is completely outside the viewport (bottom edge above the viewport or top edge at or below the last row), so embedders do not need to perform their own visibility checks. Partially visible placements always return GHOSTTY_SUCCESS with their true signed coordinates. --- include/ghostty/vt/kitty_graphics.h | 42 +++++ src/lib_vt.zig | 1 + src/terminal/c/kitty_graphics.zig | 261 ++++++++++++++++++++++++++++ src/terminal/c/main.zig | 1 + 4 files changed, 305 insertions(+) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index 4f81598a4..f0cc0f6aa 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -474,6 +474,48 @@ GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_grid_size( uint32_t* out_cols, uint32_t* out_rows); +/** + * Get the viewport-relative grid position of the current placement. + * + * Converts the placement's internal pin to viewport-relative column and + * row coordinates. The returned coordinates represent the top-left + * corner of the placement in the viewport's grid coordinate space. + * + * The row value can be negative when the placement's origin has + * scrolled above the top of the viewport. For example, a 4-row + * image that has scrolled up by 2 rows returns row=-2, meaning + * its top 2 rows are above the visible area but its bottom 2 rows + * are still on screen. Embedders should use these coordinates + * directly when computing the destination rectangle for rendering; + * the embedder is responsible for clipping the portion of the image + * that falls outside the viewport. + * + * Returns GHOSTTY_SUCCESS for any placement that is at least + * partially visible in the viewport. Returns GHOSTTY_NO_VALUE when + * the placement is completely outside the viewport (its bottom edge + * is above the viewport or its top edge is at or below the last + * viewport row), or when the placement is a virtual (unicode + * placeholder) placement. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_col On success, receives the viewport-relative column + * @param[out] out_row On success, receives the viewport-relative row + * (may be negative for partially visible placements) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if fully + * off-screen or virtual, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_viewport_pos( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + int32_t* out_col, + int32_t* out_row); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1dbd321c4..4b60ff8fa 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -245,6 +245,7 @@ comptime { @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" }); @export(&c.kitty_graphics_placement_pixel_size, .{ .name = "ghostty_kitty_graphics_placement_pixel_size" }); @export(&c.kitty_graphics_placement_grid_size, .{ .name = "ghostty_kitty_graphics_placement_grid_size" }); + @export(&c.kitty_graphics_placement_viewport_pos, .{ .name = "ghostty_kitty_graphics_placement_viewport_pos" }); @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index b3095bf36..b18affb55 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -421,6 +421,52 @@ pub fn placement_grid_size( return .success; } +pub fn placement_viewport_pos( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out_col: *i32, + out_row: *i32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const pin = switch (entry.value_ptr.location) { + .pin => |p| p, + .virtual => return .no_value, + }; + + const pages = &wrapper.terminal.screens.active.pages; + + // Get screen-absolute coordinates for both the pin and the + // viewport origin, then subtract to get viewport-relative + // coordinates that can be negative for partially visible + // placements above the viewport. + const pin_screen = pages.pointFromPin(.screen, pin.*) orelse return .no_value; + const vp_tl = pages.getTopLeft(.viewport); + const vp_screen = pages.pointFromPin(.screen, vp_tl) orelse return .no_value; + + const vp_row: i32 = @as(i32, @intCast(pin_screen.screen.y)) - + @as(i32, @intCast(vp_screen.screen.y)); + const vp_col: i32 = @intCast(pin_screen.screen.x); + + // Check if the placement is fully off-screen. A placement is + // invisible if its bottom edge is above the viewport or its + // top edge is at or below the viewport's last row. + const grid_size = entry.value_ptr.gridSize(image.*, wrapper.terminal); + const rows_i32: i32 = @intCast(grid_size.rows); + const term_rows: i32 = @intCast(wrapper.terminal.rows); + if (vp_row + rows_i32 <= 0 or vp_row >= term_rows) return .no_value; + + out_col.* = vp_col; + out_row.* = vp_row; + + return .success; +} + test "placement_iterator new/free" { var iter: PlacementIterator = null; try testing.expectEqual(Result.success, placement_iterator_new( @@ -936,6 +982,221 @@ test "placement_grid_size null args return invalid_value" { try testing.expectEqual(Result.invalid_value, placement_grid_size(null, null, null, &cols, &rows)); } +test "placement_viewport_pos with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display at cursor (0,0). + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + + try testing.expectEqual(0, col); + try testing.expectEqual(0, row); +} + +test "placement_viewport_pos fully off-screen above" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, then display at cursor (0,0) spanning 1 row. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll the image completely off: 10 newlines in a 5-row terminal + // scrolls by 5+ rows, so a 1-row image at row 0 is fully gone. + const scroll = "\n\n\n\n\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.no_value, placement_viewport_pos(iter, img, t, &col, &row)); +} + +test "placement_viewport_pos top off-screen" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, display at cursor (0,0) spanning 4 rows. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll by 2: cursor starts at row 0, 4 newlines to reach bottom, + // then 2 more to scroll by 2. Image top-left moves to vp_row=-2, + // but bottom rows -2+4=2 > 0 so it's still partially visible. + const scroll = "\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + try testing.expectEqual(0, col); + try testing.expectEqual(-2, row); +} + +test "placement_viewport_pos bottom off-screen" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 5, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, move cursor to row 3 (1-based: row 4), display spanning 4 rows. + // Image occupies rows 3-6 but viewport only has rows 0-4, so bottom is clipped. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const cursor = "\x1b[4;1H"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, cursor.ptr, cursor.len); + terminal_c.vt_write(t, display.ptr, display.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + try testing.expectEqual(0, col); + try testing.expectEqual(3, row); +} + +test "placement_viewport_pos top and bottom off-screen" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, display at cursor (0,0) spanning 10 rows. + // After scrolling by 3, image occupies vp rows -3..6, viewport is 0..4, + // so both top and bottom are clipped but center is visible. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=10;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll by 3: 4 newlines to reach bottom + 3 more to scroll. + const scroll = "\n\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + try testing.expectEqual(0, col); + try testing.expectEqual(-3, row); +} + +test "placement_viewport_pos null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_viewport_pos(null, null, null, &col, &row)); +} + test "image_get on null returns invalid_value" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index c0027d4e3..85a223c89 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -20,6 +20,7 @@ pub const kitty_graphics_placement_get = kitty_graphics.placement_get; pub const kitty_graphics_placement_rect = kitty_graphics.placement_rect; pub const kitty_graphics_placement_pixel_size = kitty_graphics.placement_pixel_size; pub const kitty_graphics_placement_grid_size = kitty_graphics.placement_grid_size; +pub const kitty_graphics_placement_viewport_pos = kitty_graphics.placement_viewport_pos; pub const types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); From d712beff5b616f1f886937c6de8e8105b9f3956e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 12:36:34 -0700 Subject: [PATCH 35/62] libghostty: add resolved source rect for placements Add ghostty_kitty_graphics_placement_source_rect which returns the fully resolved and clamped source rectangle for a placement. This applies kitty protocol semantics (width/height of 0 means full image dimension) and clamps the result to the actual image bounds, eliminating ~20 lines of protocol-aware logic from each embedder. --- include/ghostty/vt/kitty_graphics.h | 27 +++++ src/lib_vt.zig | 1 + src/terminal/c/kitty_graphics.zig | 166 ++++++++++++++++++++++++++++ src/terminal/c/main.zig | 1 + 4 files changed, 195 insertions(+) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index f0cc0f6aa..446834d18 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -516,6 +516,33 @@ GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_viewport_pos( int32_t* out_col, int32_t* out_row); +/** + * Get the resolved source rectangle for the current placement. + * + * Applies kitty protocol semantics: a width or height of 0 in the + * placement means "use the full image dimension", and the resulting + * rectangle is clamped to the actual image bounds. The returned + * values are in pixels and are ready to use for texture sampling. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param[out] out_x Source rect x origin in pixels + * @param[out] out_y Source rect y origin in pixels + * @param[out] out_width Source rect width in pixels + * @param[out] out_height Source rect height in pixels + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any + * handle is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_source_rect( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + uint32_t* out_x, + uint32_t* out_y, + uint32_t* out_width, + uint32_t* out_height); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 4b60ff8fa..ff11177da 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -246,6 +246,7 @@ comptime { @export(&c.kitty_graphics_placement_pixel_size, .{ .name = "ghostty_kitty_graphics_placement_pixel_size" }); @export(&c.kitty_graphics_placement_grid_size, .{ .name = "ghostty_kitty_graphics_placement_grid_size" }); @export(&c.kitty_graphics_placement_viewport_pos, .{ .name = "ghostty_kitty_graphics_placement_viewport_pos" }); + @export(&c.kitty_graphics_placement_source_rect, .{ .name = "ghostty_kitty_graphics_placement_source_rect" }); @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index b18affb55..0045f3368 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -467,6 +467,35 @@ pub fn placement_viewport_pos( return .success; } +pub fn placement_source_rect( + iter_: PlacementIterator, + image_: ImageHandle, + out_x: *u32, + out_y: *u32, + out_width: *u32, + out_height: *u32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const p = entry.value_ptr; + + // Apply "0 = full image dimension" convention, then clamp to image bounds. + const x = @min(p.source_x, image.width); + const y = @min(p.source_y, image.height); + const w = @min(if (p.source_width > 0) p.source_width else image.width, image.width - x); + const h = @min(if (p.source_height > 0) p.source_height else image.height, image.height - y); + + out_x.* = x; + out_y.* = y; + out_width.* = w; + out_height.* = h; + + return .success; +} + test "placement_iterator new/free" { var iter: PlacementIterator = null; try testing.expectEqual(Result.success, placement_iterator_new( @@ -1197,6 +1226,143 @@ test "placement_viewport_pos null args return invalid_value" { try testing.expectEqual(Result.invalid_value, placement_viewport_pos(null, null, null, &col, &row)); } +test "placement_source_rect defaults to full image" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display a 1x2 RGB image with no source rect specified. + // source_width=0 and source_height=0 should resolve to full image (1x2). + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h)); + try testing.expectEqual(0, x); + try testing.expectEqual(0, y); + try testing.expectEqual(1, w); + try testing.expectEqual(2, h); +} + +test "placement_source_rect with explicit source rect" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit a 4x4 RGBA image (64 bytes = 4*4*4). + // Base64 of 64 zero bytes: 86 chars. + const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ++ + "\x1b\\"; + // Display with explicit source rect: x=1, y=1, w=2, h=2. + const display = "\x1b_Ga=p,i=1,p=1,x=1,y=1,w=2,h=2;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h)); + try testing.expectEqual(1, x); + try testing.expectEqual(1, y); + try testing.expectEqual(2, w); + try testing.expectEqual(2, h); +} + +test "placement_source_rect clamps to image bounds" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit a 4x4 RGBA image. + const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ++ + "\x1b\\"; + // Display with source rect that exceeds image bounds: x=3, y=3, w=10, h=10. + // Should clamp to x=3, y=3, w=1, h=1. + const display = "\x1b_Ga=p,i=1,p=1,x=3,y=3,w=10,h=10;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h)); + try testing.expectEqual(3, x); + try testing.expectEqual(3, y); + try testing.expectEqual(1, w); + try testing.expectEqual(1, h); +} + +test "placement_source_rect null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_source_rect(null, null, &x, &y, &w, &h)); +} + test "image_get on null returns invalid_value" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 85a223c89..e7a7db68a 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -21,6 +21,7 @@ pub const kitty_graphics_placement_rect = kitty_graphics.placement_rect; pub const kitty_graphics_placement_pixel_size = kitty_graphics.placement_pixel_size; pub const kitty_graphics_placement_grid_size = kitty_graphics.placement_grid_size; pub const kitty_graphics_placement_viewport_pos = kitty_graphics.placement_viewport_pos; +pub const kitty_graphics_placement_source_rect = kitty_graphics.placement_source_rect; pub const types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); From 65e3265e3cd4df063a83fcabfa7fd2a4b61627b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 12:49:12 -0700 Subject: [PATCH 36/62] libghostty: fix kitty graphics test failures Fix three categories of test bugs in the kitty graphics C API tests: The placement iterator reset in getTyped was clobbering the layer_filter field when reinitializing the iterator struct, causing the layer filter test to see unfiltered placements. Preserve layer_filter across resets. The viewport position tests were not accounting for the default cursor_movement=after behavior of the kitty display command, which calls index() for each row of the placement before the test scroll sequence. Add C=1 to suppress cursor movement so the scroll math in the tests is correct. The source_rect tests used an 88-character all-A base64 payload which decodes to 66 bytes, but a 4x4 RGBA image requires exactly 64 bytes. Fix the payload to use proper base64 padding (AA==). --- AGENTS.md | 2 ++ src/terminal/c/kitty_graphics.zig | 18 +++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f4c4db7a9..7098007f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ A file for [guiding coding agents](https://agents.md/). - Build: `zig build -Demit-lib-vt` - Build WASM: `zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall` +- Test: `zig build test-lib-vt -Dtest-filter=` + - Prefer this when the change is in a libghostty-vt file ## Directory Structure diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 0045f3368..a086f8e9f 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -125,6 +125,7 @@ fn getTyped( it.* = .{ .alloc = it.alloc, .inner = storage.placements.iterator(), + .layer_filter = it.layer_filter, }; }, } @@ -1108,8 +1109,9 @@ test "placement_viewport_pos top off-screen" { try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); // Transmit image, display at cursor (0,0) spanning 4 rows. + // C=1 prevents cursor movement after display. const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; - const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4;\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4,C=1;\x1b\\"; terminal_c.vt_write(t, transmit.ptr, transmit.len); terminal_c.vt_write(t, display.ptr, display.len); @@ -1150,10 +1152,11 @@ test "placement_viewport_pos bottom off-screen" { try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); // Transmit image, move cursor to row 3 (1-based: row 4), display spanning 4 rows. + // C=1 prevents cursor movement after display. // Image occupies rows 3-6 but viewport only has rows 0-4, so bottom is clipped. const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; const cursor = "\x1b[4;1H"; - const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4;\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4,C=1;\x1b\\"; terminal_c.vt_write(t, transmit.ptr, transmit.len); terminal_c.vt_write(t, cursor.ptr, cursor.len); terminal_c.vt_write(t, display.ptr, display.len); @@ -1189,10 +1192,11 @@ test "placement_viewport_pos top and bottom off-screen" { try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); // Transmit image, display at cursor (0,0) spanning 10 rows. + // C=1 prevents cursor movement after display. // After scrolling by 3, image occupies vp rows -3..6, viewport is 0..4, // so both top and bottom are clipped but center is visible. const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; - const display = "\x1b_Ga=p,i=1,p=1,c=1,r=10;\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=10,C=1;\x1b\\"; terminal_c.vt_write(t, transmit.ptr, transmit.len); terminal_c.vt_write(t, display.ptr, display.len); @@ -1278,9 +1282,9 @@ test "placement_source_rect with explicit source rect" { try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); // Transmit a 4x4 RGBA image (64 bytes = 4*4*4). - // Base64 of 64 zero bytes: 86 chars. + // Base64 of 64 zero bytes: 88 chars (21 full groups + AA== padding). const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" ++ "\x1b\\"; // Display with explicit source rect: x=1, y=1, w=2, h=2. const display = "\x1b_Ga=p,i=1,p=1,x=1,y=1,w=2,h=2;\x1b\\"; @@ -1321,9 +1325,9 @@ test "placement_source_rect clamps to image bounds" { defer terminal_c.free(t); try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); - // Transmit a 4x4 RGBA image. + // Transmit a 4x4 RGBA image (64 bytes = 4*4*4). const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" ++ "\x1b\\"; // Display with source rect that exceeds image bounds: x=3, y=3, w=10, h=10. // Should clamp to x=3, y=3, w=1, h=1. From a977822b58634e0aa12aecc65fe316a56f9becab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 13:09:09 -0700 Subject: [PATCH 37/62] update kitty graphics docs --- include/ghostty/vt/kitty_graphics.h | 83 +++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index 446834d18..1f6884348 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -1,7 +1,9 @@ /** * @file kitty_graphics.h * - * Kitty graphics protocol image storage. + * Kitty graphics protocol + * + * See @ref kitty_graphics for a full usage guide. */ #ifndef GHOSTTY_VT_KITTY_GRAPHICS_H @@ -19,8 +21,83 @@ extern "C" { /** @defgroup kitty_graphics Kitty Graphics * - * Opaque handle to the Kitty graphics image storage associated with a - * terminal screen, and an iterator for inspecting placements. + * API for inspecting images and placements stored via the + * [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). + * + * The central object is @ref GhosttyKittyGraphics, an opaque handle to + * the image storage associated with a terminal's active screen. From it + * you can iterate over placements and look up individual images. + * + * ## Obtaining a KittyGraphics Handle + * + * A @ref GhosttyKittyGraphics handle is obtained from a terminal via + * ghostty_terminal_get() with @ref GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. + * The handle is borrowed from the terminal and remains valid until the + * next mutating terminal call (e.g. ghostty_terminal_vt_write() or + * ghostty_terminal_reset()). + * + * Before images can be stored, Kitty graphics must be enabled on the + * terminal by setting a non-zero storage limit with + * @ref GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, and a PNG + * decoder callback must be installed via ghostty_sys_set() with + * @ref GHOSTTY_SYS_OPT_DECODE_PNG. + * + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png + * + * ## Iterating Placements + * + * Placements are inspected through a @ref GhosttyKittyGraphicsPlacementIterator. + * The typical workflow is: + * + * 1. Create an iterator with ghostty_kitty_graphics_placement_iterator_new(). + * 2. Populate it from the storage with ghostty_kitty_graphics_get() using + * @ref GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR. + * 3. Optionally filter by z-layer with + * ghostty_kitty_graphics_placement_iterator_set(). + * 4. Advance with ghostty_kitty_graphics_placement_next() and read + * per-placement data with ghostty_kitty_graphics_placement_get(). + * 5. For each placement, look up its image with + * ghostty_kitty_graphics_image() to access pixel data and dimensions. + * 6. Free the iterator with ghostty_kitty_graphics_placement_iterator_free(). + * + * ## Looking Up Images + * + * Given an image ID (obtained from a placement via + * @ref GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID), call + * ghostty_kitty_graphics_image() to get a @ref GhosttyKittyGraphicsImage + * handle. From this handle, ghostty_kitty_graphics_image_get() provides + * the image dimensions, pixel format, compression, and a borrowed pointer + * to the raw pixel data. + * + * ## Rendering Helpers + * + * Several functions assist with rendering a placement: + * + * - ghostty_kitty_graphics_placement_pixel_size() — rendered pixel + * dimensions accounting for source rect and aspect ratio. + * - ghostty_kitty_graphics_placement_grid_size() — number of grid + * columns and rows the placement occupies. + * - ghostty_kitty_graphics_placement_viewport_pos() — viewport-relative + * grid position (may be negative for partially scrolled placements). + * - ghostty_kitty_graphics_placement_source_rect() — resolved source + * rectangle in pixels, clamped to image bounds. + * - ghostty_kitty_graphics_placement_rect() — bounding rectangle as a + * @ref GhosttySelection. + * + * ## Lifetime and Thread Safety + * + * All handles borrowed from the terminal (GhosttyKittyGraphics, + * GhosttyKittyGraphicsImage) are invalidated by any mutating terminal + * call. The placement iterator is independently owned and must be freed + * by the caller, but the data it yields is only valid while the + * underlying terminal is not mutated. + * + * ## Example + * + * The following example creates a terminal, sends a Kitty graphics + * image, then iterates placements and prints image metadata: + * + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main * * @{ */ From 05fb57dd4044dbd44f5b751afaa0beafea9df4bb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 14:02:06 -0700 Subject: [PATCH 38/62] build: emit xcframework for libghostty-vt on macOS On Darwin targets, the build now automatically produces a universal (arm64 + x86_64) XCFramework at lib/ghostty-vt.xcframework under the install prefix. This bundles the fat static library with headers so consumers using Xcode or Swift PM can link libghostty-vt directly. --- build.zig | 12 ++++++++ src/build/GhosttyLibVt.zig | 59 ++++++++++++++++++++++++++++++++++++++ src/build/GhosttyZig.zig | 42 +++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index 78977a8c9..d8e8bd86a 100644 --- a/build.zig +++ b/build.zig @@ -152,6 +152,18 @@ pub fn build(b: *std.Build) !void { ).step); } + // libghostty-vt xcframework (Apple only, universal binary) + if (config.target.result.os.tag.isDarwin()) { + const universal = try buildpkg.GhosttyLibVt.initStaticAppleUniversal( + b, + &config, + &deps, + &mod, + ); + const xcframework = universal.xcframework(); + b.getInstallStep().dependOn(xcframework.step); + } + // Helpgen if (config.emit_helpgen) deps.help_strings.install(); diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index d5e76b9de..55f22e232 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -4,9 +4,12 @@ const std = @import("std"); const builtin = @import("builtin"); const assert = std.debug.assert; const RunStep = std.Build.Step.Run; +const Config = @import("Config.zig"); const GhosttyZig = @import("GhosttyZig.zig"); const LibtoolStep = @import("LibtoolStep.zig"); +const LipoStep = @import("LipoStep.zig"); const SharedDeps = @import("SharedDeps.zig"); +const XCFrameworkStep = @import("XCFrameworkStep.zig"); /// The step that generates the file. step: *std.Build.Step, @@ -99,6 +102,45 @@ pub fn initShared( return initLib(b, zig, .dynamic); } +/// Build a macOS universal (arm64 + x86_64) static library using lipo. +pub fn initStaticAppleUniversal( + b: *std.Build, + cfg: *const Config, + deps: *const SharedDeps, + zig: *const GhosttyZig, +) !GhosttyLibVt { + const aarch64_zig = try zig.retarget( + b, + cfg, + deps, + Config.genericMacOSTarget(b, .aarch64), + ); + const x86_64_zig = try zig.retarget( + b, + cfg, + deps, + Config.genericMacOSTarget(b, .x86_64), + ); + + const aarch64 = try initStatic(b, &aarch64_zig); + const x86_64 = try initStatic(b, &x86_64_zig); + const universal = LipoStep.create(b, .{ + .name = "ghostty-vt", + .out_name = "libghostty-vt.a", + .input_a = aarch64.output, + .input_b = x86_64.output, + }); + + return .{ + .step = universal.step, + .artifact = universal.step, + .kind = .static, + .output = universal.output, + .dsym = null, + .pkg_config = null, + }; +} + fn initLib( b: *std.Build, zig: *const GhosttyZig, @@ -294,6 +336,23 @@ fn requiresPrivate(b: *std.Build) []const u8 { return ""; } +/// Create an XCFramework bundle from the static library. +pub fn xcframework( + lib_vt: *const GhosttyLibVt, +) *XCFrameworkStep { + assert(lib_vt.kind == .static); + const b = lib_vt.step.owner; + return XCFrameworkStep.create(b, .{ + .name = "ghostty-vt", + .out_path = b.pathJoin(&.{ b.install_prefix, "lib/ghostty-vt.xcframework" }), + .libraries = &.{.{ + .library = lib_vt.output, + .headers = b.path("include/ghostty"), + .dsym = null, + }}, + }); +} + pub fn install( self: *const GhosttyLibVt, step: *std.Build.Step, diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig index 3f3db95c6..8d5b78fb4 100644 --- a/src/build/GhosttyZig.zig +++ b/src/build/GhosttyZig.zig @@ -24,6 +24,44 @@ pub fn init( b: *std.Build, cfg: *const Config, deps: *const SharedDeps, +) !GhosttyZig { + return initInner(b, cfg, deps, "ghostty-vt", "ghostty-vt-c"); +} + +/// Create a new GhosttyZig with modules retargeted to a different +/// architecture. Used to produce universal (fat) binaries on macOS. +pub fn retarget( + self: *const GhosttyZig, + b: *std.Build, + cfg: *const Config, + deps: *const SharedDeps, + target: std.Build.ResolvedTarget, +) !GhosttyZig { + _ = self; + const retargeted_config = try b.allocator.create(Config); + retargeted_config.* = cfg.*; + retargeted_config.target = target; + + const retargeted_deps = try b.allocator.create(SharedDeps); + retargeted_deps.* = try deps.retarget(b, target); + + // Use unique module names to avoid collisions with the original target. + const arch_name = @tagName(target.result.cpu.arch); + return initInner( + b, + retargeted_config, + retargeted_deps, + b.fmt("ghostty-vt-{s}", .{arch_name}), + b.fmt("ghostty-vt-c-{s}", .{arch_name}), + ); +} + +fn initInner( + b: *std.Build, + cfg: *const Config, + deps: *const SharedDeps, + vt_name: []const u8, + vt_c_name: []const u8, ) !GhosttyZig { // Terminal module build options var vt_options = cfg.terminalOptions(.lib); @@ -37,7 +75,7 @@ pub fn init( return .{ .vt = try initVt( - "ghostty-vt", + vt_name, b, cfg, deps, @@ -46,7 +84,7 @@ pub fn init( ), .vt_c = try initVt( - "ghostty-vt-c", + vt_c_name, b, cfg, deps, From f567f7f46d0b60da3fddb18070e74b1c0deb074f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 14:13:20 -0700 Subject: [PATCH 39/62] build: add GhosttyVt module map to xcframework and Swift example The xcframework now generates its own headers directory with a GhosttyVt module map instead of reusing include/ directly, which contains the GhosttyKit module map for the macOS app. The generated directory copies the ghostty headers and adds a module.modulemap that exposes ghostty/vt.h as the umbrella header. A new swift-vt-xcframework example demonstrates consuming the xcframework from a Swift Package. It creates a terminal, writes VT sequences, and formats the output as plain text, verifying the full round-trip works with swift build and swift run. --- example/.gitignore | 1 + example/swift-vt-xcframework/Package.swift | 21 +++++++++ example/swift-vt-xcframework/README.md | 23 ++++++++++ .../swift-vt-xcframework/Sources/main.swift | 45 +++++++++++++++++++ src/build/GhosttyLibVt.zig | 20 ++++++++- 5 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 example/swift-vt-xcframework/Package.swift create mode 100644 example/swift-vt-xcframework/README.md create mode 100644 example/swift-vt-xcframework/Sources/main.swift diff --git a/example/.gitignore b/example/.gitignore index 6f372bc4d..9f88ccfeb 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -3,3 +3,4 @@ dist/ node_modules/ example.wasm* build/ +.build/ diff --git a/example/swift-vt-xcframework/Package.swift b/example/swift-vt-xcframework/Package.swift new file mode 100644 index 000000000..a831a42c8 --- /dev/null +++ b/example/swift-vt-xcframework/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "swift-vt-xcframework", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "swift-vt-xcframework", + dependencies: ["GhosttyVt"], + path: "Sources", + linkerSettings: [ + .linkedLibrary("c++"), + ] + ), + .binaryTarget( + name: "GhosttyVt", + path: "../../zig-out/lib/ghostty-vt.xcframework" + ), + ] +) diff --git a/example/swift-vt-xcframework/README.md b/example/swift-vt-xcframework/README.md new file mode 100644 index 000000000..3bbe8948c --- /dev/null +++ b/example/swift-vt-xcframework/README.md @@ -0,0 +1,23 @@ +# swift-vt-xcframework + +Demonstrates consuming libghostty-vt from a Swift Package using the +pre-built XCFramework. Creates a terminal, writes VT sequences into it, +and formats the screen contents as plain text. + +This example requires the XCFramework to be built first. + +## Building + +First, build the XCFramework from the repository root: + +```shell-session +zig build -Demit-lib-vt +``` + +Then build and run the Swift package: + +```shell-session +cd example/swift-vt-xcframework +swift build +swift run +``` diff --git a/example/swift-vt-xcframework/Sources/main.swift b/example/swift-vt-xcframework/Sources/main.swift new file mode 100644 index 000000000..e7d15fb89 --- /dev/null +++ b/example/swift-vt-xcframework/Sources/main.swift @@ -0,0 +1,45 @@ +import GhosttyVt + +// Create a terminal with a small grid +var terminal: GhosttyTerminal? +var opts = GhosttyTerminalOptions( + cols: 80, + rows: 24, + max_scrollback: 0 +) +let result = ghostty_terminal_new(nil, &terminal, opts) +guard result == GHOSTTY_SUCCESS, let terminal else { + fatalError("Failed to create terminal") +} + +// Write some VT-encoded content +let text = "Hello from \u{1b}[1mSwift\u{1b}[0m via xcframework!\r\n" +text.withCString { ptr in + ghostty_terminal_vt_write(terminal, ptr, strlen(ptr)) +} + +// Format the terminal contents as plain text +var fmtOpts = GhosttyFormatterTerminalOptions() +fmtOpts.size = MemoryLayout.size +fmtOpts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN +fmtOpts.trim = true + +var formatter: GhosttyFormatter? +let fmtResult = ghostty_formatter_terminal_new(nil, &formatter, terminal, fmtOpts) +guard fmtResult == GHOSTTY_SUCCESS, let formatter else { + fatalError("Failed to create formatter") +} + +var buf: UnsafeMutablePointer? +var len: Int = 0 +let allocResult = ghostty_formatter_format_alloc(formatter, nil, &buf, &len) +guard allocResult == GHOSTTY_SUCCESS, let buf else { + fatalError("Failed to format") +} + +print("Plain text (\(len) bytes):") +print(String(cString: buf)) + +ghostty_free(nil, buf, len) +ghostty_formatter_free(formatter) +ghostty_terminal_free(terminal) diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 55f22e232..904954650 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -342,12 +342,30 @@ pub fn xcframework( ) *XCFrameworkStep { assert(lib_vt.kind == .static); const b = lib_vt.step.owner; + + // Generate a headers directory with a module map for Swift PM. + // We can't use include/ directly because it contains a module map + // for GhosttyKit (the macOS app library). + const wf = b.addWriteFiles(); + _ = wf.addCopyDirectory( + b.path("include/ghostty"), + "ghostty", + .{ .include_extensions = &.{".h"} }, + ); + _ = wf.add("module.modulemap", + \\module GhosttyVt { + \\ umbrella header "ghostty/vt.h" + \\ export * + \\} + \\ + ); + return XCFrameworkStep.create(b, .{ .name = "ghostty-vt", .out_path = b.pathJoin(&.{ b.install_prefix, "lib/ghostty-vt.xcframework" }), .libraries = &.{.{ .library = lib_vt.output, - .headers = b.path("include/ghostty"), + .headers = wf.getDirectory(), .dsym = null, }}, }); From 764ff18b8edef150c7736d16a82a3f4e557fc374 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 14:16:45 -0700 Subject: [PATCH 40/62] ci: add Swift example builds on macOS Auto-discover Swift examples via example/*/Package.swift alongside the existing zig and cmake discovery. The new build-examples-swift job runs on macOS, builds the xcframework with zig build -Demit-lib-vt, then runs swift run in each example directory to verify the xcframework links and functions correctly end-to-end. --- .github/workflows/test.yml | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c40ad3e8..a3e6639d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -89,6 +89,7 @@ jobs: - build-examples-zig - build-examples-cmake - build-examples-cmake-windows + - build-examples-swift - build-cmake - build-flatpak - build-libghostty-vt @@ -181,6 +182,7 @@ jobs: outputs: zig: ${{ steps.list.outputs.zig }} cmake: ${{ steps.list.outputs.cmake }} + swift: ${{ steps.list.outputs.swift }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -193,6 +195,9 @@ jobs: cmake=$(ls example/*/CMakeLists.txt 2>/dev/null | xargs -n1 dirname | xargs -n1 basename | jq -R -s -c 'split("\n") | map(select(. != ""))') echo "$cmake" | jq . echo "cmake=$cmake" >> "$GITHUB_OUTPUT" + swift=$(ls example/*/Package.swift 2>/dev/null | xargs -n1 dirname | xargs -n1 basename | jq -R -s -c 'split("\n") | map(select(. != ""))') + echo "$swift" | jq . + echo "swift=$swift" >> "$GITHUB_OUTPUT" build-examples-zig: strategy: @@ -290,6 +295,49 @@ jobs: cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=${{ github.workspace }} cmake --build build + build-examples-swift: + strategy: + fail-fast: false + matrix: + dir: ${{ fromJSON(needs.list-examples.outputs.swift) }} + name: Example ${{ matrix.dir }} + runs-on: namespace-profile-ghostty-macos-tahoe + needs: [test, list-examples] + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.3.app + + - name: Build XCFramework + run: nix develop -c zig build -Demit-lib-vt + + - name: Build and Run Example + run: | + cd example/${{ matrix.dir }} + swift run + build-cmake: runs-on: namespace-profile-ghostty-sm needs: test From 90b706b97703bcec3dab6c2285acf86685e5fdfb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 14:22:27 -0700 Subject: [PATCH 41/62] ci: publish lib-vt xcframework in tip releases Add a build-lib-vt-xcframework job to the release-tip workflow that builds the universal xcframework with ReleaseFast, zips it, signs it with minisign, and uploads it to both the GitHub Release and R2 blob storage. Consumers can pull the xcframework zip from the tip release or by commit hash from tip.files.ghostty.org. --- .github/workflows/release-tip.yml | 85 +++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 28e56e7fb..bd6002607 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -260,6 +260,91 @@ jobs: libghostty-vt-source.tar.gz.minisig token: ${{ secrets.GH_RELEASE_TOKEN }} + build-lib-vt-xcframework: + needs: [setup] + if: | + needs.setup.outputs.should_skip != 'true' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.repository_owner == 'ghostty-org' && + github.ref_name == 'main' + ) + ) + runs-on: namespace-profile-ghostty-macos-tahoe + env: + GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.3.app + + - name: Build XCFramework + run: nix develop -c zig build -Demit-lib-vt -Doptimize=ReleaseFast + + - name: Zip XCFramework + run: | + cd zig-out/lib + zip -9 -r ../../ghostty-vt.xcframework.zip ghostty-vt.xcframework + + - name: Sign XCFramework + run: | + echo -n "${{ secrets.MINISIGN_KEY }}" > minisign.key + echo -n "${{ secrets.MINISIGN_PASSWORD }}" > minisign.password + nix develop -c minisign -S -m ghostty-vt.xcframework.zip -s minisign.key < minisign.password + + - name: Update Release + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + with: + name: 'Ghostty Tip ("Nightly")' + prerelease: true + tag_name: tip + target_commitish: ${{ github.sha }} + files: | + ghostty-vt.xcframework.zip + ghostty-vt.xcframework.zip.minisig + token: ${{ secrets.GH_RELEASE_TOKEN }} + + - name: Prep R2 Storage + run: | + mkdir -p blob/${GHOSTTY_COMMIT_LONG} + cp ghostty-vt.xcframework.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-vt.xcframework.zip + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_TIP_SECRET_KEY }} + r2-bucket: ghostty-tip + source-dir: blob + destination-dir: ./ + + - name: Echo Release URLs + run: | + echo "Release URLs:" + echo " XCFramework: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-vt.xcframework.zip" + build-macos: needs: [setup] if: | From e1a0e40ec4cfd7ae6ed7d99b92db733cea95a2c0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 14:24:53 -0700 Subject: [PATCH 42/62] build: skip xcframework when cross-compiling Gate the xcframework build on the host being macOS in addition to the target, since xcodebuild is only available on macOS. --- build.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index d8e8bd86a..074a6675e 100644 --- a/build.zig +++ b/build.zig @@ -152,8 +152,10 @@ pub fn build(b: *std.Build) !void { ).step); } - // libghostty-vt xcframework (Apple only, universal binary) - if (config.target.result.os.tag.isDarwin()) { + // libghostty-vt xcframework (Apple only, universal binary). + // Only when building on macOS (not cross-compiling) since + // xcodebuild is required. + if (builtin.os.tag.isDarwin() and config.target.result.os.tag.isDarwin()) { const universal = try buildpkg.GhosttyLibVt.initStaticAppleUniversal( b, &config, From 9b281cde4324f9a4c993c6776829fb64ce601f77 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 14:31:18 -0700 Subject: [PATCH 43/62] build: add iOS slices to lib-vt xcframework Add iOS device and simulator slices to the xcframework, gated on SDK availability via std.zig.LibCInstallation.findNative. Refactor AppleLibs from a struct with named fields to an EnumMap keyed by ApplePlatform so that adding new platforms only requires extending the enum and its sdk_platforms table. tvOS, watchOS, and visionOS are listed as not yet supported due to Zig stdlib limitations (missing PATH_MAX, mcontext fields). --- build.zig | 4 +- src/build/GhosttyLibVt.zig | 98 +++++++++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/build.zig b/build.zig index 074a6675e..e8c784611 100644 --- a/build.zig +++ b/build.zig @@ -156,13 +156,13 @@ pub fn build(b: *std.Build) !void { // Only when building on macOS (not cross-compiling) since // xcodebuild is required. if (builtin.os.tag.isDarwin() and config.target.result.os.tag.isDarwin()) { - const universal = try buildpkg.GhosttyLibVt.initStaticAppleUniversal( + const apple_libs = try buildpkg.GhosttyLibVt.initStaticAppleUniversal( b, &config, &deps, &mod, ); - const xcframework = universal.xcframework(); + const xcframework = buildpkg.GhosttyLibVt.xcframework(&apple_libs, b); b.getInstallStep().dependOn(xcframework.step); } diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 904954650..e3e6cf8c1 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -102,13 +102,39 @@ pub fn initShared( return initLib(b, zig, .dynamic); } -/// Build a macOS universal (arm64 + x86_64) static library using lipo. +/// Apple platform targets for xcframework slices. +pub const ApplePlatform = enum { + macos_universal, + ios, + ios_simulator, + // tvOS, watchOS, and visionOS are not yet supported by Zig's + // standard library (missing PATH_MAX, mcontext fields, etc.). + + /// Platforms that have device + simulator pairs, gated on SDK detection. + const sdk_platforms = [_]struct { + os_tag: std.Target.Os.Tag, + device: ApplePlatform, + simulator: ApplePlatform, + }{ + .{ .os_tag = .ios, .device = .ios, .simulator = .ios_simulator }, + }; +}; + +/// Static libraries for each Apple platform, keyed by `ApplePlatform`. +pub const AppleLibs = std.EnumMap(ApplePlatform, GhosttyLibVt); + +/// Build static libraries for all available Apple platforms. +/// Always builds a macOS universal (arm64 + x86_64) fat binary. +/// Additional platforms are included if their SDK is detected. pub fn initStaticAppleUniversal( b: *std.Build, cfg: *const Config, deps: *const SharedDeps, zig: *const GhosttyZig, -) !GhosttyLibVt { +) !AppleLibs { + var result: AppleLibs = .{}; + + // macOS universal (arm64 + x86_64) const aarch64_zig = try zig.retarget( b, cfg, @@ -121,7 +147,6 @@ pub fn initStaticAppleUniversal( deps, Config.genericMacOSTarget(b, .x86_64), ); - const aarch64 = try initStatic(b, &aarch64_zig); const x86_64 = try initStatic(b, &x86_64_zig); const universal = LipoStep.create(b, .{ @@ -130,15 +155,38 @@ pub fn initStaticAppleUniversal( .input_a = aarch64.output, .input_b = x86_64.output, }); - - return .{ + result.put(.macos_universal, .{ .step = universal.step, .artifact = universal.step, .kind = .static, .output = universal.output, .dsym = null, .pkg_config = null, - }; + }); + + // Additional Apple platforms, each gated on SDK availability. + for (ApplePlatform.sdk_platforms) |p| { + const target_query: std.Target.Query = .{ + .cpu_arch = .aarch64, + .os_tag = p.os_tag, + .os_version_min = Config.osVersionMin(p.os_tag), + }; + if (detectAppleSDK(b.resolveTargetQuery(target_query).result)) { + const dev_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(target_query)); + result.put(p.device, try initStatic(b, &dev_zig)); + + const sim_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(.{ + .cpu_arch = .aarch64, + .os_tag = p.os_tag, + .os_version_min = Config.osVersionMin(p.os_tag), + .abi = .simulator, + .cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 }, + })); + result.put(p.simulator, try initStatic(b, &sim_zig)); + } + } + + return result; } fn initLib( @@ -336,13 +384,11 @@ fn requiresPrivate(b: *std.Build) []const u8 { return ""; } -/// Create an XCFramework bundle from the static library. +/// Create an XCFramework bundle from Apple platform static libraries. pub fn xcframework( - lib_vt: *const GhosttyLibVt, + apple_libs: *const AppleLibs, + b: *std.Build, ) *XCFrameworkStep { - assert(lib_vt.kind == .static); - const b = lib_vt.step.owner; - // Generate a headers directory with a module map for Swift PM. // We can't use include/ directly because it contains a module map // for GhosttyKit (the macOS app library). @@ -359,18 +405,38 @@ pub fn xcframework( \\} \\ ); + const headers = wf.getDirectory(); + + var libraries: [AppleLibs.len]XCFrameworkStep.Library = undefined; + var lib_count: usize = 0; + for (std.enums.values(ApplePlatform)) |platform| { + if (apple_libs.get(platform)) |lib| { + libraries[lib_count] = .{ + .library = lib.output, + .headers = headers, + .dsym = null, + }; + lib_count += 1; + } + } return XCFrameworkStep.create(b, .{ .name = "ghostty-vt", .out_path = b.pathJoin(&.{ b.install_prefix, "lib/ghostty-vt.xcframework" }), - .libraries = &.{.{ - .library = lib_vt.output, - .headers = wf.getDirectory(), - .dsym = null, - }}, + .libraries = libraries[0..lib_count], }); } +/// Returns true if the Apple SDK for the given target is installed. +fn detectAppleSDK(target: std.Target) bool { + _ = std.zig.LibCInstallation.findNative(.{ + .allocator = std.heap.page_allocator, + .target = &target, + .verbose = false, + }) catch return false; + return true; +} + pub fn install( self: *const GhosttyLibVt, step: *std.Build.Step, From 249aee70105facdfdf0e627be4f0c0d342ce08a0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 14:50:35 -0700 Subject: [PATCH 44/62] example/swift-vt-xcframework: fix buffer overflow --- example/swift-vt-xcframework/Sources/main.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/example/swift-vt-xcframework/Sources/main.swift b/example/swift-vt-xcframework/Sources/main.swift index e7d15fb89..d374f539f 100644 --- a/example/swift-vt-xcframework/Sources/main.swift +++ b/example/swift-vt-xcframework/Sources/main.swift @@ -1,3 +1,4 @@ +import Foundation import GhosttyVt // Create a terminal with a small grid @@ -38,7 +39,8 @@ guard allocResult == GHOSTTY_SUCCESS, let buf else { } print("Plain text (\(len) bytes):") -print(String(cString: buf)) +let data = Data(bytes: buf, count: len) +print(String(data: data, encoding: .utf8) ?? "") ghostty_free(nil, buf, len) ghostty_formatter_free(formatter) From 445e1945da573a5b63adb4e4e7294c135cb0e86a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 14:53:10 -0700 Subject: [PATCH 45/62] ci: upload lib-vt source tarball to R2 Add R2 upload steps to the source-tarball-lib-vt job in the tip release workflow, matching the pattern used by the xcframework job. The tarball is uploaded to the ghostty-tip R2 bucket keyed by commit hash, making it available at tip.files.ghostty.org//libghostty-vt-source.tar.gz. --- .github/workflows/release-tip.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index bd6002607..0bebc2ff7 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -219,6 +219,7 @@ jobs: ) runs-on: namespace-profile-ghostty-sm env: + GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: @@ -260,6 +261,25 @@ jobs: libghostty-vt-source.tar.gz.minisig token: ${{ secrets.GH_RELEASE_TOKEN }} + - name: Prep R2 Storage + run: | + mkdir -p blob/${GHOSTTY_COMMIT_LONG} + cp libghostty-vt-source.tar.gz blob/${GHOSTTY_COMMIT_LONG}/libghostty-vt-source.tar.gz + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_TIP_SECRET_KEY }} + r2-bucket: ghostty-tip + source-dir: blob + destination-dir: ./ + + - name: Echo Release URLs + run: | + echo "Release URLs:" + echo " Source Tarball: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/libghostty-vt-source.tar.gz" + build-lib-vt-xcframework: needs: [setup] if: | From da835757b0330474ec4050fa2b149a9b0c887d52 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 14:54:15 -0700 Subject: [PATCH 46/62] prettier: ignore swift outputs --- .prettierignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.prettierignore b/.prettierignore index 2699f7e10..5613ff991 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,6 @@ website/.next # fuzz corpus files test/fuzz-libghostty/corpus/ test/fuzz-libghostty/afl-out/ + +# Swift example build outputs +example/swift-vt-xcframework/.build/ From 06340cd3f05f7124c7757571a9fba77a30f78a53 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Apr 2026 17:17:30 -0500 Subject: [PATCH 47/62] libghostty-vt: add semver pre info to build info --- example/c-vt-build-info/src/main.c | 7 +++++++ include/ghostty/vt/build_info.h | 10 +++++++++- src/terminal/build_options.zig | 6 ++---- src/terminal/c/build_info.zig | 18 ++++++++++++++++-- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/example/c-vt-build-info/src/main.c b/example/c-vt-build-info/src/main.c index 148341446..2a05e416d 100644 --- a/example/c-vt-build-info/src/main.c +++ b/example/c-vt-build-info/src/main.c @@ -19,18 +19,25 @@ void query_build_info() { size_t version_major = 0; size_t version_minor = 0; size_t version_patch = 0; + GhosttyString version_pre = {0}; GhosttyString version_build = {0}; ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_STRING, &version_string); ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_MAJOR, &version_major); ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_MINOR, &version_minor); ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_PATCH, &version_patch); + ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_PRE, &version_pre); ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_BUILD, &version_build); printf("Version: %.*s\n", (int)version_string.len, version_string.ptr); printf("Version major: %zu\n", version_major); printf("Version minor: %zu\n", version_minor); printf("Version patch: %zu\n", version_patch); + if (version_pre.len > 0) { + printf("Version pre : %.*s\n", (int)version_pre.len, version_pre.ptr); + } else { + printf("Version pre : (none)\n"); + } if (version_build.len > 0) { printf("Version build: %.*s\n", (int)version_build.len, version_build.ptr); } else { diff --git a/include/ghostty/vt/build_info.h b/include/ghostty/vt/build_info.h index 7f77a769b..19999e77f 100644 --- a/include/ghostty/vt/build_info.h +++ b/include/ghostty/vt/build_info.h @@ -107,13 +107,21 @@ typedef enum { */ GHOSTTY_BUILD_INFO_VERSION_PATCH = 8, + /** + * The pre metadata string (e.g. "alpha", "beta", "dev"). Has zero length if + * no pre metadata is present. + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_PRE = 9, + /** * The build metadata string (e.g. commit hash). Has zero length if * no build metadata is present. * * Output type: GhosttyString * */ - GHOSTTY_BUILD_INFO_VERSION_BUILD = 9, + GHOSTTY_BUILD_INFO_VERSION_BUILD = 10, } GhosttyBuildInfo; /** diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 1f7e69273..136e0f101 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -75,15 +75,13 @@ pub const Options = struct { opts.addOption(bool, "tmux_control_mode", self.oniguruma); // Version information. - var buf: [1024]u8 = undefined; opts.addOption( []const u8, "version_string", - std.fmt.bufPrint( - &buf, + b.fmt( "{f}", .{self.version}, - ) catch @panic("version string too long"), + ), ); opts.addOption(usize, "version_major", self.version.major); opts.addOption(usize, "version_minor", self.version.minor); diff --git a/src/terminal/c/build_info.zig b/src/terminal/c/build_info.zig index bef6760fa..8e0cd4d6c 100644 --- a/src/terminal/c/build_info.zig +++ b/src/terminal/c/build_info.zig @@ -25,7 +25,8 @@ pub const BuildInfo = enum(c_int) { version_major = 6, version_minor = 7, version_patch = 8, - version_build = 9, + version_pre = 9, + version_build = 10, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: BuildInfo) type { @@ -33,7 +34,7 @@ pub const BuildInfo = enum(c_int) { .invalid => void, .simd, .kitty_graphics, .tmux_control_mode => bool, .optimize => OptimizeMode, - .version_string, .version_build => lib.String, + .version_string, .version_pre, .version_build => lib.String, .version_major, .version_minor, .version_patch => usize, }; } @@ -78,6 +79,13 @@ fn getTyped( .version_major => out.* = build_options.version_major, .version_minor => out.* = build_options.version_minor, .version_patch => out.* = build_options.version_patch, + .version_pre => { + if (build_options.version_pre) |b| { + out.* = .{ .ptr = b.ptr, .len = b.len }; + } else { + out.* = .{ .ptr = "", .len = 0 }; + } + }, .version_build => { if (build_options.version_build) |b| { out.* = .{ .ptr = b.ptr, .len = b.len }; @@ -151,6 +159,12 @@ test "get version_patch" { try testing.expectEqual(build_options.version_patch, value); } +test "get version_pre" { + const testing = std.testing; + var value: lib.String = undefined; + try testing.expectEqual(Result.success, get(.version_pre, @ptrCast(&value))); +} + test "get version_build" { const testing = std.testing; var value: lib.String = undefined; From 5c45484a717388395e8537dc35881c57940edf2d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:15:26 +0000 Subject: [PATCH 48/62] build(deps): bump flatpak/flatpak-github-actions from 6.6 to 6.7 Bumps [flatpak/flatpak-github-actions](https://github.com/flatpak/flatpak-github-actions) from 6.6 to 6.7. - [Release notes](https://github.com/flatpak/flatpak-github-actions/releases) - [Commits](https://github.com/flatpak/flatpak-github-actions/compare/92ae9851ad316786193b1fd3f40c4b51eb5cb101...401fe28a8384095fc1531b9d320b292f0ee45adb) --- updated-dependencies: - dependency-name: flatpak/flatpak-github-actions dependency-version: '6.7' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/flatpak.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index d64ab829a..da1e66530 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -41,7 +41,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 + - uses: flatpak/flatpak-github-actions/flatpak-builder@401fe28a8384095fc1531b9d320b292f0ee45adb # v6.7 with: bundle: com.mitchellh.ghostty manifest-path: dist/flatpak/com.mitchellh.ghostty.yml From 8f376d84b495a389c8859309df9b35c7355c9c97 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:38:42 +0000 Subject: [PATCH 49/62] Update VOUCHED list (#12156) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12155#discussioncomment-16470483) from @jcollie. Vouch: @KieranCanter Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index ba872e57b..68afbbfde 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -120,6 +120,7 @@ kawarimidoll kayleung kenvandine khipp +kierancanter kirwiisp kjvdven kloneets From a1e75daef8b64426dbca551c6e41b1fbc2b7ae24 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:31:48 +0000 Subject: [PATCH 50/62] Update VOUCHED list (#12158) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12157#discussioncomment-16470907) from @jcollie. Vouch: @tbrundige Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 68afbbfde..4d4cdc15c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -194,6 +194,7 @@ seruman silveirapf slsrepo sunshine-syz +tbrundige tdgroot tdslot ticclick From 1322d64534a052c3f1c0a65a199a883f1e0a5f71 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Apr 2026 17:49:52 -0500 Subject: [PATCH 51/62] libghostty-vt: nix package updates and more nix tests --- .github/workflows/test.yml | 96 +++++++++++++++--- nix/libghostty-vt.nix | 161 ++++++++++++++++++++++++++++-- nix/package.nix | 4 +- nix/test-src/test_libghostty_vt.c | 9 ++ 4 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 nix/test-src/test_libghostty_vt.c diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a3e6639d7..86d69e57f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -813,28 +813,40 @@ jobs: run: nm result/bin/.ghostty-wrapped 2>&1 | grep -q 'main_ghostty.main' - name: Test ReleaseFast build of libghostty-vt - run: nix build .#libghostty-vt-releasefast - - - name: Check to see if the library looks sane - run: nm result/lib/libghostty-vt.so.0.1.0 2>&1 | grep -q 'ghostty_terminal_new' + run: | + nix build .#libghostty-vt-releasefast + nix build .#libghostty-vt-releasefast.tests.sanity-check + nix build .#libghostty-vt-releasefast.tests.pkg-config + nix build .#libghostty-vt-releasefast.tests.build-with-shared + nix build .#libghostty-vt-releasefast.tests.build-with-static + nix build .#libghostty-vt-releasefast.tests.build-example-c-vt-build-info - name: Test ReleaseFast (no SIMD) build of libghostty-vt - run: nix build .#libghostty-vt-releasefast-no-simd - - - name: Check to see if the library looks sane - run: nm result/lib/libghostty-vt.so.0.1.0 2>&1 | grep -q 'ghostty_terminal_new' + run: | + nix build .#libghostty-vt-releasefast-no-simd + nix build .#libghostty-vt-releasefast-no-simd.tests.sanity-check + nix build .#libghostty-vt-releasefast-no-simd.tests.pkg-config + nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-shared + nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-static + nix build .#libghostty-vt-releasefast-no-simd.tests.build-example-c-vt-build-info - name: Test Debug build of libghostty-vt - run: nix build .#libghostty-vt-debug - - - name: Check to see if the library looks sane - run: nm result/lib/libghostty-vt.so.0.1.0 2>&1 | grep -q 'ghostty_terminal_new' + run: | + nix build .#libghostty-vt-debug + nix build .#libghostty-vt-debug.tests.sanity-check + nix build .#libghostty-vt-debug.tests.pkg-config + nix build .#libghostty-vt-debug.tests.build-with-shared + nix build .#libghostty-vt-debug.tests.build-with-static + nix build .#libghostty-vt-debug.tests.build-example-c-vt-build-info - name: Test Debug (no SIMD) build of libghostty-vt - run: nix build .#libghostty-vt-debug-no-simd - - - name: Check to see if the library looks sane - run: nm result/lib/libghostty-vt.so.0.1.0 2>&1 | grep -q 'ghostty_terminal_new' + run: | + nix build .#libghostty-vt-debug-no-simd + nix build .#libghostty-vt-debug-no-simd.tests.sanity-check + nix build .#libghostty-vt-debug-no-simd.tests.pkg-config + nix build .#libghostty-vt-debug-no-simd.tests.build-with-shared + nix build .#libghostty-vt-debug-no-simd.tests.build-with-static + nix build .#libghostty-vt-debug-no-simd.tests.build-example-c-vt-build-info build-dist: runs-on: namespace-profile-ghostty-sm @@ -956,6 +968,58 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-nix-macos: + runs-on: namespace-profile-ghostty-macos-tahoe + needs: test + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@96951a368ba55167b55f1c916f7d416bac6505fe # v31.10.3 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test ReleaseFast build of libghostty-vt + run: | + nix build .#libghostty-vt-releasefast + nix build .#libghostty-vt-releasefast.tests.sanity-check + nix build .#libghostty-vt-releasefast.tests.pkg-config + nix build .#libghostty-vt-releasefast.tests.build-with-shared + nix build .#libghostty-vt-releasefast.tests.build-with-static + nix build .#libghostty-vt-releasefast.tests.build-example-c-vt-build-info + + - name: Test ReleaseFast (no SIMD) build of libghostty-vt + run: | + nix build .#libghostty-vt-releasefast-no-simd + nix build .#libghostty-vt-releasefast-no-simd.tests.sanity-check + nix build .#libghostty-vt-releasefast-no-simd.tests.pkg-config + nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-shared + nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-static + nix build .#libghostty-vt-releasefast-no-simd.tests.build-example-c-vt-build-info + + - name: Test Debug build of libghostty-vt + run: | + nix build .#libghostty-vt-debug + nix build .#libghostty-vt-debug.tests.sanity-check + nix build .#libghostty-vt-debug.tests.pkg-config + nix build .#libghostty-vt-debug.tests.build-with-shared + nix build .#libghostty-vt-debug.tests.build-with-static + nix build .#libghostty-vt-debug.tests.build-example-c-vt-build-info + + - name: Test Debug (no SIMD) build of libghostty-vt + run: | + nix build .#libghostty-vt-debug-no-simd + nix build .#libghostty-vt-debug-no-simd.tests.sanity-check + nix build .#libghostty-vt-debug-no-simd.tests.pkg-config + nix build .#libghostty-vt-debug-no-simd.tests.build-with-shared + nix build .#libghostty-vt-debug-no-simd.tests.build-with-static + nix build .#libghostty-vt-debug-no-simd.tests.build-example-c-vt-build-info + build-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test diff --git a/nix/libghostty-vt.nix b/nix/libghostty-vt.nix index bcd229ec2..799bba682 100644 --- a/nix/libghostty-vt.nix +++ b/nix/libghostty-vt.nix @@ -1,9 +1,13 @@ { - lib, - stdenv, callPackage, git, + lib, + llvmPackages, pkg-config, + runCommand, + stdenv, + testers, + versionCheckHook, zig_0_15, revision ? "dirty", optimize ? "Debug", @@ -22,10 +26,7 @@ stdenv.mkDerivation (finalAttrs: { root = ../.; fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) ( lib.fileset.unions [ - ../dist/linux - ../images ../include - ../po ../pkg ../src ../vendor @@ -36,7 +37,7 @@ stdenv.mkDerivation (finalAttrs: { ); }; - deps = callPackage ../build.zig.zon.nix {name = "ghostty-cache-${finalAttrs.version}";}; + deps = callPackage ../build.zig.zon.nix {name = "${finalAttrs.pname}-cache-${finalAttrs.version}";}; nativeBuildInputs = [ git @@ -46,6 +47,7 @@ stdenv.mkDerivation (finalAttrs: { buildInputs = []; + doCheck = false; dontSetZigDefaultFlags = true; zigBuildFlags = [ @@ -58,6 +60,7 @@ stdenv.mkDerivation (finalAttrs: { "-Demit-lib-vt=true" "-Dsimd=${lib.boolToString simd}" ]; + zigCheckFlags = finalAttrs.zigBuildFlags ++ ["test-lib-vt"]; outputs = [ "out" @@ -71,17 +74,159 @@ stdenv.mkDerivation (finalAttrs: { mv "$out/include" "$dev" mv "$out/share" "$dev" - ln -sf "$out/lib/libghostty-vt.so.0" "$dev/lib/libghostty-vt.so" + ln -sf "$out/lib/libghostty-vt.so.${lib.versions.major finalAttrs.version}" "$dev/lib/libghostty-vt.so" ''; postFixup = '' substituteInPlace "$dev/share/pkgconfig/libghostty-vt.pc" \ - --replace "$out" "$dev" + --replace-fail "$out" "$dev" ''; + passthru.tests = { + sanity-check = let + version = "${lib.versions.major finalAttrs.version}.${lib.versions.minor finalAttrs.version}.${lib.versions.patch finalAttrs.version}"; + in + runCommand "sanity-check" {} (builtins.concatStringsSep "\n" [ + '' + ${lib.getExe' stdenv.cc "nm"} "${finalAttrs.finalPackage}/lib/libghostty-vt.so.${version}" | grep -q 'T ghostty_terminal_new' + ${lib.getExe' stdenv.cc "nm"} "${finalAttrs.finalPackage.dev}/lib/libghostty-vt.a" | grep -q 'T ghostty_terminal_new' + '' + ( + lib.optionalString simd + '' + ${lib.getExe' stdenv.cc "nm"} "${finalAttrs.finalPackage.dev}/lib/libghostty-vt.a" | grep -q 'T .*simdutf' + ${lib.getExe' stdenv.cc "nm"} "${finalAttrs.finalPackage.dev}/lib/libghostty-vt.a" | grep -q 'T .*3hwy' + '' + ) + '' + touch "$out" + '' + ]); + pkg-config = testers.hasPkgConfigModules { + package = finalAttrs.finalPackage.dev; + }; + build-with-shared = stdenv.mkDerivation { + name = "build-with-shared"; + src = ./test-src; + doInstallCheck = true; + nativeBuildInputs = [pkg-config]; + buildInputs = [finalAttrs.finalPackage]; + buildPhase = '' + runHook preBuildHooks + + cc -o test test_libghostty_vt.c \ + ''$(pkg-config --cflags --libs libghostty-vt) \ + -Wl,-rpath,"${finalAttrs.finalPackage}/lib" + + runHook postBuildHooks + ''; + installPhase = '' + runHook preInstallHooks + + mkdir -p "$out/bin"; + cp -a test "$out/bin/test"; + + runHook postInstallHooks + ''; + installCheckPhase = '' + runHook preInstallCheckHooks + + "$out/bin/test" | grep -q "SIMD: ${ + if simd + then "yes" + else "no" + }" + ldd "$out/bin/test" 2>/dev/null | grep -q libghostty-vt + + runHook postInstallCheckHooks + ''; + meta = { + mainProgram = "test"; + }; + }; + build-with-static = stdenv.mkDerivation { + name = "build-with-static"; + src = ./test-src; + doInstallCheck = true; + nativeBuildInputs = [pkg-config]; + buildInputs = [finalAttrs.finalPackage llvmPackages.libcxxClang]; + buildPhase = '' + runHook preBuildHooks + + cc -o test test_libghostty_vt.c \ + ''$(pkg-config --cflags libghostty-vt) \ + ${finalAttrs.finalPackage.dev}/lib/libghostty-vt.a \ + ''$(pkg-config --libs-only-l --static libghostty-vt | sed 's/-lghostty-vt//') \ + -Wl,-rpath,"${finalAttrs.finalPackage}/lib" + + runHook postBuildHooks + ''; + installPhase = '' + runHook preInstallHooks + + mkdir -p "$out/bin"; + cp -a test "$out/bin/test"; + + runHook postInstallHooks + ''; + installCheckPhase = '' + runHook preInstallCheckHooks + + "$out/bin/test" | grep -q "SIMD: ${ + if simd + then "yes" + else "no" + }" + ! ldd "$out/bin/test" 2>/dev/null | grep -q libghostty-vt + + runHook postInstallCheckHooks + ''; + meta = { + mainProgram = "test"; + }; + }; + build-example-c-vt-build-info = stdenv.mkDerivation { + name = "build-example-c-vt-build-info"; + version = finalAttrs.version; + src = ../example/c-vt-build-info/src; + doInstallCheck = true; + nativeBuildInputs = [pkg-config]; + nativeInstallCheckInputs = [versionCheckHook]; + buildInputs = [finalAttrs.finalPackage]; + buildPhase = '' + runHook preBuildHooks + + cc -o test main.c \ + ''$(pkg-config --cflags --libs libghostty-vt) \ + -Wl,-rpath,"${finalAttrs.finalPackage}/lib" + + runHook postBuildHooks + ''; + installPhase = '' + runHook preInstallHooks + + mkdir -p "$out/bin"; + cp -a test "$out/bin/test"; + + runHook postInstallHooks + ''; + installCheckPhase = '' + runHook preInstallCheckHooks + + ldd "$out/bin/test" 2>/dev/null | grep -q libghostty-vt + + runHook postInstallCheckHooks + ''; + meta = { + mainProgram = "test"; + }; + }; + }; + meta = { homepage = "https://ghostty.org"; license = lib.licenses.mit; platforms = zig_0_15.meta.platforms; + pkgConfigModules = ["libghostty-vt"]; }; }) diff --git a/nix/package.nix b/nix/package.nix index 8287b0888..fd952c9de 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -30,7 +30,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.3.2-dev"; + version = "1.3.2-dev+${revision}-nix"; # We limit source like this to try and reduce the amount of rebuilds as possible # thus we only provide the source that is needed for the build @@ -86,7 +86,7 @@ in zigBuildFlags = [ "--system" "${finalAttrs.deps}" - "-Dversion-string=${finalAttrs.version}-${revision}-nix" + "-Dversion-string=${finalAttrs.version}" "-Dgtk-x11=${lib.boolToString enableX11}" "-Dgtk-wayland=${lib.boolToString enableWayland}" "-Dcpu=baseline" diff --git a/nix/test-src/test_libghostty_vt.c b/nix/test-src/test_libghostty_vt.c new file mode 100644 index 000000000..dc2586299 --- /dev/null +++ b/nix/test-src/test_libghostty_vt.c @@ -0,0 +1,9 @@ +#include +#include +int main(void) { + bool simd = false; + GhosttyResult r = ghostty_build_info(GHOSTTY_BUILD_INFO_SIMD, &simd); + if (r != GHOSTTY_SUCCESS) return 1; + printf("SIMD: %s\n", simd ? "yes" : "no"); + return 0; +} From 93a447045803099e43a603f14150b70de33f4b34 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Apr 2026 22:51:41 -0500 Subject: [PATCH 52/62] libghostty-vt: require build-nix-macos --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86d69e57f..61d844508 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,6 +99,7 @@ jobs: - build-linux - build-linux-libghostty - build-nix + - build-nix-macos - build-macos - build-macos-freetype - build-snap From dedc3fce865c92f05773cb90501cf58707b7702a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Apr 2026 22:51:56 -0500 Subject: [PATCH 53/62] libghostty-vt: build nix package on all platforms --- flake.nix | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/flake.nix b/flake.nix index 7d1cb0d31..7fb7176fd 100644 --- a/flake.nix +++ b/flake.nix @@ -91,17 +91,9 @@ }); packages = - forAllPlatforms (pkgs: { + forAllPlatforms (pkgs: rec { # Deps are needed for environmental setup on macOS deps = pkgs.callPackage ./build.zig.zon.nix {}; - }) - // forBuildablePlatforms (pkgs: rec { - ghostty-debug = pkgs.callPackage ./nix/package.nix (mkPkgArgs "Debug"); - ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseSafe"); - ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast"); - - ghostty = ghostty-releasefast; - default = ghostty; libghostty-vt-debug = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "Debug"); libghostty-vt-releasesafe = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseSafe"); @@ -111,6 +103,14 @@ libghostty-vt-releasefast-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseFast") // {simd = false;}); libghostty-vt = libghostty-vt-releasefast; + }) + // forBuildablePlatforms (pkgs: rec { + ghostty-debug = pkgs.callPackage ./nix/package.nix (mkPkgArgs "Debug"); + ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseSafe"); + ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast"); + + ghostty = ghostty-releasefast; + default = ghostty; }); formatter = forAllPlatforms (pkgs: pkgs.alejandra); From 95fb39ae0cb8d0f8cfe2e4e50a5278d2b8983335 Mon Sep 17 00:00:00 2001 From: tbrundige <77417590+tbrundige@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:10:12 -0600 Subject: [PATCH 54/62] chore: removed superfluous word --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 293f5a6e2..808b684da 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ C-compatible library for embedding a fast, feature-rich terminal emulator in any 3rd party project. This library is called `libghostty`. Due to the scope of this project, we're breaking libghostty down into -separate actually libraries, starting with `libghostty-vt`. The goal of +separate libraries, starting with `libghostty-vt`. The goal of this project is to focus on parsing terminal sequences and maintaining terminal state. This is covered in more detail in this [blog post](https://mitchellh.com/writing/libghostty-is-coming). From 4ae155be5915f02da92044a1204947d18df90896 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Apr 2026 23:22:56 -0500 Subject: [PATCH 55/62] flake: ensure that packages don't get lost --- flake.nix | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/flake.nix b/flake.nix index 7fb7176fd..51a44d96c 100644 --- a/flake.nix +++ b/flake.nix @@ -91,27 +91,32 @@ }); packages = - forAllPlatforms (pkgs: rec { - # Deps are needed for environmental setup on macOS - deps = pkgs.callPackage ./build.zig.zon.nix {}; + lib.recursiveUpdate + ( + forAllPlatforms (pkgs: rec { + # Deps are needed for environmental setup on macOS + deps = pkgs.callPackage ./build.zig.zon.nix {}; - libghostty-vt-debug = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "Debug"); - libghostty-vt-releasesafe = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseSafe"); - libghostty-vt-releasefast = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseFast"); - libghostty-vt-debug-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "Debug") // {simd = false;}); - libghostty-vt-releasesafe-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseSafe") // {simd = false;}); - libghostty-vt-releasefast-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseFast") // {simd = false;}); + libghostty-vt-debug = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "Debug"); + libghostty-vt-releasesafe = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseSafe"); + libghostty-vt-releasefast = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseFast"); + libghostty-vt-debug-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "Debug") // {simd = false;}); + libghostty-vt-releasesafe-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseSafe") // {simd = false;}); + libghostty-vt-releasefast-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseFast") // {simd = false;}); - libghostty-vt = libghostty-vt-releasefast; - }) - // forBuildablePlatforms (pkgs: rec { - ghostty-debug = pkgs.callPackage ./nix/package.nix (mkPkgArgs "Debug"); - ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseSafe"); - ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast"); + libghostty-vt = libghostty-vt-releasefast; + }) + ) + ( + forBuildablePlatforms (pkgs: rec { + ghostty-debug = pkgs.callPackage ./nix/package.nix (mkPkgArgs "Debug"); + ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseSafe"); + ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast"); - ghostty = ghostty-releasefast; - default = ghostty; - }); + ghostty = ghostty-releasefast; + default = ghostty; + }) + ); formatter = forAllPlatforms (pkgs: pkgs.alejandra); From 1d61e2735179bcf8ea06531658570ea64d5cfc05 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Apr 2026 23:40:45 -0500 Subject: [PATCH 56/62] libghostty-vt: disable macOS CI nix build --- .github/workflows/test.yml | 94 +++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61d844508..440033343 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,7 +99,7 @@ jobs: - build-linux - build-linux-libghostty - build-nix - - build-nix-macos + # - build-nix-macos - build-macos - build-macos-freetype - build-snap @@ -969,57 +969,57 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - build-nix-macos: - runs-on: namespace-profile-ghostty-macos-tahoe - needs: test - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # build-nix-macos: + # runs-on: namespace-profile-ghostty-macos-tahoe + # needs: test + # steps: + # - name: Checkout code + # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@96951a368ba55167b55f1c916f7d416bac6505fe # v31.10.3 - with: - nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 - with: - name: ghostty - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + # # Install Nix and use that to run our tests so our environment matches exactly. + # - uses: cachix/install-nix-action@96951a368ba55167b55f1c916f7d416bac6505fe # v31.10.3 + # with: + # nix_path: nixpkgs=channel:nixos-unstable + # - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + # with: + # name: ghostty + # authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Test ReleaseFast build of libghostty-vt - run: | - nix build .#libghostty-vt-releasefast - nix build .#libghostty-vt-releasefast.tests.sanity-check - nix build .#libghostty-vt-releasefast.tests.pkg-config - nix build .#libghostty-vt-releasefast.tests.build-with-shared - nix build .#libghostty-vt-releasefast.tests.build-with-static - nix build .#libghostty-vt-releasefast.tests.build-example-c-vt-build-info + # - name: Test ReleaseFast build of libghostty-vt + # run: | + # nix build .#libghostty-vt-releasefast + # nix build .#libghostty-vt-releasefast.tests.sanity-check + # nix build .#libghostty-vt-releasefast.tests.pkg-config + # nix build .#libghostty-vt-releasefast.tests.build-with-shared + # nix build .#libghostty-vt-releasefast.tests.build-with-static + # nix build .#libghostty-vt-releasefast.tests.build-example-c-vt-build-info - - name: Test ReleaseFast (no SIMD) build of libghostty-vt - run: | - nix build .#libghostty-vt-releasefast-no-simd - nix build .#libghostty-vt-releasefast-no-simd.tests.sanity-check - nix build .#libghostty-vt-releasefast-no-simd.tests.pkg-config - nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-shared - nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-static - nix build .#libghostty-vt-releasefast-no-simd.tests.build-example-c-vt-build-info + # - name: Test ReleaseFast (no SIMD) build of libghostty-vt + # run: | + # nix build .#libghostty-vt-releasefast-no-simd + # nix build .#libghostty-vt-releasefast-no-simd.tests.sanity-check + # nix build .#libghostty-vt-releasefast-no-simd.tests.pkg-config + # nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-shared + # nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-static + # nix build .#libghostty-vt-releasefast-no-simd.tests.build-example-c-vt-build-info - - name: Test Debug build of libghostty-vt - run: | - nix build .#libghostty-vt-debug - nix build .#libghostty-vt-debug.tests.sanity-check - nix build .#libghostty-vt-debug.tests.pkg-config - nix build .#libghostty-vt-debug.tests.build-with-shared - nix build .#libghostty-vt-debug.tests.build-with-static - nix build .#libghostty-vt-debug.tests.build-example-c-vt-build-info + # - name: Test Debug build of libghostty-vt + # run: | + # nix build .#libghostty-vt-debug + # nix build .#libghostty-vt-debug.tests.sanity-check + # nix build .#libghostty-vt-debug.tests.pkg-config + # nix build .#libghostty-vt-debug.tests.build-with-shared + # nix build .#libghostty-vt-debug.tests.build-with-static + # nix build .#libghostty-vt-debug.tests.build-example-c-vt-build-info - - name: Test Debug (no SIMD) build of libghostty-vt - run: | - nix build .#libghostty-vt-debug-no-simd - nix build .#libghostty-vt-debug-no-simd.tests.sanity-check - nix build .#libghostty-vt-debug-no-simd.tests.pkg-config - nix build .#libghostty-vt-debug-no-simd.tests.build-with-shared - nix build .#libghostty-vt-debug-no-simd.tests.build-with-static - nix build .#libghostty-vt-debug-no-simd.tests.build-example-c-vt-build-info + # - name: Test Debug (no SIMD) build of libghostty-vt + # run: | + # nix build .#libghostty-vt-debug-no-simd + # nix build .#libghostty-vt-debug-no-simd.tests.sanity-check + # nix build .#libghostty-vt-debug-no-simd.tests.pkg-config + # nix build .#libghostty-vt-debug-no-simd.tests.build-with-shared + # nix build .#libghostty-vt-debug-no-simd.tests.build-with-static + # nix build .#libghostty-vt-debug-no-simd.tests.build-example-c-vt-build-info build-macos: runs-on: namespace-profile-ghostty-macos-tahoe From 28c75e2c61fbe1a60fd12b312036ee71ed319104 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Apr 2026 23:41:04 -0500 Subject: [PATCH 57/62] libghostty-vt: fix nix package name --- nix/libghostty-vt.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/libghostty-vt.nix b/nix/libghostty-vt.nix index 799bba682..5a819a0cc 100644 --- a/nix/libghostty-vt.nix +++ b/nix/libghostty-vt.nix @@ -14,7 +14,7 @@ simd ? true, }: stdenv.mkDerivation (finalAttrs: { - pname = "ghostty"; + pname = "libghostty-vt"; version = "0.1.0-dev+${revision}-nix"; # We limit source like this to try and reduce the amount of rebuilds as possible From c2e2a3b7f9d9d0d175bf61a3dbb2c4dea5ad2a5a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Apr 2026 23:41:24 -0500 Subject: [PATCH 58/62] flake: make building package list more flexible --- flake.nix | 48 ++++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/flake.nix b/flake.nix index 51a44d96c..19e7b3157 100644 --- a/flake.nix +++ b/flake.nix @@ -91,32 +91,36 @@ }); packages = + builtins.foldl' lib.recursiveUpdate - ( - forAllPlatforms (pkgs: rec { - # Deps are needed for environmental setup on macOS - deps = pkgs.callPackage ./build.zig.zon.nix {}; + {} + [ + ( + forAllPlatforms (pkgs: rec { + # Deps are needed for environmental setup on macOS + deps = pkgs.callPackage ./build.zig.zon.nix {}; - libghostty-vt-debug = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "Debug"); - libghostty-vt-releasesafe = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseSafe"); - libghostty-vt-releasefast = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseFast"); - libghostty-vt-debug-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "Debug") // {simd = false;}); - libghostty-vt-releasesafe-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseSafe") // {simd = false;}); - libghostty-vt-releasefast-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseFast") // {simd = false;}); + libghostty-vt-debug = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "Debug"); + libghostty-vt-releasesafe = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseSafe"); + libghostty-vt-releasefast = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseFast"); + libghostty-vt-debug-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "Debug") // {simd = false;}); + libghostty-vt-releasesafe-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseSafe") // {simd = false;}); + libghostty-vt-releasefast-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseFast") // {simd = false;}); - libghostty-vt = libghostty-vt-releasefast; - }) - ) - ( - forBuildablePlatforms (pkgs: rec { - ghostty-debug = pkgs.callPackage ./nix/package.nix (mkPkgArgs "Debug"); - ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseSafe"); - ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast"); + libghostty-vt = libghostty-vt-releasefast; + }) + ) + ( + forBuildablePlatforms (pkgs: rec { + ghostty-debug = pkgs.callPackage ./nix/package.nix (mkPkgArgs "Debug"); + ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseSafe"); + ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast"); - ghostty = ghostty-releasefast; - default = ghostty; - }) - ); + ghostty = ghostty-releasefast; + default = ghostty; + }) + ) + ]; formatter = forAllPlatforms (pkgs: pkgs.alejandra); From 853183e911b70ff7b61057f52fc7b47ea4934238 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:58:24 +0000 Subject: [PATCH 59/62] Update VOUCHED list (#12165) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12164#discussioncomment-16477806) from @jcollie. Vouch: @MoonMao42 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 4d4cdc15c..ba7ee55ec 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -153,6 +153,7 @@ mischief mitchellh miupa molechowski +moonmao42 mrconnorkenway mrmage mtak From 363cb9560e13f35c07a7ed41a41706da44003227 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:12:37 +0200 Subject: [PATCH 60/62] macOS: fix icon style not updating on Tahoe --- .../Custom App Icon/DockTilePlugin.swift | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift index 990cd8bb2..de0661cb2 100644 --- a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift +++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift @@ -82,7 +82,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { /// Reset the application icon and dock tile icon to the default. private func resetIcon(dockTile: NSDockTile) { let appBundlePath = self.ghosttyAppURL?.path - let appIcon: NSImage + let appIcon: NSImage? if #available(macOS 26.0, *) { // Reset to the default (glassy) icon. if let appBundlePath { @@ -93,21 +93,8 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { // Use the `Blueprint` icon to distinguish Debug from Release builds. appIcon = pluginBundle.image(forResource: "BlueprintImage")! #else - // Get the composed icon from the app bundle. - if let appBundlePath, - let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath) - .bestRepresentation( - for: CGRect(origin: .zero, size: dockTile.size), - context: nil, - hints: nil - ) { - appIcon = NSImage(size: dockTile.size) - appIcon.addRepresentation(iconRep) - } else { - // If something unexpected happens on macOS 26, - // fall back to a bundled icon. - appIcon = pluginBundle.image(forResource: "AppIconImage")! - } + // Reset to Ghostty.icon + appIcon = nil #endif } else { // Use the bundled icon to keep the corner radius consistent with pre-Tahoe apps. @@ -126,9 +113,14 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { } private extension NSDockTile { - func setIcon(_ newIcon: NSImage) { + func setIcon(_ newIcon: NSImage?) { // Update the Dock tile on the main thread. DispatchQueue.main.async { + guard let newIcon else { + self.contentView = nil + self.display() + return + } let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size)) iconView.wantsLayer = true iconView.image = newIcon From 9897d6caba05c0cbf256f86bec2e2935f164a9c7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Apr 2026 10:08:43 -0700 Subject: [PATCH 61/62] libghostty: add GHOSTTY_ENUM_MAX_VALUE sentinel to all C enums Pre-C23, the C standard allows compilers to choose any integer type that can represent all enum values, so small enums could be backed by char or short. This breaks ABI compatibility with the Zig side, which backs these enums with c_int. Define GHOSTTY_ENUM_MAX_VALUE as INT_MAX in types.h and add it as the last entry in every enum in include/ghostty/vt/. This forces the compiler to use int as the backing type, matching c_int on all targets. INT_MAX is used rather than a fixed constant because enum constants must be representable as int; values above INT_MAX are a constraint violation in standard C. Document this convention in AGENTS.md. --- AGENTS.md | 2 ++ include/ghostty/vt/build_info.h | 2 ++ include/ghostty/vt/device.h | 1 + include/ghostty/vt/focus.h | 1 + include/ghostty/vt/formatter.h | 1 + include/ghostty/vt/key/encoder.h | 2 ++ include/ghostty/vt/key/event.h | 2 ++ include/ghostty/vt/kitty_graphics.h | 7 +++++++ include/ghostty/vt/modes.h | 1 + include/ghostty/vt/mouse/encoder.h | 3 +++ include/ghostty/vt/mouse/event.h | 2 ++ include/ghostty/vt/osc.h | 2 ++ include/ghostty/vt/point.h | 3 ++- include/ghostty/vt/render.h | 7 +++++++ include/ghostty/vt/screen.h | 6 ++++++ include/ghostty/vt/sgr.h | 2 ++ include/ghostty/vt/size_report.h | 1 + include/ghostty/vt/style.h | 3 ++- include/ghostty/vt/sys.h | 1 + include/ghostty/vt/terminal.h | 4 ++++ include/ghostty/vt/types.h | 22 ++++++++++++++++++++++ 21 files changed, 73 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7098007f5..8a5254889 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,8 @@ A file for [guiding coding agents](https://agents.md/). - Build WASM: `zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall` - Test: `zig build test-lib-vt -Dtest-filter=` - Prefer this when the change is in a libghostty-vt file +- All C enums in `include/ghostty/vt/` must have a `_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE` + sentinel as the last entry to force int enum sizing (pre-C23 portability). ## Directory Structure diff --git a/include/ghostty/vt/build_info.h b/include/ghostty/vt/build_info.h index 19999e77f..d0aedfdc1 100644 --- a/include/ghostty/vt/build_info.h +++ b/include/ghostty/vt/build_info.h @@ -40,6 +40,7 @@ typedef enum { GHOSTTY_OPTIMIZE_RELEASE_SAFE = 1, GHOSTTY_OPTIMIZE_RELEASE_SMALL = 2, GHOSTTY_OPTIMIZE_RELEASE_FAST = 3, + GHOSTTY_OPTIMIZE_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOptimizeMode; /** @@ -122,6 +123,7 @@ typedef enum { * Output type: GhosttyString * */ GHOSTTY_BUILD_INFO_VERSION_BUILD = 10, + GHOSTTY_BUILD_INFO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyBuildInfo; /** diff --git a/include/ghostty/vt/device.h b/include/ghostty/vt/device.h index fdf6bca7d..3b6ba3d7c 100644 --- a/include/ghostty/vt/device.h +++ b/include/ghostty/vt/device.h @@ -74,6 +74,7 @@ extern "C" { typedef enum { GHOSTTY_COLOR_SCHEME_LIGHT = 0, GHOSTTY_COLOR_SCHEME_DARK = 1, + GHOSTTY_COLOR_SCHEME_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyColorScheme; /** diff --git a/include/ghostty/vt/focus.h b/include/ghostty/vt/focus.h index 6e4c9502c..2f4954965 100644 --- a/include/ghostty/vt/focus.h +++ b/include/ghostty/vt/focus.h @@ -40,6 +40,7 @@ typedef enum { GHOSTTY_FOCUS_GAINED = 0, /** Terminal window lost focus */ GHOSTTY_FOCUS_LOST = 1, + GHOSTTY_FOCUS_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyFocusEvent; /** diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h index 9eacc6409..f0fc1447d 100644 --- a/include/ghostty/vt/formatter.h +++ b/include/ghostty/vt/formatter.h @@ -46,6 +46,7 @@ typedef enum { /** HTML with inline styles. */ GHOSTTY_FORMATTER_FORMAT_HTML, + GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyFormatterFormat; /** diff --git a/include/ghostty/vt/key/encoder.h b/include/ghostty/vt/key/encoder.h index 9d8282cec..5d1200741 100644 --- a/include/ghostty/vt/key/encoder.h +++ b/include/ghostty/vt/key/encoder.h @@ -73,6 +73,7 @@ typedef enum { GHOSTTY_OPTION_AS_ALT_LEFT = 2, /** Only right option key is treated as alt */ GHOSTTY_OPTION_AS_ALT_RIGHT = 3, + GHOSTTY_OPTION_AS_ALT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOptionAsAlt; /** @@ -104,6 +105,7 @@ typedef enum { /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, + GHOSTTY_KEY_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKeyEncoderOption; /** diff --git a/include/ghostty/vt/key/event.h b/include/ghostty/vt/key/event.h index bcc9d8dec..39f23d7e4 100644 --- a/include/ghostty/vt/key/event.h +++ b/include/ghostty/vt/key/event.h @@ -35,6 +35,7 @@ typedef enum { GHOSTTY_KEY_ACTION_PRESS = 1, /** Key is being repeated (held down) */ GHOSTTY_KEY_ACTION_REPEAT = 2, + GHOSTTY_KEY_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKeyAction; /** @@ -296,6 +297,7 @@ typedef enum { GHOSTTY_KEY_COPY, GHOSTTY_KEY_CUT, GHOSTTY_KEY_PASTE, + GHOSTTY_KEY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKey; /** diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index 1f6884348..b9ea64eb3 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -119,6 +119,7 @@ typedef enum { * Output type: GhosttyKittyGraphicsPlacementIterator * */ GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1, + GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsData; /** @@ -213,6 +214,7 @@ typedef enum { * Output type: int32_t * */ GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsPlacementData; /** @@ -231,6 +233,7 @@ typedef enum { GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_BG = 1, GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT = 2, GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT = 3, + GHOSTTY_KITTY_PLACEMENT_LAYER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyPlacementLayer; /** @@ -245,6 +248,7 @@ typedef enum { * Input type: GhosttyKittyPlacementLayer * */ GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER = 0, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsPlacementIteratorOption; /** @@ -258,6 +262,7 @@ typedef enum { GHOSTTY_KITTY_IMAGE_FORMAT_PNG = 2, GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA = 3, GHOSTTY_KITTY_IMAGE_FORMAT_GRAY = 4, + GHOSTTY_KITTY_IMAGE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyImageFormat; /** @@ -268,6 +273,7 @@ typedef enum { typedef enum { GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE = 0, GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE = 1, + GHOSTTY_KITTY_IMAGE_COMPRESSION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyImageCompression; /** @@ -335,6 +341,7 @@ typedef enum { * Output type: size_t * */ GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, + GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsImageData; /** diff --git a/include/ghostty/vt/modes.h b/include/ghostty/vt/modes.h index 513aaf5a5..f8fa7c009 100644 --- a/include/ghostty/vt/modes.h +++ b/include/ghostty/vt/modes.h @@ -157,6 +157,7 @@ typedef enum { GHOSTTY_MODE_REPORT_PERMANENTLY_SET = 3, /** Mode is permanently reset */ GHOSTTY_MODE_REPORT_PERMANENTLY_RESET = 4, + GHOSTTY_MODE_REPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyModeReportState; /** diff --git a/include/ghostty/vt/mouse/encoder.h b/include/ghostty/vt/mouse/encoder.h index 744b99303..a82dda089 100644 --- a/include/ghostty/vt/mouse/encoder.h +++ b/include/ghostty/vt/mouse/encoder.h @@ -45,6 +45,7 @@ typedef enum { /** Any-event tracking mode. */ GHOSTTY_MOUSE_TRACKING_ANY = 4, + GHOSTTY_MOUSE_TRACKING_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyMouseTrackingMode; /** @@ -58,6 +59,7 @@ typedef enum { GHOSTTY_MOUSE_FORMAT_SGR = 2, GHOSTTY_MOUSE_FORMAT_URXVT = 3, GHOSTTY_MOUSE_FORMAT_SGR_PIXELS = 4, + GHOSTTY_MOUSE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyMouseFormat; /** @@ -120,6 +122,7 @@ typedef enum { /** Whether to enable motion deduplication by last cell (value: bool). */ GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL = 4, + GHOSTTY_MOUSE_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyMouseEncoderOption; /** diff --git a/include/ghostty/vt/mouse/event.h b/include/ghostty/vt/mouse/event.h index 5b72735fc..ac71881f2 100644 --- a/include/ghostty/vt/mouse/event.h +++ b/include/ghostty/vt/mouse/event.h @@ -36,6 +36,7 @@ typedef enum { /** Mouse moved. */ GHOSTTY_MOUSE_ACTION_MOTION = 2, + GHOSTTY_MOUSE_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyMouseAction; /** @@ -56,6 +57,7 @@ typedef enum { GHOSTTY_MOUSE_BUTTON_NINE = 9, GHOSTTY_MOUSE_BUTTON_TEN = 10, GHOSTTY_MOUSE_BUTTON_ELEVEN = 11, + GHOSTTY_MOUSE_BUTTON_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyMouseButton; /** diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h index e17a8a182..43d137061 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -63,6 +63,7 @@ typedef enum { GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, + GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOscCommandType; /** @@ -88,6 +89,7 @@ typedef enum { * the same parser instance. Memory is owned by the parser. */ GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, + GHOSTTY_OSC_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOscCommandData; /** diff --git a/include/ghostty/vt/point.h b/include/ghostty/vt/point.h index f152a5c46..c1ade0f96 100644 --- a/include/ghostty/vt/point.h +++ b/include/ghostty/vt/point.h @@ -54,7 +54,8 @@ typedef enum { /** Scrollback history only (before active area). */ GHOSTTY_POINT_TAG_HISTORY = 3, -} GhosttyPointTag; + GHOSTTY_POINT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyPointTag; /** * Point value union. diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index b15be4902..759c9354c 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -95,6 +95,7 @@ typedef enum { /** Global state changed; renderer should redraw everything. */ GHOSTTY_RENDER_STATE_DIRTY_FULL = 2, + GHOSTTY_RENDER_STATE_DIRTY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateDirty; /** @@ -114,6 +115,7 @@ typedef enum { /** Hollow block cursor. */ GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK_HOLLOW = 3, + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateCursorVisualStyle; /** @@ -185,6 +187,7 @@ typedef enum { /** Whether the cursor is on the tail of a wide character (bool). * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL = 17, + GHOSTTY_RENDER_STATE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateData; /** @@ -195,6 +198,7 @@ typedef enum { typedef enum { /** Set dirty state (GhosttyRenderStateDirty). */ GHOSTTY_RENDER_STATE_OPTION_DIRTY = 0, + GHOSTTY_RENDER_STATE_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateOption; /** @@ -217,6 +221,7 @@ typedef enum { * valid as long as the underlying render state is not updated. * It is unsafe to use cell data after updating the render state. */ GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3, + GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowData; /** @@ -227,6 +232,7 @@ typedef enum { typedef enum { /** Set dirty state for the current row (bool). */ GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY = 0, + GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowOption; /** @@ -509,6 +515,7 @@ typedef enum { * color, in which case the caller should use whatever default foreground * color it wants (e.g. the terminal foreground). */ GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowCellsData; /** diff --git a/include/ghostty/vt/screen.h b/include/ghostty/vt/screen.h index 89b4825fe..13bb8e43b 100644 --- a/include/ghostty/vt/screen.h +++ b/include/ghostty/vt/screen.h @@ -69,6 +69,7 @@ typedef enum { /** No text; background color as RGB. */ GHOSTTY_CELL_CONTENT_BG_COLOR_RGB = 3, + GHOSTTY_CELL_CONTENT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyCellContentTag; /** @@ -90,6 +91,7 @@ typedef enum { /** Spacer at end of soft-wrapped line for a wide character. */ GHOSTTY_CELL_WIDE_SPACER_HEAD = 3, + GHOSTTY_CELL_WIDE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyCellWide; /** @@ -109,6 +111,7 @@ typedef enum { /** Content that is part of a shell prompt. */ GHOSTTY_CELL_SEMANTIC_PROMPT = 2, + GHOSTTY_CELL_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyCellSemanticContent; /** @@ -201,6 +204,7 @@ typedef enum { * Output type: GhosttyColorRgb * */ GHOSTTY_CELL_DATA_COLOR_RGB = 11, + GHOSTTY_CELL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyCellData; /** @@ -220,6 +224,7 @@ typedef enum { /** Prompt cells exist and this is a continuation line. */ GHOSTTY_ROW_SEMANTIC_PROMPT_CONTINUATION = 2, + GHOSTTY_ROW_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRowSemanticPrompt; /** @@ -289,6 +294,7 @@ typedef enum { * Output type: bool * */ GHOSTTY_ROW_DATA_DIRTY = 8, + GHOSTTY_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRowData; /** diff --git a/include/ghostty/vt/sgr.h b/include/ghostty/vt/sgr.h index b093bc9ff..00849283d 100644 --- a/include/ghostty/vt/sgr.h +++ b/include/ghostty/vt/sgr.h @@ -87,6 +87,7 @@ typedef enum { GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 28, GHOSTTY_SGR_ATTR_BG_256 = 29, GHOSTTY_SGR_ATTR_FG_256 = 30, + GHOSTTY_SGR_ATTR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySgrAttributeTag; /** @@ -101,6 +102,7 @@ typedef enum { GHOSTTY_SGR_UNDERLINE_CURLY = 3, GHOSTTY_SGR_UNDERLINE_DOTTED = 4, GHOSTTY_SGR_UNDERLINE_DASHED = 5, + GHOSTTY_SGR_UNDERLINE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySgrUnderline; /** diff --git a/include/ghostty/vt/size_report.h b/include/ghostty/vt/size_report.h index 98c67c5ed..d65fa95f6 100644 --- a/include/ghostty/vt/size_report.h +++ b/include/ghostty/vt/size_report.h @@ -49,6 +49,7 @@ typedef enum { GHOSTTY_SIZE_REPORT_CSI_16_T = 2, /** XTWINOPS text area size in characters: ESC [ 8 ; rows ; cols t */ GHOSTTY_SIZE_REPORT_CSI_18_T = 3, + GHOSTTY_SIZE_REPORT_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySizeReportStyle; /** diff --git a/include/ghostty/vt/style.h b/include/ghostty/vt/style.h index ac0495600..ea1750395 100644 --- a/include/ghostty/vt/style.h +++ b/include/ghostty/vt/style.h @@ -50,7 +50,8 @@ typedef enum { GHOSTTY_STYLE_COLOR_NONE = 0, GHOSTTY_STYLE_COLOR_PALETTE = 1, GHOSTTY_STYLE_COLOR_RGB = 2, -} GhosttyStyleColorTag; + GHOSTTY_STYLE_COLOR_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyStyleColorTag; /** * Style color value union. diff --git a/include/ghostty/vt/sys.h b/include/ghostty/vt/sys.h index 0634f5ac8..56e60b237 100644 --- a/include/ghostty/vt/sys.h +++ b/include/ghostty/vt/sys.h @@ -106,6 +106,7 @@ typedef enum { * Input type: GhosttySysDecodePngFn (function pointer, or NULL) */ GHOSTTY_SYS_OPT_DECODE_PNG = 1, + GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySysOption; /** diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index a229dd700..c57ba27b5 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -189,6 +189,7 @@ typedef enum { /** Scroll by a delta amount (up is negative). */ GHOSTTY_SCROLL_VIEWPORT_DELTA, + GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalScrollViewportTag; /** @@ -227,6 +228,7 @@ typedef enum { /** The alternate screen. */ GHOSTTY_TERMINAL_SCREEN_ALTERNATE = 1, + GHOSTTY_TERMINAL_SCREEN_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalScreen; /** @@ -571,6 +573,7 @@ typedef enum { * Input type: bool* */ GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM = 18, + GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; /** @@ -846,6 +849,7 @@ typedef enum { * Output type: GhosttyKittyGraphics * */ GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30, + GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalData; /** diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 0fe37e3b2..70ac11d57 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -7,6 +7,7 @@ #ifndef GHOSTTY_VT_TYPES_H #define GHOSTTY_VT_TYPES_H +#include #include #include @@ -32,6 +33,26 @@ #endif #endif +/** + * Sentinel value for enum definitions to force max int width sizing. + * + * Pre-C23, the C standard allows compilers to choose any integer type + * that can represent all enum values (C11 §6.7.2.2), so small enums + * could be backed by char or short. Adding this value as the last + * entry in every enum forces the compiler to use at an `int` + * type, ensuring ABI stability across compilers and platforms. + * + * We use INT_MAX rather than a fixed constant like 0xFFFFFFFF because + * enum constants must have type int (which is signed). Values above + * INT_MAX overflow signed int and are a constraint violation in + * standard C; compilers that accept them interpret them as negative + * values via two's complement, which can collide with legitimate + * negative enum values. Using INT_MAX also ensures the enum matches + * the target's int size, which is important because the Zig side + * backs these enums with c_int for ABI compatibility. + */ +#define GHOSTTY_ENUM_MAX_VALUE INT_MAX + /** * Result codes for libghostty-vt operations. */ @@ -46,6 +67,7 @@ typedef enum { GHOSTTY_OUT_OF_SPACE = -3, /** The requested value has no value */ GHOSTTY_NO_VALUE = -4, + GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyResult; /* ---- Opaque handles ---- */ From f282f13a210ed7e8760f1cb87f465ef82253aaf6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Apr 2026 10:41:08 -0700 Subject: [PATCH 62/62] libghostty: add GHOSTTY_ENUM_TYPED and GHOSTTY_ENUM_MAX_VALUE to all C enums GHOSTTY_ENUM_TYPED: expands to `: int` on C23 (where explicit enum underlying types are supported), empty on older standards. --- include/ghostty/vt/build_info.h | 4 +-- include/ghostty/vt/device.h | 2 +- include/ghostty/vt/focus.h | 2 +- include/ghostty/vt/formatter.h | 2 +- include/ghostty/vt/key/encoder.h | 4 +-- include/ghostty/vt/key/event.h | 4 +-- include/ghostty/vt/kitty_graphics.h | 14 ++++----- include/ghostty/vt/modes.h | 2 +- include/ghostty/vt/mouse/encoder.h | 6 ++-- include/ghostty/vt/mouse/event.h | 4 +-- include/ghostty/vt/osc.h | 4 +-- include/ghostty/vt/point.h | 2 +- include/ghostty/vt/render.h | 14 ++++----- include/ghostty/vt/screen.h | 12 ++++---- include/ghostty/vt/sgr.h | 4 +-- include/ghostty/vt/size_report.h | 2 +- include/ghostty/vt/style.h | 2 +- include/ghostty/vt/sys.h | 2 +- include/ghostty/vt/terminal.h | 8 ++--- include/ghostty/vt/types.h | 45 +++++++++++++++++++---------- 20 files changed, 77 insertions(+), 62 deletions(-) diff --git a/include/ghostty/vt/build_info.h b/include/ghostty/vt/build_info.h index d0aedfdc1..8573556f7 100644 --- a/include/ghostty/vt/build_info.h +++ b/include/ghostty/vt/build_info.h @@ -35,7 +35,7 @@ extern "C" { /** * Build optimization mode. */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_OPTIMIZE_DEBUG = 0, GHOSTTY_OPTIMIZE_RELEASE_SAFE = 1, GHOSTTY_OPTIMIZE_RELEASE_SMALL = 2, @@ -48,7 +48,7 @@ typedef enum { * * Each variant documents the expected output pointer type. */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid data type. Never results in any data extraction. */ GHOSTTY_BUILD_INFO_INVALID = 0, diff --git a/include/ghostty/vt/device.h b/include/ghostty/vt/device.h index 3b6ba3d7c..0a1567280 100644 --- a/include/ghostty/vt/device.h +++ b/include/ghostty/vt/device.h @@ -71,7 +71,7 @@ extern "C" { * * @ingroup terminal */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_COLOR_SCHEME_LIGHT = 0, GHOSTTY_COLOR_SCHEME_DARK = 1, GHOSTTY_COLOR_SCHEME_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, diff --git a/include/ghostty/vt/focus.h b/include/ghostty/vt/focus.h index 2f4954965..b9940f792 100644 --- a/include/ghostty/vt/focus.h +++ b/include/ghostty/vt/focus.h @@ -35,7 +35,7 @@ extern "C" { /** * Focus event types for focus reporting mode (mode 1004). */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Terminal window gained focus */ GHOSTTY_FOCUS_GAINED = 0, /** Terminal window lost focus */ diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h index f0fc1447d..358e95f66 100644 --- a/include/ghostty/vt/formatter.h +++ b/include/ghostty/vt/formatter.h @@ -37,7 +37,7 @@ extern "C" { * * @ingroup formatter */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Plain text (no escape sequences). */ GHOSTTY_FORMATTER_FORMAT_PLAIN, diff --git a/include/ghostty/vt/key/encoder.h b/include/ghostty/vt/key/encoder.h index 5d1200741..dc9e27e7e 100644 --- a/include/ghostty/vt/key/encoder.h +++ b/include/ghostty/vt/key/encoder.h @@ -64,7 +64,7 @@ typedef uint8_t GhosttyKittyKeyFlags; * * @ingroup key */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Option key is not treated as alt */ GHOSTTY_OPTION_AS_ALT_FALSE = 0, /** Option key is treated as alt */ @@ -84,7 +84,7 @@ typedef enum { * * @ingroup key */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Terminal DEC mode 1: cursor key application mode (value: bool) */ GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, diff --git a/include/ghostty/vt/key/event.h b/include/ghostty/vt/key/event.h index 39f23d7e4..eba433c6a 100644 --- a/include/ghostty/vt/key/event.h +++ b/include/ghostty/vt/key/event.h @@ -28,7 +28,7 @@ typedef struct GhosttyKeyEventImpl *GhosttyKeyEvent; * * @ingroup key */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Key was released */ GHOSTTY_KEY_ACTION_RELEASE = 0, /** Key was pressed */ @@ -104,7 +104,7 @@ typedef uint16_t GhosttyMods; * * @ingroup key */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_KEY_UNIDENTIFIED = 0, // Writing System Keys (W3C § 3.1.1) diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index b9ea64eb3..04d1daf27 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -107,7 +107,7 @@ extern "C" { * * @ingroup kitty_graphics */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid / sentinel value. */ GHOSTTY_KITTY_GRAPHICS_DATA_INVALID = 0, @@ -127,7 +127,7 @@ typedef enum { * * @ingroup kitty_graphics */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid / sentinel value. */ GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_INVALID = 0, @@ -228,7 +228,7 @@ typedef enum { * * @ingroup kitty_graphics */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_KITTY_PLACEMENT_LAYER_ALL = 0, GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_BG = 1, GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT = 2, @@ -241,7 +241,7 @@ typedef enum { * * @ingroup kitty_graphics */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** * Set the z-layer filter for the iterator. * @@ -256,7 +256,7 @@ typedef enum { * * @ingroup kitty_graphics */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_KITTY_IMAGE_FORMAT_RGB = 0, GHOSTTY_KITTY_IMAGE_FORMAT_RGBA = 1, GHOSTTY_KITTY_IMAGE_FORMAT_PNG = 2, @@ -270,7 +270,7 @@ typedef enum { * * @ingroup kitty_graphics */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE = 0, GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE = 1, GHOSTTY_KITTY_IMAGE_COMPRESSION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, @@ -281,7 +281,7 @@ typedef enum { * * @ingroup kitty_graphics */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid / sentinel value. */ GHOSTTY_KITTY_IMAGE_DATA_INVALID = 0, diff --git a/include/ghostty/vt/modes.h b/include/ghostty/vt/modes.h index f8fa7c009..db95a1a7d 100644 --- a/include/ghostty/vt/modes.h +++ b/include/ghostty/vt/modes.h @@ -146,7 +146,7 @@ static inline bool ghostty_mode_ansi(GhosttyMode mode) { * These correspond to the Ps2 parameter in a DECRPM response * sequence (CSI ? Ps1 ; Ps2 $ y). */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Mode is not recognized */ GHOSTTY_MODE_REPORT_NOT_RECOGNIZED = 0, /** Mode is set (enabled) */ diff --git a/include/ghostty/vt/mouse/encoder.h b/include/ghostty/vt/mouse/encoder.h index a82dda089..d84d863c8 100644 --- a/include/ghostty/vt/mouse/encoder.h +++ b/include/ghostty/vt/mouse/encoder.h @@ -30,7 +30,7 @@ typedef struct GhosttyMouseEncoderImpl *GhosttyMouseEncoder; * * @ingroup mouse */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Mouse reporting disabled. */ GHOSTTY_MOUSE_TRACKING_NONE = 0, @@ -53,7 +53,7 @@ typedef enum { * * @ingroup mouse */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_MOUSE_FORMAT_X10 = 0, GHOSTTY_MOUSE_FORMAT_UTF8 = 1, GHOSTTY_MOUSE_FORMAT_SGR = 2, @@ -107,7 +107,7 @@ typedef struct { * * @ingroup mouse */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Mouse tracking mode (value: GhosttyMouseTrackingMode). */ GHOSTTY_MOUSE_ENCODER_OPT_EVENT = 0, diff --git a/include/ghostty/vt/mouse/event.h b/include/ghostty/vt/mouse/event.h index ac71881f2..a24b0c079 100644 --- a/include/ghostty/vt/mouse/event.h +++ b/include/ghostty/vt/mouse/event.h @@ -27,7 +27,7 @@ typedef struct GhosttyMouseEventImpl *GhosttyMouseEvent; * * @ingroup mouse */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Mouse button was pressed. */ GHOSTTY_MOUSE_ACTION_PRESS = 0, @@ -44,7 +44,7 @@ typedef enum { * * @ingroup mouse */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_MOUSE_BUTTON_UNKNOWN = 0, GHOSTTY_MOUSE_BUTTON_LEFT = 1, GHOSTTY_MOUSE_BUTTON_RIGHT = 2, diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h index 43d137061..9409ebc73 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -39,7 +39,7 @@ * * @ingroup osc */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_OSC_COMMAND_INVALID = 0, GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, @@ -74,7 +74,7 @@ typedef enum { * * @ingroup osc */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid data type. Never results in any data extraction. */ GHOSTTY_OSC_DATA_INVALID = 0, diff --git a/include/ghostty/vt/point.h b/include/ghostty/vt/point.h index c1ade0f96..8b717f494 100644 --- a/include/ghostty/vt/point.h +++ b/include/ghostty/vt/point.h @@ -42,7 +42,7 @@ typedef struct { * * @ingroup point */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Active area where the cursor can move. */ GHOSTTY_POINT_TAG_ACTIVE = 0, diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index 759c9354c..3c2ea619e 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -86,7 +86,7 @@ extern "C" { * * @ingroup render */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Not dirty at all; rendering can be skipped. */ GHOSTTY_RENDER_STATE_DIRTY_FALSE = 0, @@ -103,7 +103,7 @@ typedef enum { * * @ingroup render */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Bar cursor (DECSCUSR 5, 6). */ GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BAR = 0, @@ -123,7 +123,7 @@ typedef enum { * * @ingroup render */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid / sentinel value. */ GHOSTTY_RENDER_STATE_DATA_INVALID = 0, @@ -195,7 +195,7 @@ typedef enum { * * @ingroup render */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Set dirty state (GhosttyRenderStateDirty). */ GHOSTTY_RENDER_STATE_OPTION_DIRTY = 0, GHOSTTY_RENDER_STATE_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, @@ -206,7 +206,7 @@ typedef enum { * * @ingroup render */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid / sentinel value. */ GHOSTTY_RENDER_STATE_ROW_DATA_INVALID = 0, @@ -229,7 +229,7 @@ typedef enum { * * @ingroup render */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Set dirty state for the current row (bool). */ GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY = 0, GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, @@ -481,7 +481,7 @@ GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_new( * * @ingroup render */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid / sentinel value. */ GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_INVALID = 0, diff --git a/include/ghostty/vt/screen.h b/include/ghostty/vt/screen.h index 13bb8e43b..a8f73abad 100644 --- a/include/ghostty/vt/screen.h +++ b/include/ghostty/vt/screen.h @@ -57,7 +57,7 @@ typedef uint64_t GhosttyRow; * * @ingroup screen */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** A single codepoint (may be zero for empty). */ GHOSTTY_CELL_CONTENT_CODEPOINT = 0, @@ -79,7 +79,7 @@ typedef enum { * * @ingroup screen */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Not a wide character, cell width 1. */ GHOSTTY_CELL_WIDE_NARROW = 0, @@ -102,7 +102,7 @@ typedef enum { * * @ingroup screen */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Regular output content, such as command output. */ GHOSTTY_CELL_SEMANTIC_OUTPUT = 0, @@ -122,7 +122,7 @@ typedef enum { * * @ingroup screen */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid data type. Never results in any data extraction. */ GHOSTTY_CELL_DATA_INVALID = 0, @@ -215,7 +215,7 @@ typedef enum { * * @ingroup screen */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** No prompt cells in this row. */ GHOSTTY_ROW_SEMANTIC_NONE = 0, @@ -235,7 +235,7 @@ typedef enum { * * @ingroup screen */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid data type. Never results in any data extraction. */ GHOSTTY_ROW_DATA_INVALID = 0, diff --git a/include/ghostty/vt/sgr.h b/include/ghostty/vt/sgr.h index 00849283d..8eec11dc9 100644 --- a/include/ghostty/vt/sgr.h +++ b/include/ghostty/vt/sgr.h @@ -55,7 +55,7 @@ extern "C" { * * @ingroup sgr */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SGR_ATTR_UNSET = 0, GHOSTTY_SGR_ATTR_UNKNOWN = 1, GHOSTTY_SGR_ATTR_BOLD = 2, @@ -95,7 +95,7 @@ typedef enum { * * @ingroup sgr */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SGR_UNDERLINE_NONE = 0, GHOSTTY_SGR_UNDERLINE_SINGLE = 1, GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, diff --git a/include/ghostty/vt/size_report.h b/include/ghostty/vt/size_report.h index d65fa95f6..da33e5e55 100644 --- a/include/ghostty/vt/size_report.h +++ b/include/ghostty/vt/size_report.h @@ -40,7 +40,7 @@ extern "C" { * * Determines the output format for the terminal size report. */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** In-band size report (mode 2048): ESC [ 48 ; rows ; cols ; height ; width t */ GHOSTTY_SIZE_REPORT_MODE_2048 = 0, /** XTWINOPS text area size in pixels: ESC [ 4 ; height ; width t */ diff --git a/include/ghostty/vt/style.h b/include/ghostty/vt/style.h index ea1750395..b6bf860eb 100644 --- a/include/ghostty/vt/style.h +++ b/include/ghostty/vt/style.h @@ -46,7 +46,7 @@ typedef uint16_t GhosttyStyleId; * * @ingroup style */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_STYLE_COLOR_NONE = 0, GHOSTTY_STYLE_COLOR_PALETTE = 1, GHOSTTY_STYLE_COLOR_RGB = 2, diff --git a/include/ghostty/vt/sys.h b/include/ghostty/vt/sys.h index 56e60b237..e3d6e2cc7 100644 --- a/include/ghostty/vt/sys.h +++ b/include/ghostty/vt/sys.h @@ -88,7 +88,7 @@ typedef bool (*GhosttySysDecodePngFn)( /** * System option identifiers for ghostty_sys_set(). */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** * Set the userdata pointer passed to all sys callbacks. * diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index c57ba27b5..637bebbfb 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -180,7 +180,7 @@ typedef struct { * * @ingroup terminal */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Scroll to the top of the scrollback. */ GHOSTTY_SCROLL_VIEWPORT_TOP, @@ -222,7 +222,7 @@ typedef struct { * * @ingroup terminal */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** The primary (normal) screen. */ GHOSTTY_TERMINAL_SCREEN_PRIMARY = 0, @@ -396,7 +396,7 @@ typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, * * @ingroup terminal */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** * Opaque userdata pointer passed to all callbacks. * @@ -584,7 +584,7 @@ typedef enum { * * @ingroup terminal */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid data type. Never results in any data extraction. */ GHOSTTY_TERMINAL_DATA_INVALID = 0, diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 70ac11d57..e0be0b77d 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -34,29 +34,44 @@ #endif /** - * Sentinel value for enum definitions to force max int width sizing. + * Enum int-sizing helpers. * - * Pre-C23, the C standard allows compilers to choose any integer type - * that can represent all enum values (C11 §6.7.2.2), so small enums - * could be backed by char or short. Adding this value as the last - * entry in every enum forces the compiler to use at an `int` - * type, ensuring ABI stability across compilers and platforms. + * The Zig side backs all C enums with c_int, so the C declarations + * must use int as their underlying type to maintain ABI compatibility. * - * We use INT_MAX rather than a fixed constant like 0xFFFFFFFF because - * enum constants must have type int (which is signed). Values above - * INT_MAX overflow signed int and are a constraint violation in - * standard C; compilers that accept them interpret them as negative - * values via two's complement, which can collide with legitimate - * negative enum values. Using INT_MAX also ensures the enum matches - * the target's int size, which is important because the Zig side - * backs these enums with c_int for ABI compatibility. + * C23 (detected via __STDC_VERSION__ >= 202311L) supports explicit + * enum underlying types with `enum : int { ... }`. For pre-C23 + * compilers, which are free to choose any type that can represent + * all values (C11 §6.7.2.2), we add an INT_MAX sentinel as the last + * entry to force the compiler to use int. + * + * INT_MAX is used rather than a fixed constant like 0xFFFFFFFF + * because enum constants must have type int (which is signed). + * Values above INT_MAX overflow signed int and are a constraint + * violation in standard C; compilers that accept them interpret them + * as negative values via two's complement, which can collide with + * legitimate negative enum values. + * + * Usage: + * @code + * typedef enum GHOSTTY_ENUM_TYPED { + * FOO_A = 0, + * FOO_B = 1, + * FOO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + * } Foo; + * @endcode */ +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L +#define GHOSTTY_ENUM_TYPED : int +#else +#define GHOSTTY_ENUM_TYPED +#endif #define GHOSTTY_ENUM_MAX_VALUE INT_MAX /** * Result codes for libghostty-vt operations. */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Operation completed successfully */ GHOSTTY_SUCCESS = 0, /** Operation failed due to failed allocation */