From d3775d1ed0a2e41ee8f2ecdb325f6c016b2b3e93 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 19 Apr 2026 14:34:01 -0700 Subject: [PATCH] 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`. --- src/terminal/apc.zig | 161 +++++- src/terminal/apc/glyph.zig | 158 ++++++ src/terminal/apc/glyph/request.zig | 726 ++++++++++++++++++++++++++++ src/terminal/apc/glyph/response.zig | 334 +++++++++++++ src/terminal/stream_terminal.zig | 2 + src/termio/stream_handler.zig | 2 + 6 files changed, 1366 insertions(+), 17 deletions(-) create mode 100644 src/terminal/apc/glyph.zig create mode 100644 src/terminal/apc/glyph/request.zig create mode 100644 src/terminal/apc/glyph/response.zig diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 7e6f08a7a..4ae9ead51 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -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); +} diff --git a/src/terminal/apc/glyph.zig b/src/terminal/apc/glyph.zig new file mode 100644 index 000000000..67eb5163f --- /dev/null +++ b/src/terminal/apc/glyph.zig @@ -0,0 +1,158 @@ +//! # Glyph Protocol +//! +//! The Glyph Protocol lets applications register custom glyphs with the +//! terminal at runtime and query whether a given codepoint is already +//! covered by a system font or a prior registration. It eliminates the +//! requirement for users to install patched fonts (e.g. Nerd Fonts) in +//! order to render icons in TUIs. +//! +//! This file documents the current wire protocol surface parsed and formatted +//! by the glyph APC modules. +//! +//! ## Transport +//! +//! Messages use APC (Application Program Command) framing. +//! Terminals that do not implement the protocol can safely ignore APC +//! sequences. Every message is prefixed with the identifier `25a1` +//! (U+25A1 WHITE SQUARE — the canonical tofu symbol). +//! +//! ## Framing +//! +//! ``` +//! ESC _ 25a1 ; [ ; key=value ]* [ ; ] ESC \ +//! ``` +//! +//! Four verbs are defined: +//! +//! - `s` — support query +//! - `q` — codepoint query +//! - `r` — register a glyph +//! - `c` — clear registrations +//! +//! ## Support (`s`) +//! +//! Detects whether the terminal implements Glyph Protocol and which +//! payload formats it supports. +//! +//! Request: `ESC _ 25a1 ; s ESC \` +//! Response: `ESC _ 25a1 ; s ; fmt= ESC \` +//! +//! `fmt` is a comma-separated list of supported payload format names: +//! - `glyf` — TrueType simple glyphs (required in v1) +//! - `colrv0` — COLR v0 layered flat-colour glyphs +//! - `colrv1` — COLR v1 paint-graph glyphs +//! +//! Order is not significant. An empty `fmt=` means the terminal recognizes +//! Glyph Protocol but currently advertises no payload formats. Clients must +//! ignore unknown format names. +//! +//! Any reply confirms support; no reply within a timeout means the +//! terminal does not implement the protocol. +//! +//! ## Query (`q`) +//! +//! Asks whether a codepoint is renderable and by whom. +//! +//! Request: `ESC _ 25a1 ; q ; cp= ESC \` +//! Response: `ESC _ 25a1 ; q ; cp= ; status= ESC \` +//! +//! `status` is a comma-separated list of coverage names: +//! - empty — nothing renders this codepoint (tofu) +//! - `system` — a system font covers it +//! - `glossary` — a session registration covers it +//! - `system,glossary` — both; the registration shadows the system font +//! +//! Non-PUA codepoints can only report empty or `system`. Clients must ignore +//! unknown coverage names. +//! +//! ## Register (`r`) +//! +//! Registers a glyph outline at a Private Use Area codepoint. +//! +//! Request: +//! `ESC _ 25a1 ; r ; cp= [; fmt=glyf] [; reply=<0|1|2>] +//! [; upm=] [; aw=] [; lh=] [; width=<1|2>] +//! [; size=] +//! [; align=,] +//! [; pad=,,,] ; ESC \` +//! +//! Response: +//! `ESC _ 25a1 ; r ; cp= ; status=0 ESC \` +//! On error: `status= ; reason=` +//! +//! Parameters: +//! - `cp` — target codepoint (hex). Must be in a PUA range: +//! U+E000–U+F8FF, U+F0000–U+FFFFD, or U+100000–U+10FFFD. +//! Non-PUA values are rejected with `reason=out_of_namespace`. +//! - `fmt` — payload format. Default `glyf`; `colrv0` and `colrv1` +//! are optional and advertised via the `s` reply. +//! - `reply` — response verbosity: +//! `1` (default) = success + failure replies +//! `2` = failure replies only (silent success) +//! `0` = no replies (fire-and-forget) +//! - `upm` — units-per-em for the coordinate space. Default 1000. +//! - `aw` — authored advance width in upm units. Default `upm`. +//! - `lh` — authored line height in upm units. Default `upm`. +//! - `width` — Unicode/wcwidth cell width. Must be `1` or `2`; default `1`. +//! This is authoritative for cursor advance, wrapping, and +//! selection geometry. +//! - `size` — scale policy. Default `height`. +//! - `align` — horizontal and vertical placement within the render span. +//! Default `center,center`. +//! - `pad` — fractional insets from the render span edges. Default +//! `0,0,0,0`; degenerate padding is treated as no padding. +//! - payload — base64-encoded payload for the selected `fmt`. +//! +//! The `glyf` subset accepted: +//! - Simple glyphs only (no composites). +//! - Standard flag encoding (on-curve, off-curve, x/y-short, repeat). +//! - No hinting instructions. +//! - Coordinates are in the `upm` space, Y-up, with `y=0` at the baseline; +//! the terminal scales and positions at render time using `aw`, `lh`, +//! `width`, `size`, `align`, and `pad`. +//! +//! `colrv0` and `colrv1` wrap OpenType `COLR`/`CPAL` data together with the +//! simple-glyph outlines they reference. `colrv0` uses layered flat colours; +//! `colrv1` uses the OpenType paint graph and may omit `CPAL` if it does not +//! reference palette indices. +//! +//! A second `r` on the same `cp` overwrites the previous registration. +//! `glyf` outlines render in the current foreground colour. +//! +//! ## Clear (`c`) +//! +//! Removes registrations. +//! +//! Single slot: `ESC _ 25a1 ; c ; cp= ESC \` +//! All slots: `ESC _ 25a1 ; c ESC \` +//! +//! The terminal acks with `status=0` even if the slot was already empty. +//! Clear replies do not echo `cp`. `cp` must be in a PUA range; non-PUA values return +//! `reason=out_of_namespace`. +//! +//! ## Glossary Capacity +//! +//! Each session holds at most 1024 registrations keyed by codepoint. +//! Registrations live for the session duration. A 1025th registration +//! evicts the oldest entry (FIFO). Sessions are isolated: two tabs may +//! independently register the same codepoint. +//! +//! ## Security: PUA-Only Restriction +//! +//! Registration is restricted to the three Unicode Private Use Areas to +//! prevent glyph-spoofing attacks. PUA codepoints never appear in normal +//! text (filenames, URLs, commands), so a registered glyph cannot alter +//! how real text is perceived. The cell buffer always stores the original +//! codepoint — copy/paste, search, and hyperlink detection return the +//! codepoint the application emitted, never the rendered glyph. +//! +//! Reference: + +const std = @import("std"); + +pub const request = @import("glyph/request.zig"); +pub const response = @import("glyph/response.zig"); + +pub const CommandParser = request.CommandParser; +pub const Request = request.Request; +pub const Response = response.Response; diff --git a/src/terminal/apc/glyph/request.zig b/src/terminal/apc/glyph/request.zig new file mode 100644 index 000000000..4c50525fc --- /dev/null +++ b/src/terminal/apc/glyph/request.zig @@ -0,0 +1,726 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; + +/// Stateful parser for a single glyph APC payload after the `25a1;` prefix. +pub const CommandParser = struct { + alloc: Allocator, + data: std.ArrayList(u8) = .empty, + + /// Maximum bytes the data payload can buffer. This is to prevent + /// malicious input from causing us to allocate too much memory. + max_bytes: usize, + + pub const Error = Allocator.Error || error{InvalidFormat}; + + /// Create a glyph APC parser that buffers the raw command bytes. + pub fn init(alloc: Allocator, max_bytes: usize) CommandParser { + return .{ .alloc = alloc, .max_bytes = max_bytes }; + } + + /// Release any buffered command bytes owned by the parser. + pub fn deinit(self: *CommandParser) void { + self.data.deinit(self.alloc); + } + + /// Append one more byte of APC payload to the buffered command. + pub fn feed(self: *CommandParser, byte: u8) Allocator.Error!void { + if (self.data.items.len >= self.max_bytes) return error.OutOfMemory; + try self.data.append(self.alloc, byte); + } + + /// Finish parsing and return an owned request that can outlive the parser. + pub fn complete(self: *CommandParser, alloc: Allocator) Error!Request { + // Normalize bare single-byte verbs like `s` into `s;` so the parsed + // command always has the standard `verb;...` layout. + if (self.data.items.len == 1) try self.data.append(self.alloc, ';'); + + const raw = try self.data.toOwnedSlice(alloc); + + // Ownership of the buffered bytes has moved to `raw`, so clear the + // array list before we build the final command value. + self.data = .empty; + errdefer alloc.free(raw); + return try Request.parse(alloc, raw); + } +}; + +/// Parsed glyph APC request with the verb classified eagerly. +pub const Request = union(enum) { + /// Support query (bare `s` verb, no options). + support, + + /// Codepoint coverage query. + query: Query, + + /// Glyph registration request. + register: Register, + + /// Registration clear request. + clear: Clear, + + /// Query verb payload with lazily-decoded options. + pub const Query = struct { + raw: []const u8, + + /// Initialize a query command from owned raw command bytes. + pub fn init(raw: []const u8) Query { + return .{ .raw = raw }; + } + + /// Options recognized for the glyph query request. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Return the decoded Zig type for a query option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + }; + } + + /// Return the wire-format option key for this query option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a query option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + }; + } + }; + + /// Lazily decode a query option on demand. + pub fn get(self: Query, comptime option: Option) ?option.Type() { + return option.read(self.rawOptions()); + } + + /// Return the raw option portion of a valid query command. + fn rawOptions(self: Query) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'q'); + assert(self.raw[1] == ';'); + return self.raw[2..]; + } + }; + + /// Register verb payload with lazily-decoded options and optional base64 data. + pub const Register = struct { + raw: []const u8, + payload_idx: usize, + + /// Initialize a register command from owned raw command bytes. + pub fn init(raw: []const u8) Register { + assert(raw.len >= 2); + assert(raw[0] == 'r'); + assert(raw[1] == ';'); + const payload_idx = std.mem.lastIndexOfScalar(u8, raw, ';').?; + assert(payload_idx > 1); + + return .{ + .raw = raw, + .payload_idx = payload_idx, + }; + } + + /// Options recognized for the glyph register verb. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Glyph payload format. + fmt, + + /// Requested reply verbosity for registration. + reply, + + /// Units-per-em for the glyph coordinate system. + upm, + + /// Authored advance width in units-per-em units. + aw, + + /// Authored line height in units-per-em units. + lh, + + /// Unicode cell width for terminal layout. + width, + + /// Glyph scale policy. + size, + + /// Glyph placement within the render span. + @"align", + + /// Fractional insets from the render span edges. + pad, + + /// Return the decoded Zig type for a register option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + .fmt => Format, + .reply => Reply, + .upm => u32, + .aw => u32, + .lh => u32, + .width => Width, + .size => Size, + .@"align" => Align, + .pad => Pad, + }; + } + + /// Return the protocol default value for this option, if any. + pub fn default(comptime self: Option) ?self.Type() { + return switch (self) { + .cp => null, + .fmt => .glyf, + .reply => .all, + .upm => 1000, + .aw => null, + .lh => null, + .width => .narrow, + .size => .height, + .@"align" => .{}, + .pad => .{}, + }; + } + + /// Return the wire-format option key for this register option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a register option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + .fmt => Format.init(value), + .reply => Reply.init(value) orelse .all, + .upm => std.fmt.parseInt(u32, value, 10) catch null, + .aw => std.fmt.parseInt(u32, value, 10) catch null, + .lh => std.fmt.parseInt(u32, value, 10) catch null, + .width => Width.init(value), + .size => Size.init(value), + .@"align" => Align.init(value), + .pad => Pad.init(value), + }; + } + }; + + /// Lazily decode a register option on demand, applying protocol + /// defaults when the option is omitted. + pub fn get(self: Register, comptime option: Option) ?option.Type() { + const raw = self.rawOptions(); + if (optionValue(raw, option.key()) == null) { + return switch (option) { + .aw, .lh => self.get(.upm), + else => option.default(), + }; + } + return option.read(raw); + } + + /// Return the base64 payload carried by a register request. + /// + /// If no payload is present, this returns an empty slice. The returned + /// bytes may still be invalid base64; this function only exposes the raw + /// payload segment and does not validate or decode it. + pub fn payload(self: Register) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'r'); + assert(self.raw[1] == ';'); + return if (self.payload_idx == self.raw.len) + "" + else + self.raw[self.payload_idx + 1 ..]; + } + + /// Return the raw option portion of a valid register command. + fn rawOptions(self: Register) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'r'); + assert(self.raw[1] == ';'); + assert(self.payload_idx >= 2); + assert(self.payload_idx <= self.raw.len); + return self.raw[2..self.payload_idx]; + } + }; + + /// Clear verb payload with lazily-decoded options. + pub const Clear = struct { + raw: []const u8, + + /// Initialize a clear command from owned raw command bytes. + pub fn init(raw: []const u8) Clear { + return .{ .raw = raw }; + } + + /// Options recognized for the glyph clear request. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Return the decoded Zig type for a clear option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + }; + } + + /// Return the wire-format option key for this clear option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a clear option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + }; + } + }; + + /// Lazily decode a clear option on demand. + pub fn get(self: Clear, comptime option: Option) ?option.Type() { + return option.read(self.rawOptions()); + } + + /// Return the raw option portion of a valid clear command. + fn rawOptions(self: Clear) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'c'); + assert(self.raw[1] == ';'); + return self.raw[2..]; + } + }; + + /// Parse an owned glyph APC payload into its eagerly-classified request + /// form. + /// + /// The raw format here is strict on its requirements to avoid + /// edge cases: it must contain the request AND the request must + /// end in a semicolon (even if there are no options). The spec itself + /// does not require this but we artificially insert it in our parser + /// to simplify parsing later. + pub fn parse(alloc: Allocator, raw: []const u8) error{InvalidFormat}!Request { + if (raw.len < 2) return error.InvalidFormat; + if (raw[1] != ';') return error.InvalidFormat; + + return switch (raw[0]) { + 's' => { + alloc.free(raw); + return .support; + }, + 'q' => .{ .query = .init(raw) }, + 'r' => .{ .register = .init(raw) }, + 'c' => .{ .clear = .init(raw) }, + else => error.InvalidFormat, + }; + } + + /// Free the raw bytes retained by any request variant. + pub fn deinit(self: *Request, alloc: Allocator) void { + switch (self.*) { + .support => {}, + inline else => |*cmd| if (cmd.raw.len > 0) alloc.free(cmd.raw), + } + } +}; + +/// Glyph payload formats named by the protocol. +pub const Format = enum { + /// TrueType simple glyph outline data. + glyf, + + /// OpenType COLR version 0 layered color glyph data. + colrv0, + + /// OpenType COLR version 1 paint graph glyph data. + colrv1, + + /// Parse a glyph payload format name. + pub fn init(value: []const u8) ?Format { + return std.meta.stringToEnum(Format, value); + } +}; + +/// Register command reply verbosity. +pub const Reply = enum(u2) { + /// Suppress both success and failure replies. + none = 0, + + /// Emit replies for both success and failure cases. + all = 1, + + /// Emit replies only for failure cases. + failures = 2, + + /// Parse the register command reply mode from its single-digit encoding. + pub fn init(value: []const u8) ?Reply { + if (value.len != 1) return null; + return switch (value[0]) { + '0' => .none, + '1' => .all, + '2' => .failures, + else => null, + }; + } +}; + +/// Register command width override for terminal layout. +pub const Width = enum(u2) { + /// One terminal cell. + narrow = 1, + + /// Two terminal cells. + wide = 2, + + /// Parse the register command width from its single-digit encoding. + pub fn init(value: []const u8) ?Width { + if (value.len != 1) return null; + return switch (value[0]) { + '1' => .narrow, + '2' => .wide, + else => null, + }; + } +}; + +/// Register command glyph scale policy. +pub const Size = enum { + height, + advance, + contain, + cover, + stretch, + + /// Parse a glyph scale policy name. + pub fn init(value: []const u8) ?Size { + return std.meta.stringToEnum(Size, value); + } +}; + +/// Register command glyph placement within the render span. +pub const Align = struct { + horizontal: Horizontal = .center, + vertical: Vertical = .center, + + pub const Horizontal = enum { + start, + center, + end, + + fn init(value: []const u8) ?Horizontal { + return std.meta.stringToEnum(Horizontal, value); + } + }; + + pub const Vertical = enum { + start, + center, + end, + baseline, + + fn init(value: []const u8) ?Vertical { + return std.meta.stringToEnum(Vertical, value); + } + }; + + /// Parse an align value in `,` form. + pub fn init(value: []const u8) ?Align { + var it = std.mem.splitScalar(u8, value, ','); + const horizontal = Horizontal.init(it.next() orelse return null) orelse return null; + const vertical = Vertical.init(it.next() orelse return null) orelse return null; + if (it.next() != null) return null; + + return .{ + .horizontal = horizontal, + .vertical = vertical, + }; + } +}; + +/// Register command fractional insets from the render span edges. +pub const Pad = struct { + top: f64 = 0, + right: f64 = 0, + bottom: f64 = 0, + left: f64 = 0, + + /// Parse a pad value in `,,,` form. + pub fn init(value: []const u8) ?Pad { + var it = std.mem.splitScalar(u8, value, ','); + const top = parseFraction(it.next() orelse return null) orelse return null; + const right = parseFraction(it.next() orelse return null) orelse return null; + const bottom = parseFraction(it.next() orelse return null) orelse return null; + const left = parseFraction(it.next() orelse return null) orelse return null; + if (it.next() != null) return null; + + // Glyph Protocol §8.5.2: "If `l + r ≥ 1` or `t + b ≥ 1` + // the terminal MUST treat the request as if `pad=0,0,0,0`." + if (left + right >= 1 or top + bottom >= 1) return .{}; + + return .{ + .top = top, + .right = right, + .bottom = bottom, + .left = left, + }; + } + + /// Parse one pad component from the spec's `0.0`–`1.0` fractional range. + /// Top/bottom fractions are relative to cell height; left/right fractions + /// are relative to render span width. + fn parseFraction(value: []const u8) ?f64 { + const result = std.fmt.parseFloat(f64, value) catch return null; + if (!(result >= 0 and result <= 1)) return null; + return result; + } +}; + +/// Find the last occurrence of `key=value` for a lazily-parsed option list. +fn optionValue(raw: []const u8, comptime key: []const u8) ?[]const u8 { + var remaining = raw; + var result: ?[]const u8 = null; + while (remaining.len > 0) { + // Options are semicolon-delimited, so each loop peels off one segment + // and checks whether it matches the requested key. + const len = std.mem.indexOfScalar(u8, remaining, ';') orelse remaining.len; + const full = remaining[0..len]; + + if (std.mem.indexOfScalar(u8, full, '=')) |eql_idx| { + if (std.mem.eql(u8, full[0..eql_idx], key)) { + result = full[eql_idx + 1 ..]; + } + } + + if (len == remaining.len) break; + remaining = remaining[len + 1 ..]; + } + + return result; +} + +fn testParse(alloc: Allocator, data: []const u8) CommandParser.Error!Request { + var parser = CommandParser.init(alloc, 1024 * 1024); + defer parser.deinit(); + for (data) |byte| try parser.feed(byte); + return try parser.complete(alloc); +} + +test "support command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "s"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .support); +} + +test "query command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "q;cp=E0A0"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .query); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.query.get(.cp).?); +} + +test "register command with payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;fmt=glyf;upm=1000;reply=2;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?); + try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?); + try testing.expectEqualStrings("QQ==", cmd.register.payload()); +} + +test "register command with sizing and placement options" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;upm=2048;aw=1024;lh=1536;width=2;size=contain;align=end,baseline;pad=0.1,0.2,0.3,0.4;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.upm).?); + try testing.expectEqual(@as(u32, 1024), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 1536), cmd.register.get(.lh).?); + try testing.expectEqual(Width.wide, cmd.register.get(.width).?); + try testing.expectEqual(Size.contain, cmd.register.get(.size).?); + try testing.expectEqual(Align{ + .horizontal = .end, + .vertical = .baseline, + }, cmd.register.get(.@"align").?); + try testing.expectEqual(Pad{ + .top = 0.1, + .right = 0.2, + .bottom = 0.3, + .left = 0.4, + }, cmd.register.get(.pad).?); + try testing.expectEqualStrings("QQ==", cmd.register.payload()); +} + +test "register option defaults" { + const testing = std.testing; + const Option = Request.Register.Option; + + try testing.expect(Option.cp.default() == null); + try testing.expectEqual(Format.glyf, Option.fmt.default().?); + try testing.expectEqual(@as(u32, 1000), Option.upm.default().?); + try testing.expect(Option.aw.default() == null); + try testing.expect(Option.lh.default() == null); + try testing.expectEqual(Width.narrow, Option.width.default().?); + try testing.expectEqual(Size.height, Option.size.default().?); + try testing.expectEqual(Align{}, Option.@"align".default().?); + try testing.expectEqual(Pad{}, Option.pad.default().?); + try testing.expectEqual(Reply.all, Option.reply.default().?); +} + +test "register command defaults" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.lh).?); + try testing.expectEqual(Width.narrow, cmd.register.get(.width).?); + try testing.expectEqual(Size.height, cmd.register.get(.size).?); + try testing.expectEqual(Align{}, cmd.register.get(.@"align").?); + try testing.expectEqual(Pad{}, cmd.register.get(.pad).?); + try testing.expectEqual(Reply.all, cmd.register.get(.reply).?); +} + +test "register command aw and lh default to upm" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;upm=2048;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.lh).?); +} + +test "register command invalid sizing and placement options" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;width=3;size=invalid;align=center,middle;pad=0,1.2,0,0;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expect(cmd.register.get(.width) == null); + try testing.expect(cmd.register.get(.size) == null); + try testing.expect(cmd.register.get(.@"align") == null); + try testing.expect(cmd.register.get(.pad) == null); +} + +test "register command degenerate padding defaults to no padding" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;pad=0.4,0.2,0.6,0.1;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Pad{}, cmd.register.get(.pad).?); +} + +test "register command invalid reply falls back to reply=1" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=9;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Reply.all, cmd.register.get(.reply).?); +} + +test "register command duplicate options use the last value" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=1;reply=2;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?); +} + +test "register command with invalid payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;fmt=glyf;%%%not-base64%%%", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqualStrings("%%%not-base64%%%", cmd.register.payload()); +} + +test "register response without payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=E0A0;status=4;reason=out_of_namespace", + ); + defer cmd.deinit(testing.allocator); + + // Register parsing is request-only, so the final segment is always treated + // as payload rather than as a response field. + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqualStrings("reason=out_of_namespace", cmd.register.payload()); +} + +test "clear command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "c;cp=e0a0"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .clear); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.clear.get(.cp).?); +} + +test "invalid command" { + const testing = std.testing; + + try testing.expectError( + error.InvalidFormat, + testParse(testing.allocator, "x"), + ); +} diff --git a/src/terminal/apc/glyph/response.zig b/src/terminal/apc/glyph/response.zig new file mode 100644 index 000000000..4ed52b0b2 --- /dev/null +++ b/src/terminal/apc/glyph/response.zig @@ -0,0 +1,334 @@ +const std = @import("std"); + +/// Query response coverage state for a codepoint. +pub const Coverage = packed struct(u2) { + /// A system font covers the codepoint. + system: bool = false, + + /// A session glyph registration covers the codepoint. + glossary: bool = false, + + /// No system font or registered glyph covers the codepoint. + pub const free: Coverage = .{}; + + /// Parse the query response coverage list from its comma-separated form. + /// Unknown coverage names are ignored for forward compatibility. + pub fn init(value: []const u8) ?Coverage { + var result: Coverage = .free; + var it = std.mem.splitScalar(u8, value, ','); + while (it.next()) |name| { + if (std.mem.eql(u8, name, "system")) { + result.system = true; + } else if (std.mem.eql(u8, name, "glossary")) { + result.glossary = true; + } + } + + return result; + } +}; + +/// Response to a glyph APC request, formatted for the wire protocol. +pub const Response = union(enum) { + /// Support query response listing supported payload formats. + support: Support, + + /// Codepoint coverage query response. + query: Query, + + /// Glyph registration response (success or error). + register: Register, + + /// Registration clear response. + clear: Clear, + + /// Support query response fields. + pub const Support = struct { + /// Supported payload formats. + fmt: Formats, + + pub const Formats = packed struct(u8) { + /// TrueType simple glyph outlines (required in v1). + glyf: bool = false, + + /// COLR v0 layered flat-colour glyphs. + colrv0: bool = false, + + /// COLR v1 paint-graph glyphs. + colrv1: bool = false, + + _padding: u5 = 0, + }; + }; + + /// Codepoint query response fields. + pub const Query = struct { + /// The queried codepoint. + cp: u21, + + /// Coverage status for the codepoint. + status: Coverage, + }; + + /// Register response fields. + pub const Register = struct { + /// The target codepoint of the registration. + cp: u21, + + /// Result status of the registration encoded as a decimal u8. + status: Status = .ok, + + /// Optional symbolic error reason. + reason: ?Reason = null, + + /// Register error reason codes defined by Glyph Protocol §6.2. + pub const Reason = union(enum) { + /// `cp` is not in any PUA range. + out_of_namespace, + + /// Payload contains composite glyphs. + composite_unsupported, + + /// Payload contains hinting instructions. + hinting_unsupported, + + /// Payload failed to parse as the declared `fmt`. + malformed_payload, + + /// Payload exceeds 64 KiB after base64 decoding. + payload_too_large, + + /// A reason code not known by this version of Ghostty. + other: []const u8, + + /// Return the wire-format reason name. + pub fn name(self: Reason) []const u8 { + return switch (self) { + .out_of_namespace => "out_of_namespace", + .composite_unsupported => "composite_unsupported", + .hinting_unsupported => "hinting_unsupported", + .malformed_payload => "malformed_payload", + .payload_too_large => "payload_too_large", + .other => |value| value, + }; + } + }; + }; + + /// Clear response fields. + pub const Clear = struct { + /// Result status of the clear operation encoded as a decimal u8. + status: Status = .ok, + + /// Optional symbolic error reason. + reason: ?[]const u8 = null, + }; + + /// Status code for register and clear responses. + pub const Status = enum(u8) { + /// The operation completed successfully. + ok = 0, + + /// A generic or unspecified error occurred. + err = 1, + + _, + }; + + /// Write the response in the glyph APC wire format to `writer`. + /// + /// The framing is: `ESC _ 25a1 ; ; * ESC \` + pub fn formatWire( + self: Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try writer.writeAll("\x1b_25a1;"); + switch (self) { + .support => |r| { + // From the spec: + // Order is not significant; clients MUST treat the value as a + // set. An empty fmt= value means the terminal recognises + // Glyph Protocol but currently advertises no payload formats + // — every r will be rejected. Clients MUST ignore names they + // do not recognise rather than failing the reply, so future + // format names are forward- compatible. + try writer.writeAll("s;fmt="); + var first = true; + if (r.fmt.glyf) { + first = false; + try writer.writeAll("glyf"); + } + if (r.fmt.colrv0) { + if (!first) try writer.writeByte(','); + first = false; + try writer.writeAll("colrv0"); + } + if (r.fmt.colrv1) { + if (!first) try writer.writeByte(','); + first = false; + try writer.writeAll("colrv1"); + } + }, + .query => |r| { + // status is a comma-separated list of coverage names — the + // set of sources that can render cp in this session. Order is + // not significant; clients MUST treat the value as a set. + try writer.print("q;cp={x};status=", .{r.cp}); + var first = true; + if (r.status.system) { + first = false; + try writer.writeAll("system"); + } + if (r.status.glossary) { + if (!first) try writer.writeByte(','); + try writer.writeAll("glossary"); + } + }, + .register => |r| { + try writer.print("r;cp={x};status={d}", .{ r.cp, @intFromEnum(r.status) }); + if (r.reason) |reason| { + try writer.writeAll(";reason="); + try writer.writeAll(reason.name()); + } + }, + .clear => |r| { + try writer.print("c;status={d}", .{@intFromEnum(r.status)}); + if (r.reason) |reason| { + try writer.writeAll(";reason="); + try writer.writeAll(reason); + } + }, + } + try writer.writeAll("\x1b\\"); + } +}; + +test "support formats default to no advertised formats" { + const testing = std.testing; + const Formats = Response.Support.Formats; + + try testing.expectEqual(@as(u8, 0), @as(u8, @bitCast(Formats{}))); +} + +test "response support formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .support = .{ .fmt = .{ .glyf = true, .colrv0 = true } } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;s;fmt=glyf,colrv0\x1b\\", writer.buffered()); +} + +test "response support formatWire with no formats" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .support = .{ .fmt = .{} } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;s;fmt=\x1b\\", writer.buffered()); +} + +test "response query formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{ .system = true, .glossary = true } } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=system,glossary\x1b\\", writer.buffered()); +} + +test "response query formatWire with no coverage" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{} } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=\x1b\\", writer.buffered()); +} + +test "coverage parses comma-separated names" { + const testing = std.testing; + + try testing.expectEqual(Coverage{}, Coverage.init("").?); + try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system").?); + try testing.expectEqual(Coverage{ .glossary = true }, Coverage.init("glossary").?); + try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("system,glossary").?); + try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("glossary,system").?); + try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system,future").?); + try testing.expectEqual(Coverage{}, Coverage.init("future").?); +} + +test "response register success formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0 } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=0\x1b\\", writer.buffered()); +} + +test "response register error formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = .err, .reason = .out_of_namespace } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=1;reason=out_of_namespace\x1b\\", writer.buffered()); +} + +test "response register arbitrary status formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = @enumFromInt(37), .reason = .payload_too_large } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=37;reason=payload_too_large\x1b\\", writer.buffered()); +} + +test "register reason names" { + const testing = std.testing; + const Reason = Response.Register.Reason; + + try testing.expectEqualStrings("out_of_namespace", Reason.out_of_namespace.name()); + try testing.expectEqualStrings("composite_unsupported", Reason.composite_unsupported.name()); + try testing.expectEqualStrings("hinting_unsupported", Reason.hinting_unsupported.name()); + try testing.expectEqualStrings("malformed_payload", Reason.malformed_payload.name()); + try testing.expectEqualStrings("payload_too_large", Reason.payload_too_large.name()); + try testing.expectEqualStrings("future_reason", (Reason{ .other = "future_reason" }).name()); +} + +test "response clear formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .clear = .{} }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;c;status=0\x1b\\", writer.buffered()); +} + +test "response clear error formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .clear = .{ .status = .err, .reason = "out_of_namespace" } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;c;status=1;reason=out_of_namespace\x1b\\", writer.buffered()); +} diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index f68f088bf..51ef63422 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -679,6 +679,8 @@ pub const Handler = struct { if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]); } }, + + .glyph => {}, } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index fb3a6b3ff..cb6305546 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -559,6 +559,8 @@ pub const StreamHandler = struct { } } }, + + .glyph => {}, } }