Implement Kitty Color Protocol (OSC 21)

Kitty 0.36.0 added support for a new OSC escape sequence for
quering, setting, and resetting the terminal colors. Details
can be found [here](https://sw.kovidgoyal.net/kitty/color-stack/#setting-and-querying-colors).

This fully parses the OSC 21 escape sequences, but only supports
actually querying and changing the foreground color, the background
color, and the cursor color because that's what Ghostty currently
supports. Adding support for the other settings that Kitty supports
changing ranges from easy (cursor text) to difficult (visual bell,
second transparent background color).
This commit is contained in:
Jeffrey C. Ollie
2024-08-17 06:55:51 -05:00
parent dd9e1d9fa7
commit b11b8be124
5 changed files with 304 additions and 9 deletions

View File

@ -259,6 +259,11 @@ pub const VTEvent = struct {
} }
}, },
.Struct => try md.put(
key,
try alloc.dupeZ(u8, @typeName(Value)),
),
else => switch (Value) { else => switch (Value) {
u8 => try md.put( u8 => try md.put(
key, key,

View File

@ -208,7 +208,7 @@ pub const RGB = struct {
/// where <red>, <green>, and <blue> are floating point values between /// where <red>, <green>, and <blue> are floating point values between
/// 0.0 and 1.0 (inclusive). /// 0.0 and 1.0 (inclusive).
/// ///
/// 3. #hhhhhh /// 3. #hhh, #hhhhhh, #hhhhhhhhh #hhhhhhhhhhhh
/// ///
/// where `h` is a single hexadecimal digit. /// where `h` is a single hexadecimal digit.
pub fn parse(value: []const u8) !RGB { pub fn parse(value: []const u8) !RGB {
@ -217,15 +217,30 @@ pub const RGB = struct {
} }
if (value[0] == '#') { if (value[0] == '#') {
if (value.len != 7) { switch (value.len) {
return error.InvalidFormat; 4 => return RGB{
} .r = try RGB.fromHex(value[1..2]),
.g = try RGB.fromHex(value[2..3]),
return RGB{ .b = try RGB.fromHex(value[3..4]),
},
7 => return RGB{
.r = try RGB.fromHex(value[1..3]), .r = try RGB.fromHex(value[1..3]),
.g = try RGB.fromHex(value[3..5]), .g = try RGB.fromHex(value[3..5]),
.b = try RGB.fromHex(value[5..7]), .b = try RGB.fromHex(value[5..7]),
}; },
10 => return RGB{
.r = try RGB.fromHex(value[1..4]),
.g = try RGB.fromHex(value[4..7]),
.b = try RGB.fromHex(value[7..10]),
},
13 => return RGB{
.r = try RGB.fromHex(value[1..5]),
.g = try RGB.fromHex(value[5..9]),
.b = try RGB.fromHex(value[9..13]),
},
else => return error.InvalidFormat,
}
} }
// Check for X11 named colors. We allow whitespace around the edges // Check for X11 named colors. We allow whitespace around the edges
@ -308,6 +323,9 @@ test "RGB.parse" {
try testing.expectEqual(RGB{ .r = 127, .g = 160, .b = 0 }, try RGB.parse("rgb:7f/a0a0/0")); try testing.expectEqual(RGB{ .r = 127, .g = 160, .b = 0 }, try RGB.parse("rgb:7f/a0a0/0"));
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("rgb:f/ff/fff")); try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("rgb:f/ff/fff"));
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffff")); try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffff"));
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#fff"));
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#fffffffff"));
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffffffffff"));
try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 16 }, try RGB.parse("#ff0010")); try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 16 }, try RGB.parse("#ff0010"));
try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, try RGB.parse("black")); try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, try RGB.parse("black"));

View File

