mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-06-15 08:03:56 +00:00
terminal: glyph protocol Glossary entry starting to take shape
This commit is contained in:
@@ -152,7 +152,13 @@ const std = @import("std");
|
||||
|
||||
pub const request = @import("glyph/request.zig");
|
||||
pub const response = @import("glyph/response.zig");
|
||||
pub const execute = @import("glyph/execute.zig").execute;
|
||||
|
||||
pub const CommandParser = request.CommandParser;
|
||||
pub const Glossary = @import("glyph/Glossary.zig");
|
||||
pub const Request = request.Request;
|
||||
pub const Response = response.Response;
|
||||
|
||||
test {
|
||||
std.testing.refAllDecls(@This());
|
||||
}
|
||||
|
||||
230
src/terminal/apc/glyph/Glossary.zig
Normal file
230
src/terminal/apc/glyph/Glossary.zig
Normal file
@@ -0,0 +1,230 @@
|
||||
/// Glossary is the per-terminal storage for Glyph Protocol
|
||||
/// codepoints. We use the word Glossary to match up with the spec which
|
||||
/// also uses this word.
|
||||
const Glossary = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const CircBuf = @import("../../../datastruct/circ_buf.zig").CircBuf;
|
||||
const face = @import("../../../font/face.zig");
|
||||
const Glyf = @import("../../../font/opentype/glyf.zig").Glyf;
|
||||
const glyf_rasterize = @import("../../../font/glyf_rasterize.zig");
|
||||
|
||||
const request = @import("request.zig");
|
||||
const RegisterReq = request.Request.Register;
|
||||
|
||||
/// The set of entries in the glossary keyed by the codepoint.
|
||||
///
|
||||
/// The array hash map preserves insertion order and has O(N)
|
||||
/// orderedRemove, so we use it as a FIFO too for eviction when
|
||||
/// the glossary is full. Since the specification limits the protocol
|
||||
/// to 1024 maximum entries, ordered removal should never be that
|
||||
/// expensive.
|
||||
///
|
||||
/// I'm also operating under the assumption that full glossaries
|
||||
/// for a session will be rare, so the eviction cost shouldn't
|
||||
/// happen regularly.
|
||||
entries: std.AutoArrayHashMap(u21, Entry),
|
||||
|
||||
/// A single glyph registration entry.
|
||||
pub const Entry = struct {
|
||||
/// Stored glyph payload variants.
|
||||
pub const Glyph = union(enum) {
|
||||
glyf: Glyf.Outline,
|
||||
};
|
||||
|
||||
/// The glyph itself. The tagged union only has glyf right now but
|
||||
/// will eventually expand to support COLR and maybe other formats.
|
||||
/// These are stored as raw outlines; rasterization is delayed to
|
||||
/// renderers. The outlines have been validated.
|
||||
glyph: Glyph,
|
||||
|
||||
/// Authored metrics for the glyph's design coordinate space.
|
||||
design: glyf_rasterize.DesignMetrics,
|
||||
|
||||
/// Unicode cell width requested by the registration.
|
||||
width: request.Width,
|
||||
|
||||
/// Normalized scale, alignment, and padding behavior for rasterization.
|
||||
constraint: face.RenderOptions.Constraint,
|
||||
|
||||
/// Errors that can occur while constructing a glossary entry from a
|
||||
/// register request.
|
||||
pub const InitError = RegisterReq.DecodeError || error{
|
||||
/// The register request is missing a required option or has an invalid
|
||||
/// explicitly-provided option value.
|
||||
InvalidOptions,
|
||||
|
||||
/// `cp` is not in any PUA range.
|
||||
OutOfNamespace,
|
||||
|
||||
/// The requested payload format is not supported by this glossary.
|
||||
UnsupportedFormat,
|
||||
};
|
||||
|
||||
/// Initialize a glossary entry from a register request.
|
||||
///
|
||||
/// This validates the request fields needed to construct the entry,
|
||||
/// decodes the base64 glyph payload, and stores the decoded outline. The
|
||||
/// returned entry owns decoded glyph memory and must be released with
|
||||
/// `deinit`.
|
||||
pub fn init(alloc: Allocator, register: RegisterReq) InitError!Entry {
|
||||
// Validate codepoint
|
||||
const cp = register.get(.cp) orelse return error.InvalidOptions;
|
||||
if (!isPrivateUse(cp)) return error.OutOfNamespace;
|
||||
|
||||
// Validate format
|
||||
const fmt = register.get(.fmt) orelse return error.InvalidOptions;
|
||||
const design: glyf_rasterize.DesignMetrics = .{
|
||||
.units_per_em = register.get(.upm) orelse return error.InvalidOptions,
|
||||
.advance_width = register.get(.aw) orelse return error.InvalidOptions,
|
||||
.line_height = register.get(.lh) orelse return error.InvalidOptions,
|
||||
};
|
||||
if (design.units_per_em == 0 or
|
||||
design.advance_width == 0 or
|
||||
design.line_height == 0) return error.InvalidOptions;
|
||||
const width = register.get(.width) orelse return error.InvalidOptions;
|
||||
|
||||
// Get our constraints
|
||||
const constraint = try constraintFromRegister(register);
|
||||
|
||||
// Decode the payload into some usable glyph format for
|
||||
// future rasterization.
|
||||
const glyph: Glyph = switch (fmt) {
|
||||
.glyf => .{ .glyf = try register.decodeGlyfPayload(alloc) },
|
||||
.colrv0, .colrv1 => return error.UnsupportedFormat,
|
||||
};
|
||||
|
||||
// No more errors, since we never do glyph cleanup above.
|
||||
errdefer comptime unreachable;
|
||||
|
||||
return .{
|
||||
.glyph = glyph,
|
||||
.design = design,
|
||||
.width = width,
|
||||
.constraint = constraint,
|
||||
};
|
||||
}
|
||||
|
||||
/// Release memory owned by this entry.
|
||||
pub fn deinit(self: *Entry, alloc: Allocator) void {
|
||||
switch (self.glyph) {
|
||||
.glyf => |*outline| outline.deinit(alloc),
|
||||
}
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Return the renderer constraint for a register request.
|
||||
///
|
||||
/// Glyph Protocol §8.5 defines sizing, alignment, and padding in terms of
|
||||
/// the authored extent and render span. Ghostty's existing constraint type
|
||||
/// is the closest renderer-native representation for these controls, but
|
||||
/// it does not have exact equivalents for every protocol size mode, so this
|
||||
/// function is the single normalization point for those policy choices.
|
||||
fn constraintFromRegister(
|
||||
register: RegisterReq,
|
||||
) error{InvalidOptions}!face.RenderOptions.Constraint {
|
||||
// Register.get applies the Glyph Protocol §6.1 defaults when options
|
||||
// are omitted: size=height, align=center,center, and pad=0,0,0,0.
|
||||
const size = register.get(.size) orelse return error.InvalidOptions;
|
||||
const alignment = register.get(.@"align") orelse return error.InvalidOptions;
|
||||
const pad = register.get(.pad) orelse return error.InvalidOptions;
|
||||
|
||||
return .{
|
||||
.size = switch (size) {
|
||||
// The rasterizer's base transform already maps the design em
|
||||
// to the cell height. That is the closest existing behavior to
|
||||
// the protocol's default height-driven mode.
|
||||
.height => .none,
|
||||
// There is no width-driven, aspect-preserving constraint mode
|
||||
// today. Leave the base transform intact rather than forcing a
|
||||
// fit/contain policy that would unexpectedly prevent overflow.
|
||||
.advance => .none,
|
||||
// Constraint.cover currently scales preserving aspect ratio to
|
||||
// the available bounds, which is the best existing match for
|
||||
// the protocol's contain mode.
|
||||
.contain => .cover,
|
||||
// There is no true protocol-cover equivalent that chooses the
|
||||
// larger axis scale, so use the nearest named renderer policy.
|
||||
.cover => .cover,
|
||||
.stretch => .stretch,
|
||||
},
|
||||
.align_horizontal = switch (alignment.horizontal) {
|
||||
.start => .start,
|
||||
.center => .center,
|
||||
.end => .end,
|
||||
},
|
||||
.align_vertical = switch (alignment.vertical) {
|
||||
.start => .start,
|
||||
.center => .center,
|
||||
.end => .end,
|
||||
// The current constraint API has no baseline alignment mode.
|
||||
// Start is the closest stable default because the glyf
|
||||
// rasterizer's coordinate model already treats y=0 as the
|
||||
// baseline/bottom before constraints are applied.
|
||||
.baseline => .start,
|
||||
},
|
||||
.pad_top = pad.top,
|
||||
.pad_right = pad.right,
|
||||
.pad_bottom = pad.bottom,
|
||||
.pad_left = pad.left,
|
||||
};
|
||||
}
|
||||
|
||||
/// Return true if `cp` is in one of the Unicode Private Use Areas.
|
||||
fn isPrivateUse(cp: u21) bool {
|
||||
return (cp >= 0xE000 and cp <= 0xF8FF) or
|
||||
(cp >= 0xF0000 and cp <= 0xFFFFD) or
|
||||
(cp >= 0x100000 and cp <= 0x10FFFD);
|
||||
}
|
||||
};
|
||||
|
||||
fn testParseRegister(alloc: Allocator, data: []const u8) !RegisterReq {
|
||||
const raw = try alloc.dupe(u8, data);
|
||||
errdefer alloc.free(raw);
|
||||
|
||||
const req = try request.Request.parse(alloc, raw);
|
||||
switch (req) {
|
||||
.register => |register| return register,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
test "Entry init decodes glyf payload and applies register fields" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const register = try testParseRegister(
|
||||
alloc,
|
||||
"r;cp=e000;upm=2048;aw=1024;lh=1536;width=2;size=stretch;align=end,start;pad=0.1,0.2,0.3,0.4;AAAAAAAAAAAAAA==",
|
||||
);
|
||||
defer alloc.free(register.raw);
|
||||
|
||||
var entry = try Entry.init(alloc, register);
|
||||
defer entry.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(@as(u32, 2048), entry.design.units_per_em);
|
||||
try testing.expectEqual(@as(u32, 1024), entry.design.advance_width);
|
||||
try testing.expectEqual(@as(u32, 1536), entry.design.line_height);
|
||||
try testing.expectEqual(request.Width.wide, entry.width);
|
||||
try testing.expectEqual(face.RenderOptions.Constraint.Size.stretch, entry.constraint.size);
|
||||
try testing.expectEqual(face.RenderOptions.Constraint.Align.end, entry.constraint.align_horizontal);
|
||||
try testing.expectEqual(face.RenderOptions.Constraint.Align.start, entry.constraint.align_vertical);
|
||||
try testing.expectEqual(@as(f64, 0.1), entry.constraint.pad_top);
|
||||
try testing.expectEqual(@as(f64, 0.2), entry.constraint.pad_right);
|
||||
try testing.expectEqual(@as(f64, 0.3), entry.constraint.pad_bottom);
|
||||
try testing.expectEqual(@as(f64, 0.4), entry.constraint.pad_left);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), entry.glyph.glyf.points.len);
|
||||
try testing.expectEqual(@as(usize, 0), entry.glyph.glyf.contours.len);
|
||||
}
|
||||
|
||||
test "Entry init rejects invalid register payload" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const register = try testParseRegister(alloc, "r;cp=e000;%%%not-base64%%%");
|
||||
defer alloc.free(register.raw);
|
||||
|
||||
try testing.expectError(error.MalformedPayload, Entry.init(alloc, register));
|
||||
}
|
||||
38
src/terminal/apc/glyph/execute.zig
Normal file
38
src/terminal/apc/glyph/execute.zig
Normal file
@@ -0,0 +1,38 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const request = @import("request.zig");
|
||||
const response = @import("response.zig");
|
||||
const Glossary = @import("Glossary.zig");
|
||||
const Request = request.Request;
|
||||
const Response = response.Response;
|
||||
|
||||
const log = std.log.scoped(.glyph);
|
||||
|
||||
/// Payload formats we support. Hardcoded because the support is
|
||||
/// fixed.
|
||||
pub const supported_formats: response.Response.Support.Formats = .{
|
||||
.glyf = true,
|
||||
};
|
||||
|
||||
/// Execute a Glyph protocol request against the given state.
|
||||
///
|
||||
/// This will never fail, but the response may indiciate an error and
|
||||
/// the terminal state may not be updated to reflect the command. This will
|
||||
/// never put the terminal in a corrupt or non-recoverable state.
|
||||
///
|
||||
/// For example, allocation errors can happen, but they're wrapped up in
|
||||
/// an out of memory response.
|
||||
pub fn execute(
|
||||
alloc: Allocator,
|
||||
glossary: *Glossary,
|
||||
req: *const Request,
|
||||
) ?Response {
|
||||
_ = alloc;
|
||||
_ = glossary;
|
||||
log.debug("executing glyph protocol request: {t}", .{req.*});
|
||||
return switch (req.*) {
|
||||
.support => .{ .support = .{ .fmt = supported_formats } },
|
||||
.query, .register, .clear => @panic("TODO"),
|
||||
};
|
||||
}
|
||||
@@ -303,11 +303,11 @@ 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("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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user