mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-11 11:49:40 +00:00
lib-vt: report OSC and Kitty color queries (#13239)
Hooks up responses to OSC and Kitty color queries if `write_pty` is set for libghostty. Also found a memory leak: the OSC parser now releases color operation request lists during reset.
This commit is contained in:
@@ -150,6 +150,49 @@ pub const Colors = struct {
|
||||
};
|
||||
};
|
||||
|
||||
/// Returns the current color for an xterm OSC color target.
|
||||
///
|
||||
/// Unsupported dynamic and special colors return null. The cursor color
|
||||
/// follows xterm-style reporting and falls back to the foreground color when
|
||||
/// no explicit cursor color is set.
|
||||
pub fn colorForXterm(self: *const Terminal, target: osc.color.Target) ?color.RGB {
|
||||
return switch (target) {
|
||||
.palette => |i| self.colors.palette.current[i],
|
||||
.dynamic => |dynamic| switch (dynamic) {
|
||||
.foreground => self.colors.foreground.get(),
|
||||
.background => self.colors.background.get(),
|
||||
.cursor => self.colors.cursor.get() orelse
|
||||
self.colors.foreground.get(),
|
||||
.pointer_foreground,
|
||||
.pointer_background,
|
||||
.tektronix_foreground,
|
||||
.tektronix_background,
|
||||
.highlight_background,
|
||||
.tektronix_cursor,
|
||||
.highlight_foreground,
|
||||
=> null,
|
||||
},
|
||||
.special => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the current color for a Kitty color protocol key.
|
||||
///
|
||||
/// Only palette, foreground, background, and cursor colors are backed by
|
||||
/// Terminal state. Unsupported keys, or supported dynamic colors without a
|
||||
/// value, return null.
|
||||
pub fn colorForKitty(self: *const Terminal, key: kitty.color.Kind) ?color.RGB {
|
||||
return switch (key) {
|
||||
.palette => |palette| self.colors.palette.current[palette],
|
||||
.special => |special| switch (special) {
|
||||
.foreground => self.colors.foreground.get(),
|
||||
.background => self.colors.background.get(),
|
||||
.cursor => self.colors.cursor.get(),
|
||||
else => null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// This is a set of dirty flags the renderer can use to determine
|
||||
/// what parts of the screen need to be redrawn. It is up to the renderer
|
||||
/// to clear these flags.
|
||||
|
||||
@@ -2179,6 +2179,46 @@ test "set write_pty callback" {
|
||||
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
|
||||
}
|
||||
|
||||
test "write_pty receives OSC color query response" {
|
||||
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 S = struct {
|
||||
var last_data: ?[]u8 = null;
|
||||
|
||||
fn deinit() void {
|
||||
if (last_data) |d| testing.allocator.free(d);
|
||||
last_data = null;
|
||||
}
|
||||
|
||||
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
|
||||
if (last_data) |d| testing.allocator.free(d);
|
||||
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
|
||||
}
|
||||
};
|
||||
defer S.deinit();
|
||||
|
||||
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
|
||||
|
||||
const set_fg = "\x1B]10;rgb:01/02/03\x1B\\";
|
||||
vt_write(t, set_fg, set_fg.len);
|
||||
try testing.expect(S.last_data == null);
|
||||
|
||||
const query_fg = "\x1B]10;?\x1B\\";
|
||||
vt_write(t, query_fg, query_fg.len);
|
||||
try testing.expect(S.last_data != null);
|
||||
try testing.expectEqualStrings("\x1B]10;rgb:0101/0202/0303\x1B\\", S.last_data.?);
|
||||
}
|
||||
|
||||
test "set write_pty without callback ignores queries" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -516,6 +516,24 @@ pub const RGB = packed struct(u24) {
|
||||
return self.r == other.r and self.g == other.g and self.b == other.b;
|
||||
}
|
||||
|
||||
pub fn encodeRgb8(self: RGB, writer: *std.Io.Writer) !void {
|
||||
try writer.print(
|
||||
"rgb:{x:0>2}/{x:0>2}/{x:0>2}",
|
||||
.{ self.r, self.g, self.b },
|
||||
);
|
||||
}
|
||||
|
||||
pub fn encodeRgb16(self: RGB, writer: *std.Io.Writer) !void {
|
||||
try writer.print(
|
||||
"rgb:{x:0>4}/{x:0>4}/{x:0>4}",
|
||||
.{
|
||||
@as(u16, self.r) * 257,
|
||||
@as(u16, self.g) * 257,
|
||||
@as(u16, self.b) * 257,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculates the contrast ratio between two colors. The contrast
|
||||
/// ration is a value between 1 and 21 where 1 is the lowest contrast
|
||||
/// and 21 is the highest contrast.
|
||||
@@ -913,6 +931,19 @@ test "RGB.parse" {
|
||||
try testing.expectError(error.InvalidFormat, RGB.parse("nosuchcolor"));
|
||||
}
|
||||
|
||||
test "RGB: encode" {
|
||||
const rgb: RGB = .{ .r = 0x01, .g = 0x23, .b = 0xff };
|
||||
|
||||
var buf: [64]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try rgb.encodeRgb8(&writer);
|
||||
try std.testing.expectEqualStrings("rgb:01/23/ff", writer.buffered());
|
||||
|
||||
writer = .fixed(&buf);
|
||||
try rgb.encodeRgb16(&writer);
|
||||
try std.testing.expectEqualStrings("rgb:0101/2323/ffff", writer.buffered());
|
||||
}
|
||||
|
||||
test "DynamicPalette: init" {
|
||||
const testing = std.testing;
|
||||
|
||||
|
||||
@@ -51,6 +51,23 @@ pub const Kind = union(enum) {
|
||||
return .{ .palette = std.fmt.parseUnsigned(u8, key, 10) catch return null };
|
||||
}
|
||||
|
||||
/// Returns true when a terminal has built-in state for this key.
|
||||
///
|
||||
/// Unsupported special colors may still be valid Kitty protocol keys, but
|
||||
/// libghostty-vt cannot report them because Terminal does not store them.
|
||||
pub fn hasTerminalQueryColor(self: Kind) bool {
|
||||
return switch (self) {
|
||||
.palette => true,
|
||||
.special => |special| switch (special) {
|
||||
.foreground,
|
||||
.background,
|
||||
.cursor,
|
||||
=> true,
|
||||
else => false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn format(
|
||||
self: Kind,
|
||||
writer: *std.Io.Writer,
|
||||
@@ -74,4 +91,8 @@ test "OSC: kitty color protocol kind string" {
|
||||
const actual = try std.fmt.bufPrint(&buf, "{f}", .{Kind{ .palette = 42 }});
|
||||
try testing.expectEqualStrings("42", actual);
|
||||
}
|
||||
|
||||
try testing.expect((Kind{ .palette = 42 }).hasTerminalQueryColor());
|
||||
try testing.expect((Kind{ .special = .foreground }).hasTerminalQueryColor());
|
||||
try testing.expect(!(Kind{ .special = .selection_background }).hasTerminalQueryColor());
|
||||
}
|
||||
|
||||
@@ -405,10 +405,12 @@ pub const Parser = struct {
|
||||
.kitty_color_protocol => |*v| kitty_color_protocol: {
|
||||
v.deinit(self.alloc orelse break :kitty_color_protocol);
|
||||
},
|
||||
.color_operation => |*v| color_operation: {
|
||||
v.requests.deinit(self.alloc orelse break :color_operation);
|
||||
},
|
||||
.change_window_icon,
|
||||
.change_window_title,
|
||||
.clipboard_contents,
|
||||
.color_operation,
|
||||
.conemu_change_tab_title,
|
||||
.conemu_comment,
|
||||
.conemu_guimacro,
|
||||
|
||||
@@ -8,7 +8,9 @@ const device_status = @import("device_status.zig");
|
||||
const stream = @import("stream.zig");
|
||||
const Action = stream.Action;
|
||||
const Screen = @import("Screen.zig");
|
||||
const color = @import("color.zig");
|
||||
const modes = @import("modes.zig");
|
||||
const osc = @import("osc.zig");
|
||||
const osc_color = @import("osc/parsers/color.zig");
|
||||
const kitty_color = @import("kitty/color.zig");
|
||||
const size_report = @import("size_report.zig");
|
||||
@@ -257,7 +259,7 @@ pub const Handler = struct {
|
||||
.end_hyperlink => self.terminal.screens.active.endHyperlink(),
|
||||
.semantic_prompt => try self.terminal.semanticPrompt(value),
|
||||
.mouse_shape => self.terminal.mouse_shape = value,
|
||||
.color_operation => try self.colorOperation(value.op, &value.requests),
|
||||
.color_operation => try self.colorOperation(&value.requests, value.terminator),
|
||||
.kitty_color_report => try self.kittyColorOperation(value),
|
||||
|
||||
// APC
|
||||
@@ -598,12 +600,17 @@ pub const Handler = struct {
|
||||
|
||||
fn colorOperation(
|
||||
self: *Handler,
|
||||
op: osc_color.Operation,
|
||||
requests: *const osc_color.List,
|
||||
terminator: osc.Terminator,
|
||||
) !void {
|
||||
_ = op;
|
||||
if (requests.count() == 0) return;
|
||||
|
||||
var stack = std.heap.stackFallback(1024, self.terminal.gpa());
|
||||
const alloc = stack.get();
|
||||
var response: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer response.deinit();
|
||||
const writer = &response.writer;
|
||||
|
||||
var it = requests.constIterator(0);
|
||||
while (it.next()) |req| {
|
||||
switch (req.*) {
|
||||
@@ -661,17 +668,67 @@ pub const Handler = struct {
|
||||
mask.* = .initEmpty();
|
||||
},
|
||||
|
||||
.query,
|
||||
.reset_special,
|
||||
=> {},
|
||||
.query => |target| {
|
||||
if (self.effects.write_pty == null) continue;
|
||||
const c = self.terminal.colorForXterm(target) orelse continue;
|
||||
try writeXtermColorReport(writer, target, c, terminator);
|
||||
},
|
||||
|
||||
.reset_special => {},
|
||||
}
|
||||
}
|
||||
|
||||
if (response.written().len > 0) {
|
||||
const resp = try response.toOwnedSliceSentinel(0);
|
||||
defer alloc.free(resp);
|
||||
self.writePty(resp);
|
||||
}
|
||||
}
|
||||
|
||||
fn writeXtermColorReport(
|
||||
writer: *std.Io.Writer,
|
||||
target: osc_color.Target,
|
||||
c: color.RGB,
|
||||
terminator: osc.Terminator,
|
||||
) !void {
|
||||
switch (target) {
|
||||
.palette => |i| {
|
||||
try writer.print("\x1b]4;{d};", .{i});
|
||||
try c.encodeRgb16(writer);
|
||||
try writer.writeAll(terminator.string());
|
||||
},
|
||||
.dynamic => |dynamic| switch (dynamic) {
|
||||
.foreground,
|
||||
.background,
|
||||
.cursor,
|
||||
=> {
|
||||
try writer.print("\x1b]{d};", .{@intFromEnum(dynamic)});
|
||||
try c.encodeRgb16(writer);
|
||||
try writer.writeAll(terminator.string());
|
||||
},
|
||||
.pointer_foreground,
|
||||
.pointer_background,
|
||||
.tektronix_foreground,
|
||||
.tektronix_background,
|
||||
.highlight_background,
|
||||
.tektronix_cursor,
|
||||
.highlight_foreground,
|
||||
=> {},
|
||||
},
|
||||
.special => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn kittyColorOperation(
|
||||
self: *Handler,
|
||||
request: kitty_color.OSC,
|
||||
) !void {
|
||||
var stack = std.heap.stackFallback(1024, self.terminal.gpa());
|
||||
const alloc = stack.get();
|
||||
var response: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer response.deinit();
|
||||
const writer = &response.writer;
|
||||
|
||||
for (request.list.items) |item| {
|
||||
switch (item) {
|
||||
.set => |v| switch (v.key) {
|
||||
@@ -698,9 +755,28 @@ pub const Handler = struct {
|
||||
else => {},
|
||||
},
|
||||
},
|
||||
.query => {},
|
||||
.query => |key| {
|
||||
if (self.effects.write_pty == null) continue;
|
||||
const c = self.terminal.colorForKitty(key) orelse {
|
||||
if (!key.hasTerminalQueryColor()) continue;
|
||||
if (response.written().len == 0) try writer.writeAll("\x1b]21");
|
||||
try writer.print(";{f}=", .{key});
|
||||
continue;
|
||||
};
|
||||
|
||||
if (response.written().len == 0) try writer.writeAll("\x1b]21");
|
||||
try writer.print(";{f}=", .{key});
|
||||
try c.encodeRgb8(writer);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (response.written().len > 0) {
|
||||
try writer.writeAll(request.terminator.string());
|
||||
const resp = try response.toOwnedSliceSentinel(0);
|
||||
defer alloc.free(resp);
|
||||
self.writePty(resp);
|
||||
}
|
||||
}
|
||||
|
||||
fn apcEnd(self: *Handler) void {
|
||||
@@ -1021,6 +1097,8 @@ test "ignores query actions" {
|
||||
s.nextSlice("\x1B[c"); // Device attributes
|
||||
s.nextSlice("\x1B[5n"); // Device status report
|
||||
s.nextSlice("\x1B[6n"); // Cursor position report
|
||||
s.nextSlice("\x1B]4;0;?\x1B\\"); // OSC color query
|
||||
s.nextSlice("\x1B]21;foreground=?\x1B\\"); // Kitty color query
|
||||
|
||||
// Terminal should still be functional
|
||||
s.nextSlice("Test");
|
||||
@@ -1137,6 +1215,63 @@ test "OSC 12 set and reset cursor color" {
|
||||
// After reset, cursor might be null (using default)
|
||||
}
|
||||
|
||||
test "OSC color query responses" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
var last_response: ?[:0]const u8 = null;
|
||||
|
||||
fn reset() void {
|
||||
if (last_response) |old| testing.allocator.free(old);
|
||||
last_response = null;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
reset();
|
||||
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
|
||||
}
|
||||
};
|
||||
S.last_response = null;
|
||||
defer S.reset();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, handler);
|
||||
defer s.deinit();
|
||||
|
||||
s.nextSlice("\x1b]10;?\x1b\\");
|
||||
try testing.expect(S.last_response == null);
|
||||
|
||||
s.nextSlice("\x1b]11;?\x1b\\");
|
||||
try testing.expect(S.last_response == null);
|
||||
|
||||
s.nextSlice("\x1b]4;2;rgb:12/34/56;2;?\x1b\\");
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]4;2;rgb:1212/3434/5656\x1b\\",
|
||||
S.last_response.?,
|
||||
);
|
||||
|
||||
s.nextSlice("\x1b]10;rgb:01/02/03\x1b\\");
|
||||
s.nextSlice("\x1b]11;rgb:04/05/06\x1b\\");
|
||||
s.nextSlice("\x1b]12;rgb:07/08/09\x1b\\");
|
||||
s.nextSlice("\x1b]10;?;?;?\x1b\\");
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]10;rgb:0101/0202/0303\x1b\\" ++
|
||||
"\x1b]11;rgb:0404/0505/0606\x1b\\" ++
|
||||
"\x1b]12;rgb:0707/0808/0909\x1b\\",
|
||||
S.last_response.?,
|
||||
);
|
||||
|
||||
s.nextSlice("\x1b]112\x1b\\");
|
||||
s.nextSlice("\x1b]12;?\x07");
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]12;rgb:0101/0202/0303\x07",
|
||||
S.last_response.?,
|
||||
);
|
||||
}
|
||||
|
||||
test "kitty color protocol set palette" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
|
||||
defer t.deinit(testing.allocator);
|
||||
@@ -1231,6 +1366,46 @@ test "kitty color protocol reset foreground" {
|
||||
try testing.expect(t.colors.foreground.get() == null);
|
||||
}
|
||||
|
||||
test "kitty color protocol query responses" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
var last_response: ?[:0]const u8 = null;
|
||||
|
||||
fn reset() void {
|
||||
if (last_response) |old| testing.allocator.free(old);
|
||||
last_response = null;
|
||||
}
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
reset();
|
||||
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
|
||||
}
|
||||
};
|
||||
S.last_response = null;
|
||||
defer S.reset();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, handler);
|
||||
defer s.deinit();
|
||||
|
||||
s.nextSlice("\x1b]21;background=?\x1b\\");
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]21;background=\x1b\\",
|
||||
S.last_response.?,
|
||||
);
|
||||
|
||||
s.nextSlice("\x1b]21;foreground=rgb:12/34/56;2=rgb:aa/bb/cc\x1b\\");
|
||||
s.nextSlice("\x1b]21;foreground=?;background=?;2=?\x1b\\");
|
||||
try testing.expectEqualStrings(
|
||||
"\x1b]21;foreground=rgb:12/34/56;background=;2=rgb:aa/bb/cc\x1b\\",
|
||||
S.last_response.?,
|
||||
);
|
||||
}
|
||||
|
||||
test "palette dirty flag set on color change" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
Reference in New Issue
Block a user