terminal: glyph protocol parser and response encoder

This adds the core parse/encode for the still in-development and experimental
terminal glyph protocol: https://github.com/raphamorim/rio/pull/1542
Up to version 1.9.

The only cross-cutting change necessary was changing the APC
identification logic which previously only looked at a single byte to
support multi-byte identifiers since the glyph protocol uses `25a1`.
This commit is contained in:
Mitchell Hashimoto
2026-04-19 14:34:01 -07:00
parent 0f7cd84b88
commit d3775d1ed0
6 changed files with 1366 additions and 17 deletions

View File

@@ -2,6 +2,7 @@ const std = @import("std");
const build_options = @import("terminal_options");
const Allocator = std.mem.Allocator;
const glyph = @import("apc/glyph.zig");
const kitty_gfx = @import("kitty/graphics.zig");
const log = std.log.scoped(.terminal_apc);
@@ -18,6 +19,7 @@ pub const Handler = struct {
/// use `.initFull`.
max_bytes: std.EnumMap(Protocol, usize) = .initFullWith(.{
.kitty = Protocol.defaultMaxBytes(.kitty),
.glyph = Protocol.defaultMaxBytes(.glyph),
}),
pub fn deinit(self: *Handler) void {
@@ -26,7 +28,7 @@ pub const Handler = struct {
pub fn start(self: *Handler) void {
self.state.deinit();
self.state = .{ .identify = {} };
self.state = .{ .identify = .{} };
}
pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void {
@@ -38,21 +40,45 @@ pub const Handler = struct {
.ignore => return,
// We identify the APC command by the first byte.
.identify => {
switch (byte) {
// Kitty graphics protocol
'G' => self.state = if (comptime build_options.kitty_graphics)
.{ .kitty = .init(
.identify => |*id| id: {
// Kitty graphics is detected immediately on the `G` byte,
// since commands begin immediately after with no termination
// character after the 'G'.
if (comptime build_options.kitty_graphics) {
if (id.len == 0 and byte == 'G') {
self.state = .{ .kitty = .init(
alloc,
self.max_bytes.get(.kitty) orelse
Protocol.defaultMaxBytes(.kitty),
) }
else
.ignore,
// Unknown
else => self.state = .ignore,
) };
break :id;
}
}
// If we hit `;` then identify...
if (byte == ';') {
const str = id.buf[0..id.len];
if (std.mem.eql(u8, str, "25a1")) {
self.state = .{ .glyph = .init(
alloc,
self.max_bytes.get(.glyph) orelse
Protocol.defaultMaxBytes(.glyph),
) };
} else {
self.state = .ignore;
}
break :id;
}
// If we're out of space to buffer then we're done.
if (id.len >= id.buf.len) {
self.state = .ignore;
break :id;
}
id.buf[id.len] = byte;
id.len += 1;
},
.kitty => |*p| if (comptime build_options.kitty_graphics) {
@@ -62,6 +88,12 @@ pub const Handler = struct {
self.state = .ignore;
};
} else unreachable,
.glyph => |*p| p.feed(byte) catch |err| {
log.warn("glyph protocol error: {}", .{err});
p.deinit();
self.state = .ignore;
},
}
}
@@ -86,21 +118,40 @@ pub const Handler = struct {
break :kitty .{ .kitty = command };
},
.glyph => |*p| glyph_cmd: {
const command = p.complete(p.alloc) catch |err| {
log.warn("glyph protocol error: {}", .{err});
break :glyph_cmd null;
};
break :glyph_cmd .{ .glyph = command };
},
};
}
};
pub const State = union(enum) {
/// We're not in the middle of an APC command yet.
inactive: void,
inactive,
/// We got an unrecognized APC sequence or the APC sequence we
/// recognized became invalid. We're just dropping bytes.
ignore: void,
ignore,
/// We're waiting to identify the APC sequence. This is done by
/// inspecting the first byte of the sequence.
identify: void,
/// We're waiting to identify the APC sequence. The way this is done
/// is pretty fluid depending on supported APC protocols, but for now
/// our rule is:
///
/// * 'G' - immediate transition to Kitty graphics protocol
/// * Buffer up to `;` and the bytes before dictate the protocol.
/// If we overflow then we're immediately invalid because we don't
/// support anything longer than this.
///
identify: struct {
len: u3 = 0,
buf: [4]u8 = undefined,
},
/// Kitty graphics protocol
kitty: if (build_options.kitty_graphics)
@@ -108,9 +159,13 @@ pub const State = union(enum) {
else
void,
/// Glyph protocol
glyph: glyph.CommandParser,
pub fn deinit(self: *State) void {
switch (self.*) {
.inactive, .ignore, .identify => {},
.glyph => |*v| v.deinit(),
.kitty => |*v| if (comptime build_options.kitty_graphics)
v.deinit()
else
@@ -122,6 +177,7 @@ pub const State = union(enum) {
/// Possible APC command types.
pub const Protocol = enum {
kitty,
glyph,
/// Returns the default maximum bytes for the given protocol.
pub fn defaultMaxBytes(self: Protocol) usize {
@@ -129,6 +185,10 @@ pub const Protocol = enum {
// Kitty graphics payloads can be very large (e.g. full images
// encoded as base64), so the default is set to 65 MiB.
.kitty => 65 * 1024 * 1024,
// Glyph protocol messages carry single glyf outlines which
// are small, but base64 encoding inflates them. 1 MiB is
// generous for any single simple-glyph record.
.glyph => 1 * 1024 * 1024,
};
}
};
@@ -140,12 +200,16 @@ pub const Command = union(Protocol) {
else
void,
glyph: glyph.Request,
pub fn deinit(self: *Command, alloc: Allocator) void {
switch (self.*) {
.kitty => |*v| if (comptime build_options.kitty_graphics)
v.deinit(alloc)
else
unreachable,
.glyph => |*v| v.deinit(alloc),
}
}
};
@@ -246,3 +310,66 @@ test "valid Kitty command" {
defer cmd.deinit(alloc);
try testing.expect(cmd == .kitty);
}
test "identify with unrecognized command" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.start();
for ("abcd;payload") |c| h.feed(alloc, c);
try testing.expect(h.end() == null);
}
test "identify buffer overflow" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.start();
for ("abcde;payload") |c| h.feed(alloc, c);
try testing.expect(h.end() == null);
}
test "identify with no input" {
const testing = std.testing;
var h: Handler = .{};
h.start();
try testing.expect(h.end() == null);
}
test "identify with unknown partial input" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.start();
for ("25a") |c| h.feed(alloc, c);
try testing.expect(h.end() == null);
}
test "garbage glyph command" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.start();
for ("25a1;X") |c| h.feed(alloc, c);
try testing.expect(h.end() == null);
}
test "valid glyph command" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.start();
for ("25a1;q;cp=E0A0") |c| h.feed(alloc, c);
var cmd = h.end().?;
defer cmd.deinit(alloc);
try testing.expect(cmd == .glyph);
try testing.expect(cmd.glyph == .query);
}