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

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