@ -9,6 +9,7 @@ const std = @import("std");
const mem = std.mem; const mem = std.mem;
const assert = std.debug.assert; const assert = std.debug.assert;
const Allocator = mem.Allocator; const Allocator = mem.Allocator;
const RGB = @import("color.zig").RGB;
const log = std.log.scoped(.osc); const log = std.log.scoped(.osc);
@ -137,6 +138,10 @@ pub const Command = union(enum) {
value: []const u8, value: []const u8,
}, },
/// Kitty color protocl, OSC 21
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
kitty_color_protocol: KittyColorProtocol,
/// Show a desktop notification (OSC 9 or OSC 777) /// Show a desktop notification (OSC 9 or OSC 777)
show_desktop_notification: struct { show_desktop_notification: struct {
title: []const u8, title: []const u8,
@ -167,6 +172,34 @@ pub const Command = union(enum) {
}; };
} }
}; };
pub const KittyColorProtocol = struct {
const Kind = enum {
foreground,
background,
selection_foreground,
selection_background,
cursor,
cursor_text,
visual_bell,
second_transparent_background,
};
const Request = union(enum) {
query: Kind,
set: struct {
key: Kind,
color: RGB,
},
reset: Kind,
};
/// list of requests
list: std.ArrayList(Request),
/// We must reply with the same string terminator (ST) as used in the
/// request.
terminator: Terminator = .st,
};
}; };
/// The terminator used to end an OSC command. For OSC commands that demand /// The terminator used to end an OSC command. For OSC commands that demand
@ -251,6 +284,7 @@ pub const Parser = struct {
@"13", @"13",
@"133", @"133",
@"2", @"2",
@"21",
@"22", @"22",
@"4", @"4",
@"5", @"5",
@ -310,6 +344,11 @@ pub const Parser = struct {
// If the parser has no allocator then it is treated as if the // If the parser has no allocator then it is treated as if the
// buffer is full. // buffer is full.
allocable_string, allocable_string,
// Kitty color protocol
// https://sw.kovidgoyal.net/kitty/color-stack/#id1
kitty_color_protocol_key,
kitty_color_protocol_value,
}; };
/// This must be called to clean up any allocated memory. /// This must be called to clean up any allocated memory.
@ -323,6 +362,9 @@ pub const Parser = struct {
self.buf_start = 0; self.buf_start = 0;
self.buf_idx = 0; self.buf_idx = 0;
self.complete = false; self.complete = false;
if (self.command == .kitty_color_protocol) {
self.command.kitty_color_protocol.list.deinit();
}
if (self.buf_dynamic) |ptr| { if (self.buf_dynamic) |ptr| {
const alloc = self.alloc.?; const alloc = self.alloc.?;
ptr.deinit(alloc); ptr.deinit(alloc);
@ -439,6 +481,7 @@ pub const Parser = struct {
}, },
.@"2" => switch (c) { .@"2" => switch (c) {
'1' => self.state = .@"21",
'2' => self.state = .@"22", '2' => self.state = .@"22",
';' => { ';' => {
self.command = .{ .change_window_title = undefined }; self.command = .{ .change_window_title = undefined };
@ -450,6 +493,45 @@ pub const Parser = struct {
else => self.state = .invalid, else => self.state = .invalid,
}, },
.@"21" => switch (c) {
';' => {
self.command = .{
.kitty_color_protocol = .{
.list = std.ArrayList(Command.KittyColorProtocol.Request).init(self.alloc.?),
},
};
self.state = .kitty_color_protocol_key;
self.complete = true;
self.buf_start = self.buf_idx;
},
else => self.state = .invalid,
},
.kitty_color_protocol_key => switch (c) {
';' => {
self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] };
self.endKittyColorProtocolOption(.key_only, false);
self.state = .kitty_color_protocol_key;
self.buf_start = self.buf_idx;
},
'=' => {
self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] };
self.state = .kitty_color_protocol_value;
self.buf_start = self.buf_idx;
},
else => {},
},
.kitty_color_protocol_value => switch (c) {
';' => {
self.endKittyColorProtocolOption(.key_and_value, false);
self.state = .kitty_color_protocol_key;
self.buf_start = self.buf_idx;
},
else => {},
},
.@"22" => switch (c) { .@"22" => switch (c) {
';' => { ';' => {
self.command = .{ .mouse_shape = undefined }; self.command = .{ .mouse_shape = undefined };
@ -936,6 +1018,56 @@ pub const Parser = struct {
self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx];
} }
fn endKittyColorProtocolOption(self: *Parser, kind: enum { key_only, key_and_value }, final: bool) void {
if (self.temp_state.key.len == 0) {
log.warn("zero length key in kitty color protocol", .{});
return;
}
const key = std.meta.stringToEnum(Command.KittyColorProtocol.Kind, self.temp_state.key) orelse {
log.warn("unknown key in kitty color protocol: {s}", .{self.temp_state.key});
return;
};
const value = value: {
if (self.buf_start == self.buf_idx) break :value "";
if (final) break :value std.mem.trim(u8, self.buf[self.buf_start..self.buf_idx], " ");
break :value std.mem.trim(u8, self.buf[self.buf_start .. self.buf_idx - 1], " ");
};
switch (self.command) {
.kitty_color_protocol => |*v| {
if (kind == .key_only) {
v.list.append(.{ .reset = key }) catch unreachable;
return;
}
if (value.len == 0) {
v.list.append(.{ .reset = key }) catch unreachable;
return;
}
if (mem.eql(u8, "?", value)) {
v.list.append(.{ .query = key }) catch unreachable;
return;
}
v.list.append(
.{
.set = .{
.key = key,
.color = RGB.parse(value) catch |err| switch (err) {
error.InvalidFormat => {
log.err("invalid color format in kitty color protocol: {s}", .{value});
return;
},
},
},
},
) catch unreachable;
return;
},
else => {},
}
}
fn endAllocableString(self: *Parser) void { fn endAllocableString(self: *Parser) void {
const list = self.buf_dynamic.?; const list = self.buf_dynamic.?;
self.temp_state.str.* = list.items; self.temp_state.str.* = list.items;
@ -958,11 +1090,14 @@ pub const Parser = struct {
.hyperlink_uri => self.endHyperlink(), .hyperlink_uri => self.endHyperlink(),
.string => self.endString(), .string => self.endString(),
.allocable_string => self.endAllocableString(), .allocable_string => self.endAllocableString(),
.kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true),
.kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true),
else => {}, else => {},
} }
switch (self.command) { switch (self.command) {
.report_color => |*c| c.terminator = Terminator.init(terminator_ch), .report_color => |*c| c.terminator = Terminator.init(terminator_ch),
.kitty_color_protocol => |*c| c.terminator = Terminator.init(terminator_ch),
else => {}, else => {},
} }
@ -1497,3 +1632,40 @@ test "OSC: hyperlink end" {
const cmd = p.end('\x1b').?; const cmd = p.end('\x1b').?;
try testing.expect(cmd == .hyperlink_end); try testing.expect(cmd == .hyperlink_end);
} }
test "OSC: kitty color protocol" {
const testing = std.testing;
var p: Parser = .{ .alloc = std.testing.allocator };
defer p.deinit();
const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .kitty_color_protocol);
try testing.expectEqual(@as(usize, 7), cmd.kitty_color_protocol.list.items.len);
try testing.expect(cmd.kitty_color_protocol.list.items[0] == .query);
try testing.expectEqual(@as(Command.KittyColorProtocol.Kind, .foreground), cmd.kitty_color_protocol.list.items[0].query);
try testing.expect(cmd.kitty_color_protocol.list.items[1] == .set);
try testing.expectEqual(@as(Command.KittyColorProtocol.Kind, .background), cmd.kitty_color_protocol.list.items[1].set.key);
try testing.expectEqual(@as(u8, 0xf0), cmd.kitty_color_protocol.list.items[1].set.color.r);
try testing.expectEqual(@as(u8, 0xf8), cmd.kitty_color_protocol.list.items[1].set.color.g);
try testing.expectEqual(@as(u8, 0xff), cmd.kitty_color_protocol.list.items[1].set.color.b);
try testing.expect(cmd.kitty_color_protocol.list.items[2] == .set);
try testing.expectEqual(@as(Command.KittyColorProtocol.Kind, .cursor), cmd.kitty_color_protocol.list.items[2].set.key);
try testing.expectEqual(@as(u8, 0xf0), cmd.kitty_color_protocol.list.items[2].set.color.r);
try testing.expectEqual(@as(u8, 0xf8), cmd.kitty_color_protocol.list.items[2].set.color.g);
try testing.expectEqual(@as(u8, 0xff), cmd.kitty_color_protocol.list.items[2].set.color.b);
try testing.expect(cmd.kitty_color_protocol.list.items[3] == .reset);
try testing.expectEqual(@as(Command.KittyColorProtocol.Kind, .cursor_text), cmd.kitty_color_protocol.list.items[3].reset);
try testing.expect(cmd.kitty_color_protocol.list.items[4] == .reset);
try testing.expectEqual(@as(Command.KittyColorProtocol.Kind, .visual_bell), cmd.kitty_color_protocol.list.items[4].reset);
try testing.expect(cmd.kitty_color_protocol.list.items[5] == .query);
try testing.expectEqual(@as(Command.KittyColorProtocol.Kind, .selection_background), cmd.kitty_color_protocol.list.items[5].query);
try testing.expect(cmd.kitty_color_protocol.list.items[6] == .set);
try testing.expectEqual(@as(Command.KittyColorProtocol.Kind, .selection_background), cmd.kitty_color_protocol.list.items[6].set.key);
try testing.expectEqual(@as(u8, 0xaa), cmd.kitty_color_protocol.list.items[6].set.color.r);
try testing.expectEqual(@as(u8, 0xbb), cmd.kitty_color_protocol.list.items[6].set.color.g);
try testing.expectEqual(@as(u8, 0xcc), cmd.kitty_color_protocol.list.items[6].set.color.b);
}

