diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 9b573c36a..fb3102147 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -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; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d41e47501..3a914794f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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, ®ister_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)); +} diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 4ae9ead51..61d30822f 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -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); +} diff --git a/src/terminal/apc/glyph/response.zig b/src/terminal/apc/glyph/response.zig index 5b72495d7..9e40f0ea7 100644 --- a/src/terminal/apc/glyph/response.zig +++ b/src/terminal/apc/glyph/response.zig @@ -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, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ab719eb4a..9fcf2e940 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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( diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 113521072..eda2af0e0 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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" { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index cb6305546..657ca25c7 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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)); + } + }, } }