mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 03:25:50 +00:00
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:
@@ -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");
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
38
src/terminal/osc/encoding.zig
Normal file
38
src/terminal/osc/encoding.zig
Normal 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"));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
250
src/terminal/osc/parsers/kitty_text_sizing.zig
Normal file
250
src/terminal/osc/parsers/kitty_text_sizing.zig
Normal 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);
|
||||
}
|
||||
@@ -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});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user