terminal: hook up glyph protocol glossary to terminal state (#12937)

This hooks up the glyph protocol glossary to the terminal state. This
effectively makes us handle the APC protocol for it both in Ghostty GUI
and libghostty, although we didn't implement the renderer yet.

The Zig/C libghostty API also has a way to disable the protocol but it
is enabled by default. The memory usage is bound by the specification.

For dirty tracking for the renderer, we're going with the simple route
that any glyph change marks a coarse grained dirty flag and we'll [in
the future] rebuild the entire state in the renderer. I think this will
be fine for realistic workloads, but we can reassess in the future when
we have real workloads.
This commit is contained in:
Mitchell Hashimoto
2026-06-05 19:47:46 -07:00
committed by GitHub
7 changed files with 212 additions and 5 deletions

View File

@@ -647,6 +647,18 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: bool*
*/
GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK = 23,
/**
* Enable or disable Glyph Protocol APC handling.
*
* When disabled, Glyph Protocol APC sequences are ignored and no
* support/query/register/clear responses are emitted. Disabling also clears
* the terminal session's glyph glossary. A NULL value pointer is a no-op.
*
* Input type: bool*
*/
GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;

View File

@@ -17,6 +17,7 @@ const modespkg = @import("modes.zig");
const charsets = @import("charsets.zig");
const csi = @import("csi.zig");
const hyperlink = @import("hyperlink.zig");
const glyph = @import("apc/glyph.zig");
const kitty = @import("kitty.zig");
const osc = @import("osc.zig");
const point = @import("point.zig");
@@ -81,6 +82,9 @@ modes: modespkg.ModeState = .{},
/// The most recently set mouse shape for the terminal.
mouse_shape: mouse.Shape = .text,
/// Per-session Glyph Protocol registrations.
glyph_glossary: glyph.Glossary = .empty,
/// These are just a packed set of flags we may set on the terminal.
flags: packed struct {
// This supports a Kitty extension where programs using semantic
@@ -165,6 +169,11 @@ pub const Dirty = packed struct {
/// Set when the pre-edit is modified.
preedit: bool = false,
/// Set when Glyph Protocol registrations may have changed. Registered
/// glyphs can affect already-visible PUA cells, so this requires a full
/// render-state rebuild.
glyph_glossary: bool = false,
};
/// Scrolling region is the area of the screen designated where scrolling
@@ -256,6 +265,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void {
self.screens.deinit(alloc);
self.pwd.deinit(alloc);
self.title.deinit(alloc);
self.glyph_glossary.deinit(alloc);
self.* = undefined;
}
@@ -2720,6 +2730,22 @@ pub fn kittyGraphics(
return kitty.graphics.execute(alloc, self, cmd);
}
/// Execute a Glyph Protocol APC command against this terminal's per-session
/// glossary. The returned response, if any, should be sent back to the pty as
/// a complete APC sequence via `Response.formatWire`.
pub fn glyphProtocol(
self: *Terminal,
alloc: Allocator,
req: *const glyph.Request,
) ?glyph.Response {
const resp = glyph.execute(alloc, &self.glyph_glossary, req);
switch (req.*) {
.register, .clear => self.flags.dirty.glyph_glossary = true,
.support, .query => {},
}
return resp;
}
/// Set the storage size limit for Kitty graphics across all screens.
pub fn setKittyGraphicsSizeLimit(
self: *Terminal,
@@ -3171,6 +3197,7 @@ pub fn fullReset(self: *Terminal) void {
self.previous_char = null;
self.pwd.clearRetainingCapacity();
self.title.clearRetainingCapacity();
self.glyph_glossary.clearAndFree(self.gpa());
self.status_display = .main;
self.scrolling_region = .{
.top = 0,
@@ -13183,3 +13210,35 @@ test "Terminal: deleteLines wide char at right margin with full clear" {
// violation in clearCells.
try t.scrollUp(t.rows);
}
test "Terminal: glyph APC stores session glossary entries" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 80, .rows = 24 });
defer t.deinit(alloc);
var register_parser = glyph.CommandParser.init(alloc, 1024 * 1024);
defer register_parser.deinit();
for ("r;cp=e0a0;AAAAAAAAAAAAAA==") |byte| try register_parser.feed(byte);
var register_req = try register_parser.complete(alloc);
defer register_req.deinit(alloc);
try testing.expectEqual(glyph.Response{
.register = .{ .cp = 0xE0A0 },
}, t.glyphProtocol(alloc, &register_req).?);
try testing.expect(t.glyph_glossary.contains(0xE0A0));
try testing.expect(t.flags.dirty.glyph_glossary);
var query_parser = glyph.CommandParser.init(alloc, 1024 * 1024);
defer query_parser.deinit();
for ("q;cp=e0a0") |byte| try query_parser.feed(byte);
var query_req = try query_parser.complete(alloc);
defer query_req.deinit(alloc);
try testing.expectEqual(glyph.Response{ .query = .{
.cp = 0xE0A0,
.status = .{ .glossary = true },
} }, t.glyphProtocol(alloc, &query_req).?);
t.fullReset();
try testing.expect(!t.glyph_glossary.contains(0xE0A0));
}

View File

@@ -2,7 +2,7 @@ const std = @import("std");
const build_options = @import("terminal_options");
const Allocator = std.mem.Allocator;
const glyph = @import("apc/glyph.zig");
pub const glyph = @import("apc/glyph.zig");
const kitty_gfx = @import("kitty/graphics.zig");
const log = std.log.scoped(.terminal_apc);
@@ -22,6 +22,11 @@ pub const Handler = struct {
.glyph = Protocol.defaultMaxBytes(.glyph),
}),
/// Protocols recognized by this APC handler. When a protocol is absent,
/// matching APC sequences are ignored so callers see the same behavior as
/// an unsupported protocol: no command execution and no response.
enabled: std.EnumSet(Protocol) = .initFull(),
pub fn deinit(self: *Handler) void {
self.state.deinit();
}
@@ -31,6 +36,12 @@ pub const Handler = struct {
self.state = .{ .identify = .{} };
}
/// Enable or disable APC protocol recognition for future APC sequences.
/// This does not affect any APC command already being parsed.
pub fn enable(self: *Handler, protocol: Protocol, enabled: bool) void {
self.enabled.setPresent(protocol, enabled);
}
pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void {
switch (self.state) {
.inactive => unreachable,
@@ -45,7 +56,10 @@ pub const Handler = struct {
// 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') {
if (id.len == 0 and
byte == 'G' and
self.enabled.contains(.kitty))
{
self.state = .{ .kitty = .init(
alloc,
self.max_bytes.get(.kitty) orelse
@@ -58,7 +72,9 @@ pub const Handler = struct {
// If we hit `;` then identify...
if (byte == ';') {
const str = id.buf[0..id.len];
if (std.mem.eql(u8, str, "25a1")) {
if (std.mem.eql(u8, str, "25a1") and
self.enabled.contains(.glyph))
{
self.state = .{ .glyph = .init(
alloc,
self.max_bytes.get(.glyph) orelse
@@ -373,3 +389,14 @@ test "valid glyph command" {
try testing.expect(cmd == .glyph);
try testing.expect(cmd.glyph == .query);
}
test "disabled glyph command is ignored" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.enable(.glyph, false);
h.start();
for ("25a1;q;cp=e0a0") |c| h.feed(alloc, c);
try testing.expect(h.end() == null);
}

View File

@@ -30,6 +30,18 @@ pub const Coverage = packed struct(u2) {
/// Response to a glyph APC request, formatted for the wire protocol.
pub const Response = union(enum) {
/// Recommended fixed buffer size for formatting a Glyph Protocol response.
///
/// Glyph Protocol responses contain only framing plus bounded scalar fields:
/// a u21 codepoint as hex, a decimal u8 status, a small fixed set of
/// supported format names, coverage names, and the reason names produced by
/// the executor. 1024 bytes is therefore far above the longest response we
/// can emit today, while still being small enough for stack allocation in
/// stream handlers. If callers construct responses with arbitrary `.other`
/// or clear reason strings, they must ensure those strings fit or handle the
/// writer error from `formatWire`.
pub const max_wire_bytes = 1024;
/// Support query response listing supported payload formats.
support: Support,

View File

@@ -329,6 +329,7 @@ pub const Option = enum(c_int) {
selection = 21,
default_cursor_style = 22,
default_cursor_blink = 23,
glyph_protocol = 24,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -349,6 +350,7 @@ pub const Option = enum(c_int) {
.kitty_image_medium_file,
.kitty_image_medium_temp_file,
.kitty_image_medium_shared_mem,
.glyph_protocol,
=> ?*const bool,
.apc_max_bytes, .apc_max_bytes_kitty => ?*const usize,
.selection => ?*const selection_c.CSelection,
@@ -461,6 +463,11 @@ fn setTyped(
wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty);
}
},
.glyph_protocol => {
const enabled = (value orelse return .success).*;
wrapper.stream.handler.apc_handler.enable(.glyph, enabled);
if (!enabled) wrapper.terminal.glyph_glossary.clearAndFree(wrapper.terminal.gpa());
},
.selection => {
if (value) |ptr| {
const sel = ptr.toZig() orelse return .invalid_value;
@@ -3183,6 +3190,33 @@ test "set color sets dirty flag" {
try testing.expect(zt.flags.dirty.palette);
}
test "set glyph protocol disables APC handling and clears glossary" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
));
defer free(t);
const register = "\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\";
vt_write(t, register, register.len);
try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0));
const disabled = false;
try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&disabled)));
try testing.expect(!t.?.stream.handler.apc_handler.enabled.contains(.glyph));
try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0));
vt_write(t, register, register.len);
try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0));
const enabled = true;
try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&enabled)));
vt_write(t, register, register.len);
try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0));
}
test "get_multi success" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -697,7 +697,24 @@ pub const Handler = struct {
}
},
.glyph => {},
.glyph => |*glyph_req| {
const resp = self.terminal.glyphProtocol(alloc, glyph_req);
if (resp) |r| resp_block: {
// Don't waste time encoding if we can't write responses
// anyways.
if (self.effects.write_pty == null) break :resp_block;
// Glyph responses are short and bounded by the protocol
// fields we emit, so this matches the Kitty response
// buffer size above with ample headroom.
var buf: [apc.glyph.Response.max_wire_bytes]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
r.formatWire(&writer) catch return;
writer.writeByte(0) catch return;
const final = writer.buffered();
self.writePty(final[0 .. final.len - 1 :0]);
}
},
}
}
};
@@ -919,6 +936,8 @@ test "full reset" {
s.nextSlice("\x1B[10;20H");
s.nextSlice("\x1B[5;20r"); // Set scroll region
s.nextSlice("\x1B[?7l"); // Disable wraparound
s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\");
try testing.expect(t.glyph_glossary.contains(0xE0A0));
// Full reset
s.nextSlice("\x1Bc");
@@ -929,6 +948,35 @@ test "full reset" {
try testing.expectEqual(@as(usize, 0), t.scrolling_region.top);
try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom);
try testing.expect(t.modes.get(.wraparound));
try testing.expect(!t.glyph_glossary.contains(0xE0A0));
}
test "glyph protocol APC with write_pty callback" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var last_response: ?[:0]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
if (last_response) |old| testing.allocator.free(old);
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
}
};
S.last_response = null;
defer if (S.last_response) |old| testing.allocator.free(old);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1B_25a1;s\x1B\\");
try testing.expectEqualStrings("\x1B_25a1;s;fmt=glyf\x1B\\", S.last_response.?);
s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\");
try testing.expectEqualStrings("\x1B_25a1;r;cp=e0a0;status=0\x1B\\", S.last_response.?);
try testing.expect(t.glyph_glossary.contains(0xE0A0));
}
test "ignores query actions" {

View File

@@ -560,7 +560,22 @@ pub const StreamHandler = struct {
}
},
.glyph => {},
.glyph => |*glyph_req| {
const resp = self.terminal.glyphProtocol(self.alloc, glyph_req);
switch (glyph_req.*) {
.register, .clear => try self.queueRender(),
.support, .query => {},
}
if (resp) |r| {
var buf: [terminal.apc.glyph.Response.max_wire_bytes]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try r.formatWire(&writer);
const final = writer.buffered();
log.debug("glyph protocol response: {x}", .{final});
self.messageWriter(try termio.Message.writeReq(self.alloc, final));
}
},
}
}