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); + } };