From f00e906949bbe46904ff7a13eeff9e8d4a292d09 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jul 2026 20:34:53 -0700 Subject: [PATCH] lib-vt: add color scheme report encoder Add a shared encoder for CSI ? 997 ; Ps n color scheme reports and use it for both CSI ? 996 n replies and unsolicited Termio reports. Export the same encoder through the libghostty-vt C API with docs and an example. This is a really light API, arguably easy for consumers to hardcode, but it didn't match the rest of our style in the libghostty API so we should expose it. Example: GHOSTTY_COLOR_SCHEME_DARK encodes to ESC [ ? 997 ; 1 n, while GHOSTTY_COLOR_SCHEME_LIGHT encodes to ESC [ ? 997 ; 2 n. --- example/c-vt-color-scheme/README.md | 18 ++++++ example/c-vt-color-scheme/build.zig | 42 ++++++++++++++ example/c-vt-color-scheme/build.zig.zon | 24 ++++++++ example/c-vt-color-scheme/src/main.c | 20 +++++++ include/ghostty/vt.h | 1 + include/ghostty/vt/color_scheme.h | 73 +++++++++++++++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/color_scheme.zig | 63 +++++++++++++++++++++ src/terminal/c/main.zig | 4 ++ src/terminal/device_status.zig | 42 ++++++++++++++ src/terminal/stream_terminal.zig | 9 +-- src/termio/Termio.zig | 12 ++-- 12 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 example/c-vt-color-scheme/README.md create mode 100644 example/c-vt-color-scheme/build.zig create mode 100644 example/c-vt-color-scheme/build.zig.zon create mode 100644 example/c-vt-color-scheme/src/main.c create mode 100644 include/ghostty/vt/color_scheme.h create mode 100644 src/terminal/c/color_scheme.zig diff --git a/example/c-vt-color-scheme/README.md b/example/c-vt-color-scheme/README.md new file mode 100644 index 000000000..f3e2f8a81 --- /dev/null +++ b/example/c-vt-color-scheme/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Color Scheme Report Encoding + +This contains a simple example of how to use the `ghostty-vt` color scheme +report encoding API to encode terminal color scheme reports into escape +sequences. + +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-color-scheme/build.zig b/example/c-vt-color-scheme/build.zig new file mode 100644 index 000000000..132f46c86 --- /dev/null +++ b/example/c-vt-color-scheme/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_color_scheme", + .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-color-scheme/build.zig.zon b/example/c-vt-color-scheme/build.zig.zon new file mode 100644 index 000000000..d5d1087d5 --- /dev/null +++ b/example/c-vt-color-scheme/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_color_scheme, + .version = "0.0.0", + .fingerprint = 0xb794dffb11875b23, + .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-color-scheme/src/main.c b/example/c-vt-color-scheme/src/main.c new file mode 100644 index 000000000..dc70e606e --- /dev/null +++ b/example/c-vt-color-scheme/src/main.c @@ -0,0 +1,20 @@ +#include +#include + +//! [color-scheme-report-encode] +int main() { + char buf[16]; + size_t written = 0; + + GhosttyResult result = ghostty_color_scheme_report_encode( + GHOSTTY_COLOR_SCHEME_DARK, buf, sizeof(buf), &written); + + if (result == GHOSTTY_SUCCESS) { + printf("Encoded %zu bytes: ", written); + fwrite(buf, 1, written, stdout); + printf("\n"); + } + + return 0; +} +//! [color-scheme-report-encode] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 30df72a3a..b566c730a 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -126,6 +126,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/color_scheme.h b/include/ghostty/vt/color_scheme.h new file mode 100644 index 000000000..d028ec080 --- /dev/null +++ b/include/ghostty/vt/color_scheme.h @@ -0,0 +1,73 @@ +/** + * @file color_scheme.h + * + * Color scheme report encoding - encode terminal color scheme reports into + * escape sequences. + */ + +#ifndef GHOSTTY_VT_COLOR_SCHEME_H +#define GHOSTTY_VT_COLOR_SCHEME_H + +/** @defgroup color_scheme Color Scheme Report Encoding + * + * Utilities for encoding color scheme reports into terminal escape + * sequences for color scheme reporting mode (mode 2031). + * + * ## Basic Usage + * + * Use ghostty_color_scheme_report_encode() to encode a color scheme report + * into a caller-provided buffer. If the buffer is too small, the function + * returns GHOSTTY_OUT_OF_SPACE and sets the required size in the output + * parameter. + * + * ## Example + * + * @snippet c-vt-color-scheme/src/main.c color-scheme-report-encode + * + * @{ + */ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Encode a color scheme report into an escape sequence. + * + * Encodes a color scheme report into the provided buffer. Dark color schemes + * emit ESC [ ? 997 ; 1 n, and light color schemes emit ESC [ ? 997 ; 2 n. + * The encoded bytes are identical to the terminal's internal CSI ? 996 n + * query response. + * + * Hosts should gate unsolicited sends on GHOSTTY_MODE_COLOR_SCHEME_REPORT + * (mode 2031) being set, which can be checked via the mode getters. + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param scheme The color scheme to encode + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_color_scheme_report_encode( + GhosttyColorScheme scheme, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_COLOR_SCHEME_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 5af267f55..49045edb4 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -191,6 +191,7 @@ comptime { @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); + @export(&c.color_scheme_report_encode, .{ .name = "ghostty_color_scheme_report_encode" }); @export(&c.focus_encode, .{ .name = "ghostty_focus_encode" }); @export(&c.mode_report_encode, .{ .name = "ghostty_mode_report_encode" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); diff --git a/src/terminal/c/color_scheme.zig b/src/terminal/c/color_scheme.zig new file mode 100644 index 000000000..ee022b359 --- /dev/null +++ b/src/terminal/c/color_scheme.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const device_status = @import("../device_status.zig"); +const Result = @import("result.zig").Result; + +pub fn report_encode( + scheme: device_status.ColorScheme, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + device_status.encodeColorSchemeReport(&writer, scheme) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + device_status.encodeColorSchemeReport(&discarding.writer, scheme) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode color scheme report dark" { + var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined; + var written: usize = 0; + const result = report_encode(.dark, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?997;1n", buf[0..written]); +} + +test "encode color scheme report light" { + var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined; + var written: usize = 0; + const result = report_encode(.light, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?997;2n", buf[0..written]); +} + +test "encode color scheme report with null buffer" { + var written: usize = 0; + const result = report_encode(.dark, null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(@as(usize, 9), written); +} + +test "encode color scheme report with insufficient buffer" { + var buf: [3]u8 = undefined; + var written: usize = 0; + const result = report_encode(.light, &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(@as(usize, 9), written); +} + +test "encode color scheme report with exact buffer" { + var buf: [9]u8 = undefined; + var written: usize = 0; + const result = report_encode(.dark, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqual(@as(usize, 9), written); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index ba04209c7..b41e5a815 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -5,6 +5,7 @@ const buildpkg = @import("build_info.zig"); pub const allocator = @import("allocator.zig"); pub const cell = @import("cell.zig"); pub const color = @import("color.zig"); +pub const color_scheme = @import("color_scheme.zig"); pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); @@ -58,6 +59,8 @@ pub const osc_command_data = osc.commandData; pub const color_rgb_get = color.rgb_get; +pub const color_scheme_report_encode = color_scheme.report_encode; + pub const focus_encode = focus.encode; pub const mode_report_encode = modes.report_encode; @@ -218,6 +221,7 @@ test { _ = buildpkg; _ = cell; _ = color; + _ = color_scheme; _ = grid_ref; _ = grid_ref_tracked; _ = kitty_graphics; diff --git a/src/terminal/device_status.zig b/src/terminal/device_status.zig index 42b4bf01b..fd1a9b7e6 100644 --- a/src/terminal/device_status.zig +++ b/src/terminal/device_status.zig @@ -7,6 +7,32 @@ pub const ColorScheme = lib.Enum(lib.target, &.{ "dark", }); +/// Maximum number of bytes that `encodeColorSchemeReport` will write. +pub const max_color_scheme_report_encode_size = max: { + var result: usize = 0; + for (@typeInfo(ColorScheme).@"enum".fields) |field| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + encodeColorSchemeReport( + &discarding.writer, + @enumFromInt(field.value), + ) catch unreachable; + result = @max(result, @as(usize, @intCast(discarding.count))); + } + + break :max result; +}; + +/// Encode a color scheme report response for CSI ? 996 n queries. +pub fn encodeColorSchemeReport( + writer: *std.Io.Writer, + scheme: ColorScheme, +) std.Io.Writer.Error!void { + try writer.writeAll(switch (scheme) { + .dark => "\x1B[?997;1n", + .light => "\x1B[?997;2n", + }); +} + /// An enum(u16) of the available device status requests. pub const Request = dsr_enum: { const EnumField = std.builtin.Type.EnumField; @@ -72,3 +98,19 @@ const entries: []const Entry = &.{ .{ .name = "cursor_position", .value = 6 }, .{ .name = "color_scheme", .value = 996, .question = true }, }; + +test "encode color scheme report dark" { + try std.testing.expectEqual(@as(usize, 9), max_color_scheme_report_encode_size); + + var buf: [max_color_scheme_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeColorSchemeReport(&writer, .dark); + try std.testing.expectEqualStrings("\x1B[?997;1n", writer.buffered()); +} + +test "encode color scheme report light" { + var buf: [max_color_scheme_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeColorSchemeReport(&writer, .light); + try std.testing.expectEqualStrings("\x1B[?997;2n", writer.buffered()); +} diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index e3d42c51e..929e4ccd4 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -348,10 +348,11 @@ pub const Handler = struct { .color_scheme => { const func = self.effects.color_scheme orelse return; const scheme = func(self) orelse return; - self.writePty(switch (scheme) { - .dark => "\x1B[?997;1n", - .light => "\x1B[?997;2n", - }); + var buf: [device_status.max_color_scheme_report_encode_size + 1]u8 = undefined; + var writer: std.Io.Writer = .fixed(buf[0..device_status.max_color_scheme_report_encode_size]); + device_status.encodeColorSchemeReport(&writer, scheme) catch return; + buf[writer.end] = 0; + self.writePty(buf[0..writer.end :0]); }, } } diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 1d1bfe25a..1b08c5e9a 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -712,11 +712,15 @@ pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !voi if (!force and !self.renderer_state.terminal.modes.get(.report_color_scheme)) { return; } - const output = switch (self.config.conditional_state.theme) { - .light => "\x1B[?997;2n", - .dark => "\x1B[?997;1n", + const scheme: terminalpkg.device_status.ColorScheme = switch (self.config.conditional_state.theme) { + .light => .light, + .dark => .dark, }; - try self.queueWrite(td, output, false); + + var buf: [terminalpkg.device_status.max_color_scheme_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try terminalpkg.device_status.encodeColorSchemeReport(&writer, scheme); + try self.queueWrite(td, writer.buffered(), false); } /// ThreadData is the data created and stored in the termio thread