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..2e99a8b7b --- /dev/null +++ b/src/terminal/apc/glyph.zig @@ -0,0 +1,131 @@ +//! # 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 wire protocol surface implemented by the parser +//! and response formatter below. +//! +//! ## 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` bits: +//! - bit 0 (`1`): `glyf` — TrueType simple glyphs (required in v1) +//! - bit 1 (`2`): `colrv0` — COLR v0 layered flat-colour glyphs +//! - bit 2 (`4`): `colrv1` — COLR v1 paint-graph glyphs +//! +//! 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 two-bit field: +//! - `0` (`free`) — nothing renders this codepoint (tofu) +//! - `1` (`system`) — a system font covers it +//! - `2` (`glossary`) — a session registration covers it +//! - `3` (`both`) — both; the registration shadows the system font +//! +//! ## Register (`r`) +//! +//! Registers a glyph outline at a Private Use Area codepoint. +//! +//! Request: +//! `ESC _ 25a1 ; r ; cp= [; fmt=glyf] [; upm=] +//! [; reply=<0|1|2>] ; 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. +//! - `upm` — units-per-em for the coordinate space. Default 1000. +//! - `reply` — response verbosity: +//! `1` (default) = success + failure replies +//! `2` = failure replies only (silent success) +//! `0` = no replies (fire-and-forget) +//! - payload — base64-encoded `glyf` simple-glyph record. +//! +//! 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; the terminal scales to cell size. +//! +//! 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..1d17977c8 --- /dev/null +++ b/src/terminal/apc/glyph/request.zig @@ -0,0 +1,494 @@ +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, + + /// Units-per-em for the glyph coordinate system. + upm, + + /// Requested reply verbosity for registration. + reply, + + /// Return the decoded Zig type for a register option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + .fmt => Format, + .upm => u32, + .reply => Reply, + }; + } + + /// 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, + .upm => 1000, + .reply => .all, + }; + } + + /// 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), + .upm => std.fmt.parseInt(u32, value, 10) catch null, + .reply => Reply.init(value) orelse .all, + }; + } + }; + + /// 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 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, + }; + } +}; + +/// 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 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.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(Reply.all, cmd.register.get(.reply).?); +} + +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..f00f51bdc --- /dev/null +++ b/src/terminal/apc/glyph/response.zig @@ -0,0 +1,218 @@ +const std = @import("std"); + +/// Query response coverage state for a codepoint. +pub const Coverage = enum(u2) { + /// No system font or registered glyph covers the codepoint. + free = 0, + + /// A system font covers the codepoint. + system = 1, + + /// A session glyph registration covers the codepoint. + glossary = 2, + + /// Both the system font and a session registration cover the codepoint. + both = 3, + + /// Parse the query response coverage bitfield from its decimal form. + pub fn init(value: []const u8) ?Coverage { + const raw = std.fmt.parseInt(u2, value, 10) catch return null; + return std.meta.intToEnum(Coverage, raw) catch null; + } +}; + +/// 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 (e.g. `out_of_namespace`). + reason: ?[]const u8 = null, + }; + + /// 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| { + try writer.writeAll("s;fmt="); + try writer.print("{d}", .{@as(u8, @bitCast(r.fmt))}); + }, + .query => |r| { + try writer.print("q;cp={x};status={d}", .{ r.cp, @intFromEnum(r.status) }); + }, + .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); + } + }, + .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 bit layout" { + const testing = std.testing; + const Formats = Response.Support.Formats; + + try testing.expectEqual(@as(u8, 1), @as(u8, @bitCast(Formats{ .glyf = true }))); + try testing.expectEqual(@as(u8, 2), @as(u8, @bitCast(Formats{ .colrv0 = true }))); + try testing.expectEqual(@as(u8, 4), @as(u8, @bitCast(Formats{ .colrv1 = true }))); + try testing.expectEqual(@as(u8, 7), @as(u8, @bitCast(Formats{ .glyf = true, .colrv0 = true, .colrv1 = true }))); +} + +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=3\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 = .both } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=3\x1b\\", writer.buffered()); +} + +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 "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 => {}, } }