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..46aa2aaae 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -208,24 +208,41 @@ pub const RGB = struct { /// where , , and are floating point values between /// 0.0 and 1.0 (inclusive). /// - /// 3. #hhhhhh + /// 3. #rgb, #rrggbb, #rrrgggbbb #rrrrggggbbbb /// - /// where `h` is a single hexadecimal digit. + /// where `r`, `g`, and `b` are a single hexadecimal digit. + /// These specifiy a color with 4, 8, 12, and 16 bits of precision + /// per color channel. pub fn parse(value: []const u8) !RGB { if (value.len == 0) { return error.InvalidFormat; } 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 +325,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/kitty.zig b/src/terminal/kitty.zig index 6492aca6f..482919f9f 100644 --- a/src/terminal/kitty.zig +++ b/src/terminal/kitty.zig @@ -1,6 +1,7 @@ //! Types and functions related to Kitty protocols. const key = @import("kitty/key.zig"); +pub const color = @import("kitty/color.zig"); pub const graphics = @import("kitty/graphics.zig"); pub const KeyFlags = key.Flags; diff --git a/src/terminal/kitty/color.zig b/src/terminal/kitty/color.zig new file mode 100644 index 000000000..97acbcaff --- /dev/null +++ b/src/terminal/kitty/color.zig @@ -0,0 +1,92 @@ +const std = @import("std"); +const terminal = @import("../main.zig"); +const RGB = terminal.color.RGB; +const Terminator = terminal.osc.Terminator; + +pub const OSC = struct { + pub 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, +}; + +pub const Kind = enum(u9) { + // Make sure that this stays in sync with the higest numbered enum + // value. + pub const max: u9 = 263; + + // These _must_ start at 256 since enum values 0-255 are reserved + // for the palette. + foreground = 256, + background = 257, + selection_foreground = 258, + selection_background = 259, + cursor = 260, + cursor_text = 261, + visual_bell = 262, + second_transparent_background = 263, + _, + + /// Return the palette index that this kind is representing + /// or null if its a special color. + pub fn palette(self: Kind) ?u8 { + return std.math.cast(u8, @intFromEnum(self)) orelse null; + } + + pub fn format( + self: Kind, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + _ = opts; + + // Format as a number if its a palette color otherwise + // format as a string. + if (self.palette()) |idx| { + try writer.print("{}", .{idx}); + } else { + try writer.print("{s}", .{@tagName(self)}); + } + } +}; + +test "OSC: kitty color protocol kind" { + const info = @typeInfo(Kind); + + try std.testing.expectEqual(false, info.Enum.is_exhaustive); + + var min: usize = std.math.maxInt(info.Enum.tag_type); + var max: usize = 0; + + inline for (info.Enum.fields) |field| { + if (field.value > max) max = field.value; + if (field.value < min) min = field.value; + } + + try std.testing.expect(min >= 256); + try std.testing.expect(max == Kind.max); +} + +test "OSC: kitty color protocol kind string" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + { + const actual = try std.fmt.bufPrint(&buf, "{}", .{Kind.foreground}); + try testing.expectEqualStrings("foreground", actual); + } + { + const actual = try std.fmt.bufPrint(&buf, "{}", .{@as(Kind, @enumFromInt(42))}); + try testing.expectEqualStrings("42", actual); + } +} diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 5aedaa6e0..17b52ab13 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -9,6 +9,8 @@ const std = @import("std"); const mem = std.mem; const assert = std.debug.assert; const Allocator = mem.Allocator; +const RGB = @import("color.zig").RGB; +const kitty = @import("kitty.zig"); const log = std.log.scoped(.osc); @@ -137,6 +139,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: kitty.color.OSC, + /// Show a desktop notification (OSC 9 or OSC 777) show_desktop_notification: struct { title: []const u8, @@ -251,6 +257,7 @@ pub const Parser = struct { @"13", @"133", @"2", + @"21", @"22", @"4", @"5", @@ -310,6 +317,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. @@ -329,6 +341,12 @@ pub const Parser = struct { alloc.destroy(ptr); self.buf_dynamic = null; } + + // Some commands have their own memory management we need to clear. + switch (self.command) { + .kitty_color_protocol => |*v| v.list.deinit(), + else => {}, + } } /// Consume the next character c and advance the parser state. @@ -439,6 +457,7 @@ pub const Parser = struct { }, .@"2" => switch (c) { + '1' => self.state = .@"21", '2' => self.state = .@"22", ';' => { self.command = .{ .change_window_title = undefined }; @@ -450,6 +469,51 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"21" => switch (c) { + ';' => kitty: { + const alloc = self.alloc orelse { + log.info("OSC 21 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :kitty; + }; + + self.command = .{ + .kitty_color_protocol = .{ + .list = std.ArrayList(kitty.color.OSC.Request).init(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 +1000,72 @@ 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; + } + + // For our key, we first try to parse it as a special key. If that + // doesn't work then we try to parse it as a number for a palette. + const key: kitty.color.Kind = std.meta.stringToEnum( + kitty.color.Kind, + self.temp_state.key, + ) orelse @enumFromInt(std.fmt.parseUnsigned( + u8, + self.temp_state.key, + 10, + ) catch { + 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| { + // Cap our allocation amount for our list. + if (v.list.items.len >= @as(usize, kitty.color.Kind.max) * 2) { + self.state = .invalid; + log.warn("exceeded limit for number of keys in kitty color protocol, ignoring", .{}); + return; + } + + if (kind == .key_only or value.len == 0) { + v.list.append(.{ .reset = key }) catch |err| { + log.warn("unable to append kitty color protocol option: {}", .{err}); + return; + }; + } else if (mem.eql(u8, "?", value)) { + v.list.append(.{ .query = key }) catch |err| { + log.warn("unable to append kitty color protocol option: {}", .{err}); + return; + }; + } else { + v.list.append(.{ + .set = .{ + .key = key, + .color = RGB.parse(value) catch |err| switch (err) { + error.InvalidFormat => { + log.warn("invalid color format in kitty color protocol: {s}", .{value}); + return; + }, + }, + }, + }) catch |err| { + log.warn("unable to append kitty color protocol option: {}", .{err}); + return; + }; + } + }, + else => {}, + } + } + fn endAllocableString(self: *Parser) void { const list = self.buf_dynamic.?; self.temp_state.str.* = list.items; @@ -958,11 +1088,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 +1630,85 @@ 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 = 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;2=?;3=rgbi:1.0/1.0/1.0"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .kitty_color_protocol); + try testing.expectEqual(@as(usize, 9), cmd.kitty_color_protocol.list.items.len); + { + const item = cmd.kitty_color_protocol.list.items[0]; + try testing.expect(item == .query); + try testing.expectEqual(kitty.color.Kind.foreground, item.query); + } + { + const item = cmd.kitty_color_protocol.list.items[1]; + try testing.expect(item == .set); + try testing.expectEqual(kitty.color.Kind.background, item.set.key); + try testing.expectEqual(@as(u8, 0xf0), item.set.color.r); + try testing.expectEqual(@as(u8, 0xf8), item.set.color.g); + try testing.expectEqual(@as(u8, 0xff), item.set.color.b); + } + { + const item = cmd.kitty_color_protocol.list.items[2]; + try testing.expect(item == .set); + try testing.expectEqual(kitty.color.Kind.cursor, item.set.key); + try testing.expectEqual(@as(u8, 0xf0), item.set.color.r); + try testing.expectEqual(@as(u8, 0xf8), item.set.color.g); + try testing.expectEqual(@as(u8, 0xff), item.set.color.b); + } + { + const item = cmd.kitty_color_protocol.list.items[3]; + try testing.expect(item == .reset); + try testing.expectEqual(kitty.color.Kind.cursor_text, item.reset); + } + { + const item = cmd.kitty_color_protocol.list.items[4]; + try testing.expect(item == .reset); + try testing.expectEqual(kitty.color.Kind.visual_bell, item.reset); + } + { + const item = cmd.kitty_color_protocol.list.items[5]; + try testing.expect(item == .query); + try testing.expectEqual(kitty.color.Kind.selection_background, item.query); + } + { + const item = cmd.kitty_color_protocol.list.items[6]; + try testing.expect(item == .set); + try testing.expectEqual(kitty.color.Kind.selection_background, item.set.key); + try testing.expectEqual(@as(u8, 0xaa), item.set.color.r); + try testing.expectEqual(@as(u8, 0xbb), item.set.color.g); + try testing.expectEqual(@as(u8, 0xcc), item.set.color.b); + } + { + const item = cmd.kitty_color_protocol.list.items[7]; + try testing.expect(item == .query); + try testing.expectEqual(@as(kitty.color.Kind, @enumFromInt(2)), item.query); + } + { + const item = cmd.kitty_color_protocol.list.items[8]; + try testing.expect(item == .set); + try testing.expectEqual(@as(kitty.color.Kind, @enumFromInt(3)), item.set.key); + try testing.expectEqual(@as(u8, 0xff), item.set.color.r); + try testing.expectEqual(@as(u8, 0xff), item.set.color.g); + try testing.expectEqual(@as(u8, 0xff), item.set.color.b); + } +} + +test "OSC: kitty color protocol without allocator" { + const testing = std.testing; + + var p: Parser = .{}; + defer p.deinit(); + + const input = "21;foreground=?"; + for (input) |ch| p.next(ch); + try testing.expect(p.end('\x1b') == null); +} 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 7daf2b7a2..5aa3dfd75 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -144,6 +144,38 @@ pub const StreamHandler = struct { self.termio_messaged = true; } + /// Send a renderer message and unlock the renderer state mutex + /// if necessary to ensure we don't deadlock. + /// + /// This assumes the renderer state mutex is locked. + inline fn rendererMessageWriter( + self: *StreamHandler, + msg: renderer.Message, + ) void { + // See termio.Mailbox.send for more details on how this works. + + // Try instant first. If it works then we can return. + if (self.renderer_mailbox.push(msg, .{ .instant = {} }) > 0) { + return; + } + + // Instant would have blocked. Release the renderer mutex, + // wake up the renderer to allow it to process the message, + // and then try again. + self.renderer_state.mutex.unlock(); + defer self.renderer_state.mutex.lock(); + self.renderer_wakeup.notify() catch |err| { + // This is an EXTREMELY unlikely case. We still don't return + // and attempt to send the message because its most likely + // that everything is fine, but log in case a freeze happens. + log.warn( + "failed to notify renderer, may deadlock err={}", + .{err}, + ); + }; + _ = self.renderer_mailbox.push(msg, .{ .forever = {} }); + } + pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { var cmd = self.dcs.hook(self.alloc, dcs) orelse return; defer cmd.deinit(); @@ -1273,4 +1305,135 @@ pub const StreamHandler = struct { .csi_21_t => self.surfaceMessageWriter(.{ .report_title = .csi_21_t }), } } + + pub fn sendKittyColorReport( + self: *StreamHandler, + request: terminal.kitty.color.OSC, + ) !void { + var buf = std.ArrayList(u8).init(self.alloc); + defer buf.deinit(); + const writer = buf.writer(); + try writer.writeAll("\x1b[21"); + + for (request.list.items) |item| { + switch (item) { + .query => |key| { + const color: terminal.color.RGB = switch (key) { + .foreground => self.foreground_color, + .background => self.background_color, + .cursor => self.cursor_color, + else => if (key.palette()) |idx| + self.terminal.color_palette.colors[idx] + else { + log.warn("ignoring unsupported kitty color protocol key: {}", .{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}", + .{ key, color.r, color.g, color.b }, + ); + }, + .set => |v| switch (v.key) { + .foreground => { + self.foreground_color = v.color; + + // See messageWriter which has similar logic and + // explains why we may have to do this. + self.rendererMessageWriter(.{ + .foreground_color = v.color, + }); + }, + .background => { + self.background_color = v.color; + + // See messageWriter which has similar logic and + // explains why we may have to do this. + self.rendererMessageWriter(.{ + .background_color = v.color, + }); + }, + .cursor => { + self.cursor_color = v.color; + + // See messageWriter which has similar logic and + // explains why we may have to do this. + self.rendererMessageWriter(.{ + .cursor_color = v.color, + }); + }, + + else => if (v.key.palette()) |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = v.color; + self.terminal.color_palette.mask.unset(i); + } else { + log.warn( + "ignoring unsupported kitty color protocol key: {}", + .{v.key}, + ); + continue; + }, + }, + .reset => |key| switch (key) { + .foreground => { + self.foreground_color = self.default_foreground_color; + + // See messageWriter which has similar logic and + // explains why we may have to do this. + self.rendererMessageWriter(.{ + .foreground_color = self.default_foreground_color, + }); + }, + .background => { + self.background_color = self.default_background_color; + + // See messageWriter which has similar logic and + // explains why we may have to do this. + self.rendererMessageWriter(.{ + .background_color = self.default_background_color, + }); + }, + .cursor => { + self.cursor_color = self.default_cursor_color; + + // See messageWriter which has similar logic and + // explains why we may have to do this. + self.rendererMessageWriter(.{ + .cursor_color = self.default_cursor_color, + }); + }, + + else => if (key.palette()) |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + self.terminal.color_palette.mask.unset(i); + } else { + log.warn( + "ignoring unsupported kitty color protocol key: {}", + .{key}, + ); + continue; + }, + }, + } + } + + try writer.writeAll(request.terminator.string()); + + self.messageWriter(.{ + .write_alloc = .{ + .alloc = self.alloc, + .data = try buf.toOwnedSlice(), + }, + }); + + // Note: we don't have to do a queueRender here because every + // processed stream will queue a render once it is done processing + // the read() syscall. + } };