terminal: parse kitty text sizing protocol (OSC 66), redux

#9845 redone to use the new OSC parser

Implements the VT side of #5563
This commit is contained in:
Leah Amelia Chen
2026-01-14 22:51:53 +08:00
parent 5ff99ba9a0
commit 916b99df7c
6 changed files with 323 additions and 9 deletions

View File

@@ -286,18 +286,19 @@ pub const VTEvent = struct {
),
else => switch (Value) {
u8, u16 => try md.put(
key,
try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0),
),
[]const u8,
[:0]const u8,
=> try md.put(key, try alloc.dupeZ(u8, value)),
else => |T| {
@compileLog(T);
@compileError("unsupported type, see log");
else => |T| switch (@typeInfo(T)) {
.int => try md.put(
key,
try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0),
),
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
},
}

View File

@@ -14,6 +14,8 @@ const Allocator = mem.Allocator;
const LibEnum = @import("../lib/enum.zig").Enum;
const kitty_color = @import("kitty/color.zig");
const parsers = @import("osc/parsers.zig");
const encoding = @import("osc/encoding.zig");
pub const color = parsers.color;
const log = std.log.scoped(.osc);
@@ -191,6 +193,9 @@ pub const Command = union(Key) {
/// ConEmu GUI macro (OSC 9;6)
conemu_guimacro: [:0]const u8,
/// Kitty text sizing protocol (OSC 66)
kitty_text_sizing: parsers.kitty_text_sizing.OSC,
pub const Key = LibEnum(
if (build_options.c_abi) .c else .zig,
// NOTE: Order matters, see LibEnum documentation.
@@ -216,6 +221,7 @@ pub const Command = union(Key) {
"conemu_progress_report",
"conemu_wait_input",
"conemu_guimacro",
"kitty_text_sizing",
},
);
@@ -342,6 +348,7 @@ pub const Parser = struct {
@"2",
@"4",
@"5",
@"6",
@"7",
@"8",
@"9",
@@ -358,6 +365,7 @@ pub const Parser = struct {
@"21",
@"22",
@"52",
@"66",
@"77",
@"104",
@"110",
@@ -431,6 +439,7 @@ pub const Parser = struct {
.prompt_start,
.report_pwd,
.show_desktop_notification,
.kitty_text_sizing,
=> {},
}
@@ -510,6 +519,7 @@ pub const Parser = struct {
'2' => self.state = .@"2",
'4' => self.state = .@"4",
'5' => self.state = .@"5",
'6' => self.state = .@"6",
'7' => self.state = .@"7",
'8' => self.state = .@"8",
'9' => self.state = .@"9",
@@ -600,7 +610,14 @@ pub const Parser = struct {
else => self.state = .invalid,
},
.@"52" => switch (c) {
.@"6" => switch (c) {
'6' => self.state = .@"66",
else => self.state = .invalid,
},
.@"52",
.@"66",
=> switch (c) {
';' => self.writeToAllocating(),
else => self.state = .invalid,
},
@@ -685,6 +702,10 @@ pub const Parser = struct {
.@"52" => parsers.clipboard_operation.parse(self, terminator_ch),
.@"6" => null,
.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch),
.@"77" => null,
.@"133" => parsers.semantic_prompt.parse(self, terminator_ch),
@@ -696,4 +717,5 @@ pub const Parser = struct {
test {
_ = parsers;
_ = encoding;
}

View File

@@ -0,0 +1,38 @@
//! Specialized encodings used in some OSC protocols.
const std = @import("std");
/// Kitty defines "Escape code safe UTF-8" as valid UTF-8 with the
/// additional requirement of not containing any C0 escape codes
/// (0x00-0x1f), DEL (0x7f) and C1 escape codes (0x80-0x9f).
///
/// Used by OSC 66 (text sizing) and OSC 99 (Kitty notifications).
///
/// See: https://sw.kovidgoyal.net/kitty/desktop-notifications/#safe-utf8
pub fn isSafeUtf8(s: []const u8) bool {
const utf8 = std.unicode.Utf8View.init(s) catch {
@branchHint(.cold);
return false;
};
var it = utf8.iterator();
while (it.nextCodepoint()) |cp| switch (cp) {
0x00...0x1f, 0x7f, 0x80...0x9f => {
@branchHint(.cold);
return false;
},
else => {},
};
return true;
}
test isSafeUtf8 {
const testing = std.testing;
try testing.expect(isSafeUtf8("Hello world!"));
try testing.expect(isSafeUtf8("安全的ユニコード☀️"));
try testing.expect(!isSafeUtf8("No linebreaks\nallowed"));
try testing.expect(!isSafeUtf8("\x07no bells"));
try testing.expect(!isSafeUtf8("\x1b]9;no OSCs\x1b\\\x1b[m"));
try testing.expect(!isSafeUtf8("\x9f8-bit escapes are clever, but no"));
}

View File

@@ -6,6 +6,7 @@ pub const clipboard_operation = @import("parsers/clipboard_operation.zig");
pub const color = @import("parsers/color.zig");
pub const hyperlink = @import("parsers/hyperlink.zig");
pub const kitty_color = @import("parsers/kitty_color.zig");
pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig");
pub const mouse_shape = @import("parsers/mouse_shape.zig");
pub const osc9 = @import("parsers/osc9.zig");
pub const report_pwd = @import("parsers/report_pwd.zig");
@@ -19,6 +20,7 @@ test {
_ = color;
_ = hyperlink;
_ = kitty_color;
_ = kitty_text_sizing;
_ = mouse_shape;
_ = osc9;
_ = report_pwd;

View File

@@ -0,0 +1,250 @@
//! Kitty's text sizing protocol (OSC 66)
//! Specification: https://sw.kovidgoyal.net/kitty/text-sizing-protocol/
const std = @import("std");
const build_options = @import("terminal_options");
const assert = @import("../../../quirks.zig").inlineAssert;
const Parser = @import("../../osc.zig").Parser;
const Command = @import("../../osc.zig").Command;
const encoding = @import("../encoding.zig");
const lib = @import("../../../lib/main.zig");
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
const log = std.log.scoped(.kitty_text_sizing);
pub const max_payload_length = 4096;
pub const VAlign = lib.Enum(lib_target, &.{
"top",
"bottom",
"center",
});
pub const HAlign = lib.Enum(lib_target, &.{
"left",
"right",
"center",
});
pub const OSC = struct {
scale: u3 = 1, // 1 - 7
width: u3 = 0, // 0 - 7 (0 means default)
numerator: u4 = 0,
denominator: u4 = 0,
valign: VAlign = .top,
halign: HAlign = .left,
text: [:0]const u8,
/// We don't currently support encoding this to C in any way.
pub const C = void;
pub fn cval(_: OSC) C {
return {};
}
fn update(self: *OSC, key: u8, value: []const u8) !void {
// All values are numeric, so we can do a small hack here
const v = try std.fmt.parseInt(u4, value, 10);
switch (key) {
's' => {
if (v == 0) return error.InvalidValue;
self.scale = std.math.cast(u3, v) orelse return error.Overflow;
},
'w' => self.width = std.math.cast(u3, v) orelse return error.Overflow,
'n' => self.numerator = v,
'd' => self.denominator = v,
'v' => self.valign = std.enums.fromInt(VAlign, v) orelse return error.InvalidValue,
'h' => self.halign = std.enums.fromInt(HAlign, v) orelse return error.InvalidValue,
else => return error.UnknownKey,
}
}
};
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
assert(parser.state == .@"66");
const writer = parser.writer orelse {
parser.state = .invalid;
return null;
};
// Write a NUL byte to ensure that `text` is NUL-terminated
writer.writeByte(0) catch {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const payload_start = std.mem.indexOfScalar(u8, data, ';') orelse {
log.warn("missing semicolon before payload", .{});
parser.state = .invalid;
return null;
};
const payload = data[payload_start + 1 .. data.len - 1 :0];
// Payload has to be a URL-safe UTF-8 string,
// and be under the size limit.
if (payload.len > max_payload_length) {
log.warn("payload is too long", .{});
parser.state = .invalid;
return null;
}
if (!encoding.isSafeUtf8(payload)) {
log.warn("payload is not escape code safe UTF-8", .{});
parser.state = .invalid;
return null;
}
parser.command = .{
.kitty_text_sizing = .{ .text = payload },
};
const cmd = &parser.command.kitty_text_sizing;
// Parse any arguments if given
if (payload_start > 0) {
var kv_it = std.mem.splitScalar(
u8,
data[0..payload_start],
':',
);
while (kv_it.next()) |kv| {
var it = std.mem.splitScalar(u8, kv, '=');
const k = it.next() orelse {
log.warn("missing key", .{});
continue;
};
if (k.len != 1) {
log.warn("key must be a single character", .{});
continue;
}
const value = it.next() orelse {
log.warn("missing value", .{});
continue;
};
cmd.update(k[0], value) catch |err| {
switch (err) {
error.UnknownKey => log.warn("unknown key: '{c}'", .{k[0]}),
else => log.warn("invalid value for key '{c}': {}", .{ k[0], err }),
}
continue;
};
}
}
return &parser.command;
}
test "OSC 66: empty parameters" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "66;;bobr";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_text_sizing);
try testing.expectEqual(1, cmd.kitty_text_sizing.scale);
try testing.expectEqualStrings("bobr", cmd.kitty_text_sizing.text);
}
test "OSC 66: single parameter" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "66;s=2;kurwa";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_text_sizing);
try testing.expectEqual(2, cmd.kitty_text_sizing.scale);
try testing.expectEqualStrings("kurwa", cmd.kitty_text_sizing.text);
}
test "OSC 66: multiple parameters" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "66;s=2:w=7:n=13:d=15:v=1:h=2;long";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_text_sizing);
try testing.expectEqual(2, cmd.kitty_text_sizing.scale);
try testing.expectEqual(7, cmd.kitty_text_sizing.width);
try testing.expectEqual(13, cmd.kitty_text_sizing.numerator);
try testing.expectEqual(15, cmd.kitty_text_sizing.denominator);
try testing.expectEqual(.bottom, cmd.kitty_text_sizing.valign);
try testing.expectEqual(.center, cmd.kitty_text_sizing.halign);
try testing.expectEqualStrings("long", cmd.kitty_text_sizing.text);
}
test "OSC 66: scale is zero" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "66;s=0;nope";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_text_sizing);
try testing.expectEqual(1, cmd.kitty_text_sizing.scale);
}
test "OSC 66: invalid parameters" {
const testing = std.testing;
var p: Parser = .init(null);
for ("66;w=8:v=3:n=16;") |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_text_sizing);
try testing.expectEqual(0, cmd.kitty_text_sizing.width);
try testing.expect(cmd.kitty_text_sizing.valign == .top);
try testing.expectEqual(0, cmd.kitty_text_sizing.numerator);
}
test "OSC 66: UTF-8" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "66;;👻魑魅魍魉ゴースッティ";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_text_sizing);
try testing.expectEqualStrings("👻魑魅魍魉ゴースッティ", cmd.kitty_text_sizing.text);
}
test "OSC 66: unsafe UTF-8" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "66;;\n";
for (input) |ch| p.next(ch);
try testing.expect(p.end('\x1b') == null);
}
test "OSC 66: overlong UTF-8" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "66;;" ++ "bobr" ** 1025;
for (input) |ch| p.next(ch);
try testing.expect(p.end('\x1b') == null);
}

View File

@@ -2107,6 +2107,7 @@ pub fn Stream(comptime Handler: type) type {
.conemu_change_tab_title,
.conemu_wait_input,
.conemu_guimacro,
.kitty_text_sizing,
=> {
log.debug("unimplemented OSC callback: {}", .{cmd});
},