Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Kitty Color Protocol (OSC 21) #2113

Merged
merged 9 commits into from
Aug 21, 2024
5 changes: 5 additions & 0 deletions src/inspector/termio.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 30 additions & 10 deletions src/terminal/color.zig
Original file line number Diff line number Diff line change
Expand Up @@ -208,24 +208,41 @@ pub const RGB = struct {
/// where <red>, <green>, and <blue> 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]),
},

else => return error.InvalidFormat,
}

return RGB{
.r = try RGB.fromHex(value[1..3]),
.g = try RGB.fromHex(value[3..5]),
.b = try RGB.fromHex(value[5..7]),
};
}

// Check for X11 named colors. We allow whitespace around the edges
Expand Down Expand Up @@ -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"));
Expand Down
230 changes: 230 additions & 0 deletions src/terminal/osc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -167,6 +172,42 @@ pub const Command = union(enum) {
};
}
};

pub const KittyColorProtocol = struct {
const Kind = enum(u9) {
// 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,
_,

// Make sure that this stays in sync with the higest numbered enum
// value.
const max: u9 = 263;
};

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
Expand Down Expand Up @@ -251,6 +292,7 @@ pub const Parser = struct {
@"13",
@"133",
@"2",
@"21",
@"22",
@"4",
@"5",
Expand Down Expand Up @@ -310,6 +352,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.
Expand All @@ -323,6 +370,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);
Expand Down Expand Up @@ -439,6 +489,7 @@ pub const Parser = struct {
},

.@"2" => switch (c) {
'1' => self.state = .@"21",
'2' => self.state = .@"22",
';' => {
self.command = .{ .change_window_title = undefined };
Expand All @@ -450,6 +501,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 };
Expand Down Expand Up @@ -936,6 +1026,82 @@ 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 = key: {
break :key std.meta.stringToEnum(Command.KittyColorProtocol.Kind, self.temp_state.key) orelse {
const v = std.fmt.parseUnsigned(u9, self.temp_state.key, 10) catch {
log.warn("unknown key in kitty color protocol: {s}", .{self.temp_state.key});
return;
};
if (v > 255) {
log.warn("unknown key in kitty color protocol: {s}", .{self.temp_state.key});
return;
}
break :key @as(Command.KittyColorProtocol.Kind, @enumFromInt(v));
};
};

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 (v.list.items.len >= @as(usize, Command.KittyColorProtocol.Kind.max) * 2) {
self.state = .invalid;
log.warn("exceeded limit for number of keys in kitty color protocol, ignoring", .{});
return;
}
if (kind == .key_only) {
v.list.append(.{ .reset = key }) catch |err| {
log.warn("unable to append kitty color protocol option: {}", .{err});
return;
};
return;
}
if (value.len == 0) {
v.list.append(.{ .reset = key }) catch |err| {
log.warn("unable to append kitty color protocol option: {}", .{err});
return;
};
return;
}
if (mem.eql(u8, "?", value)) {
v.list.append(.{ .query = key }) catch |err| {
log.warn("unable to append kitty color protocol option: {}", .{err});
return;
};
return;
}
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;
};
return;
},
else => {},
}
}

fn endAllocableString(self: *Parser) void {
const list = self.buf_dynamic.?;
self.temp_state.str.* = list.items;
Expand All @@ -958,11 +1124,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 => {},
}

Expand Down Expand Up @@ -1497,3 +1666,64 @@ 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);
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);
try testing.expect(cmd.kitty_color_protocol.list.items[7] == .query);
try testing.expectEqual(@as(Command.KittyColorProtocol.Kind, @enumFromInt(2)), cmd.kitty_color_protocol.list.items[7].query);
try testing.expect(cmd.kitty_color_protocol.list.items[8] == .set);
try testing.expectEqual(@as(Command.KittyColorProtocol.Kind, @enumFromInt(3)), cmd.kitty_color_protocol.list.items[8].set.key);
try testing.expectEqual(@as(u8, 0xff), cmd.kitty_color_protocol.list.items[8].set.color.r);
try testing.expectEqual(@as(u8, 0xff), cmd.kitty_color_protocol.list.items[8].set.color.g);
try testing.expectEqual(@as(u8, 0xff), cmd.kitty_color_protocol.list.items[8].set.color.b);
}

test "OSC: kitty color protocol kind" {
const info = @typeInfo(Command.KittyColorProtocol.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 == Command.KittyColorProtocol.Kind.max);
}
7 changes: 7 additions & 0 deletions src/terminal/stream.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading