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.
This commit is contained in:
Mitchell Hashimoto
2026-07-04 20:34:53 -07:00
parent 98df7efc83
commit f00e906949
12 changed files with 301 additions and 8 deletions

View File

@@ -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
```

View File

@@ -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);
}

View File

@@ -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",
},
}

View File

@@ -0,0 +1,20 @@
#include <stdio.h>
#include <ghostty/vt.h>
//! [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]

View File

@@ -126,6 +126,7 @@ extern "C" {
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/build_info.h>
#include <ghostty/vt/color.h>
#include <ghostty/vt/color_scheme.h>
#include <ghostty/vt/device.h>
#include <ghostty/vt/focus.h>
#include <ghostty/vt/formatter.h>

View File

@@ -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 <stddef.h>
#include <ghostty/vt/types.h>
#include <ghostty/vt/device.h>
#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 */

View File

@@ -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" });

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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());
}

View File

@@ -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]);
},
}
}

View File

@@ -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