View File

@ -1393,6 +1393,13 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented OSC callback: {}", .{cmd}); } else log.warn("unimplemented OSC callback: {}", .{cmd});
}, },
.kitty_color_protocol => |v| {
if (@hasDecl(T, "sendKittyColorReport")) {
try self.handler.sendKittyColorReport(v);
return;
} else log.warn("unimplemented OSC callback: {}", .{cmd});
},
.show_desktop_notification => |v| { .show_desktop_notification => |v| {
if (@hasDecl(T, "showDesktopNotification")) { if (@hasDecl(T, "showDesktopNotification")) {
try self.handler.showDesktopNotification(v.title, v.body); try self.handler.showDesktopNotification(v.title, v.body);

View File

@ -1269,4 +1269,97 @@ pub const StreamHandler = struct {
.csi_21_t => self.surfaceMessageWriter(.{ .report_title = .csi_21_t }), .csi_21_t => self.surfaceMessageWriter(.{ .report_title = .csi_21_t }),
} }
} }
pub fn sendKittyColorReport(self: *StreamHandler, request: terminal.osc.Command.KittyColorProtocol) !void {
var buf = std.ArrayList(u8).init(self.alloc);
errdefer buf.deinit();
const writer = buf.writer();
try writer.writeAll("\x1b[21");
for (request.list.items) |item| {
switch (item) {
.query => |key| {
const color = switch (key) {
.foreground => self.foreground_color,
.background => self.background_color,
.cursor => self.cursor_color,
else => {
log.warn("ignoring unsupported kitty color protocol key: {s}", .{@tagName(key)});
continue;
},
} orelse {
log.warn("no color configured for: {s}", .{@tagName(key)});
continue;
};
try writer.print(
";rgb:{x:0>2}/{x:0>2}/{x:0>2}",
.{
@as(u16, color.r),
@as(u16, color.g),
@as(u16, color.b),
},
);
},
.set => |v| switch (v.key) {
.foreground => {
self.foreground_color = v.color;
_ = self.renderer_mailbox.push(.{
.foreground_color = v.color,
}, .{ .forever = {} });
},
.background => {
self.background_color = v.color;
_ = self.renderer_mailbox.push(.{
.background_color = v.color,
}, .{ .forever = {} });
},
.cursor => {
self.cursor_color = v.color;
_ = self.renderer_mailbox.push(.{
.cursor_color = v.color,
}, .{ .forever = {} });
},
else => {
log.warn("ignoring unsupported kitty color protocol key: {s}", .{@tagName(v.key)});
continue;
},
},
.reset => |key| switch (key) {
.foreground => {
self.foreground_color = self.default_foreground_color;
_ = self.renderer_mailbox.push(.{
.foreground_color = self.foreground_color,
}, .{ .forever = {} });
},
.background => {
self.background_color = self.default_background_color;
_ = self.renderer_mailbox.push(.{
.background_color = self.background_color,
}, .{ .forever = {} });
},
.cursor => {
self.cursor_color = self.default_cursor_color;
_ = self.renderer_mailbox.push(.{
.cursor_color = self.cursor_color,
}, .{ .forever = {} });
},
else => {
log.warn("ignoring unsupported kitty color protocol key: {s}", .{@tagName(key)});
continue;
},
},
}
}
try writer.writeAll(request.terminator.string());
const msg = termio.Message{
.write_alloc = .{
.alloc = self.alloc,
.data = try buf.toOwnedSlice(),
},
};
self.messageWriter(msg);
}
}; };