lib-vt: add color scheme report encoder (#13192)

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.
This commit is contained in:
Mitchell Hashimoto
2026-07-05 12:53:13 -07:00
committed by GitHub
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