From b11b8be12463f39c3f51d69936db8b911a694839 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 17 Aug 2024 06:55:51 -0500 Subject: [PATCH] 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). --- src/inspector/termio.zig | 5 + src/terminal/color.zig | 36 +++++-- src/terminal/osc.zig | 172 ++++++++++++++++++++++++++++++++++ src/terminal/stream.zig | 7 ++ src/termio/stream_handler.zig | 93 ++++++++++++++++++ 5 files changed, 304 insertions(+), 9 deletions(-) diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 1b9a45581..bae07bcfe 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -259,6 +259,11 @@ pub const VTEvent = struct { } }, + .Struct => try md.put( + key, + try alloc.dupeZ(u8, @typeName(Value)), + ), + else => switch (Value) { u8 => try md.put( key, diff --git a/src/terminal/color.zig b/src/terminal/color.zig index ed6d0be3d..c8929b060 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -208,7 +208,7 @@ pub const RGB = struct { /// where , , and are floating point values between /// 0.0 and 1.0 (inclusive). /// - /// 3. #hhhhhh + /// 3. #hhh, #hhhhhh, #hhhhhhhhh #hhhhhhhhhhhh /// /// where `h` is a single hexadecimal digit. pub fn parse(value: []const u8) !RGB { @@ -217,15 +217,30 @@ pub const RGB = struct { } if (value[0] == '#') { - if (value.len != 7) { - return error.InvalidFormat; - } + switch (value.len) { + 4 => return RGB{ + .r = try RGB.fromHex(value[1..2]), + .g = try RGB.fromHex(value[2..3]), + .b = try RGB.fromHex(value[3..4]), + }, + 7 => return RGB{ + .r = try RGB.fromHex(value[1..3]), + .g = try RGB.fromHex(value[3..5]), + .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]), + }, - return RGB{ - .r = try RGB.fromHex(value[1..3]), - .g = try RGB.fromHex(value[3..5]), - .b = try RGB.fromHex(value[5..7]), - }; + else => return error.InvalidFormat, + } } // 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 = 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("#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 = 0, .g = 0, .b = 0 }, try RGB.parse("black")); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 5aedaa6e0..326766f6a 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -9,6 +9,7 @@ const std = @import("std"); const mem = std.mem; const assert = std.debug.assert; const Allocator = mem.Allocator; +const RGB = @import("color.zig").RGB; const log = std.log.scoped(.osc); @@ -137,6 +138,10 @@ pub const Command = union(enum) { 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_desktop_notification: struct { 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 @@ -251,6 +284,7 @@ pub const Parser = struct { @"13", @"133", @"2", + @"21", @"22", @"4", @"5", @@ -310,6 +344,11 @@ pub const Parser = struct { // If the parser has no allocator then it is treated as if the // buffer is full. 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. @@ -323,6 +362,9 @@ pub const Parser = struct { self.buf_start = 0; self.buf_idx = 0; self.complete = false; + if (self.command == .kitty_color_protocol) { + self.command.kitty_color_protocol.list.deinit(); + } if (self.buf_dynamic) |ptr| { const alloc = self.alloc.?; ptr.deinit(alloc); @@ -439,6 +481,7 @@ pub const Parser = struct { }, .@"2" => switch (c) { + '1' => self.state = .@"21", '2' => self.state = .@"22", ';' => { self.command = .{ .change_window_title = undefined }; @@ -450,6 +493,45 @@ pub const Parser = struct { 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) { ';' => { 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]; } + 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 { const list = self.buf_dynamic.?; self.temp_state.str.* = list.items; @@ -958,11 +1090,14 @@ pub const Parser = struct { .hyperlink_uri => self.endHyperlink(), .string => self.endString(), .allocable_string => self.endAllocableString(), + .kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true), + .kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true), else => {}, } switch (self.command) { .report_color => |*c| c.terminator = Terminator.init(terminator_ch), + .kitty_color_protocol => |*c| c.terminator = Terminator.init(terminator_ch), else => {}, } @@ -1497,3 +1632,40 @@ test "OSC: hyperlink end" { const cmd = p.end('\x1b').?; 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); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 6adfa3280..7ade11963 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1393,6 +1393,13 @@ pub fn Stream(comptime Handler: type) type { } 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| { if (@hasDecl(T, "showDesktopNotification")) { try self.handler.showDesktopNotification(v.title, v.body); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 648efb6fb..b3f31e4b0 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1269,4 +1269,97 @@ pub const StreamHandler = struct { .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); + } };