diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 0f4a294bc..9c7dc602f 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -17,6 +17,7 @@ const noMinContrast = cellpkg.noMinContrast; const constraintWidth = cellpkg.constraintWidth; const isCovering = cellpkg.isCovering; const rowNeverExtendBg = @import("row.zig").neverExtendBg; +const glyph_protocol = @import("glyph_protocol.zig"); const Overlay = @import("Overlay.zig"); const imagepkg = @import("image.zig"); const ImageState = imagepkg.State; @@ -173,6 +174,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { font_shaper: font.Shaper, font_shaper_cache: font.ShaperCache, + /// Renderer-local snapshot of per-session Glyph Protocol entries. + glyph_protocol: glyph_protocol.State = .empty, + /// The images that we may render. images: ImageState = .empty, @@ -815,6 +819,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper.deinit(); self.font_shaper_cache.deinit(self.alloc); + self.glyph_protocol.deinit(self.alloc); self.config.deinit(); @@ -1074,6 +1079,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update our grid self.font_grid = grid; + // When our font grid changes, our cell metrics can change, + // our atlas is gone, etc. so invalid all prior glyph + // protocol rasterizations. + self.glyph_protocol.invalidateRasterized(); + // Update all our textures so that they sync on the next frame. // We can modify this without a lock because the GPU does not // touch this data. @@ -1199,6 +1209,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state.terminal.scrollViewport(.bottom); } + // Sync our Glyph Protocol state before updating the render + // state because RenderState.update consumes terminal dirty + // flags, including glyph_glossary. + if (state.terminal.flags.dirty.glyph_glossary) { + self.glyph_protocol.syncFromGlossary( + self.alloc, + &state.terminal.glyph_glossary, + ) catch |err| { + // Don't hard fail the frame update, just degrade + // to showing some invalid glyphs. + log.warn("error syncing glyph protocol state err={}", .{err}); + }; + } + // Update our terminal state try self.terminal_state.update(self.alloc, state.terminal); @@ -3173,6 +3197,59 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const cell = cell_raws[x]; const cp = cell.codepoint(); + // If we have a glyph via the glyph protocol, render that. + if (self.glyph_protocol.renderGlyph( + self.alloc, + self.font_grid, + self.grid_metrics, + cp, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.RasterizationFailed => glyph: { + // If rasterization fails for any reason, just render the + // replacement char to show some invalid character is + // here. + log.warn("error rasterizing glyph protocol glyph, rendering replacement character cp={X}", .{cp}); + const replacement = try self.font_grid.renderCodepoint( + self.alloc, + 0xFFFD, + .regular, + .text, + .{ + .grid_metrics = self.grid_metrics, + .cell_width = cell.gridWidth(), + }, + ); + + break :glyph if (replacement) |render| + render.glyph + else { + // Super weird, we should always have something for + // the replacement char. Log it and just render + // nothing I guess. + log.warn("replacement character couldn't render", .{}); + return; + }; + }, + }) |glyph| { + // If the glyph is 0 width or height, it will be invisible + // when drawn, so don't bother adding it to the buffer. + if (glyph.width == 0 or glyph.height == 0) return; + try self.cells.add(self.alloc, .text, .{ + // For now, we only support grayscale glyphs. + .atlas = .grayscale, + .grid_pos = .{ @intCast(x), @intCast(y) }, + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, + .glyph_size = .{ glyph.width, glyph.height }, + .bearings = .{ + @intCast(glyph.offset_x), + @intCast(glyph.offset_y), + }, + }); + return; + } + // Render const render = try self.font_grid.renderGlyph( self.alloc, diff --git a/src/renderer/glyph_protocol.zig b/src/renderer/glyph_protocol.zig new file mode 100644 index 000000000..7b2d55561 --- /dev/null +++ b/src/renderer/glyph_protocol.zig @@ -0,0 +1,318 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; + +const font = @import("../font/main.zig"); +const glyf_rasterize = font.glyf_rasterize; +const Glossary = @import("../terminal/main.zig").apc.glyph.Glossary; +const Glyf = @import("../font/opentype/glyf.zig").Glyf; + +/// Map of registered codepoints in renderer-local state. +pub const CodepointMap = std.AutoArrayHashMapUnmanaged(u21, Codepoint); + +/// Renderer-local Glyph Protocol state for one terminal session. +/// +/// The terminal owns the authoritative `Glossary`, but the renderer cannot +/// keep pointers into it because rendering happens outside the terminal mutex +/// and rasterized atlas metadata has renderer-specific lifetime. +/// +/// This state is a snapshot of the terminal glossary plus lazy rasterization +/// cache for glyphs that have actually appeared on screen. +pub const State = struct { + /// Registered codepoints keyed by Unicode scalar value. + codepoints: CodepointMap = .empty, + + /// Empty initial state with no registrations and no allocated storage. + pub const empty: State = .{ .codepoints = .empty }; + + /// Release all cloned glossary entries and map storage owned by this state. + pub fn deinit(self: *State, alloc: Allocator) void { + for (self.codepoints.values()) |*entry| entry.deinit(alloc); + self.codepoints.deinit(alloc); + self.* = undefined; + } + + /// Synchronize the renderer-local state from the terminal glossary. + /// + /// This does a full replacement rather than attempting to diff because the + /// glossary is spec-limited to 1024 entries and full replacement naturally + /// invalidates rasterized glyphs for clear, overwrite, and FIFO eviction. + /// If cloning fails, the previous renderer state is left intact. + pub fn syncFromGlossary( + self: *State, + alloc: Allocator, + glossary: *const Glossary, + ) Allocator.Error!void { + // Build a complete replacement first. If any clone/allocation fails, + // errdefer releases the partial map and `self` continues to point at + // the previous successfully-synced snapshot. + var new_codepoints: CodepointMap = .empty; + errdefer deinitMap(&new_codepoints, alloc); + + try new_codepoints.ensureTotalCapacity(alloc, glossary.entries.count()); + for (glossary.entries.keys(), glossary.entries.values()) |cp, *entry| { + new_codepoints.putAssumeCapacityNoClobber(cp, .{ + .entry = try entry.clone(alloc), + }); + } + + // Only after the new snapshot is complete do we release the old one. + // This makes sync failure non-destructive, which lets the renderer + // fall back to the last good snapshot under memory pressure. + deinitMap(&self.codepoints, alloc); + self.codepoints = new_codepoints; + } + + /// Invalidate rasterized atlas metadata while preserving registration + /// entries so they can be re-rasterized for a new font grid or cell size. + /// + /// Call this when the terminal grid metrics change in any way. + pub fn invalidateRasterized(self: *State) void { + for (self.codepoints.values()) |*entry| entry.rasterized = null; + } + + /// Errors that callers of `renderGlyph` can meaningfully react to. + pub const RenderError = Allocator.Error || error{ + /// The decoded glyph could not be rasterized by the renderer. + RasterizationFailed, + }; + + /// Render a registered codepoint into the shared grayscale atlas, lazily. + /// + /// The returned glyph is atlas metadata only. The underlying registration + /// entry is kept so font-grid changes can invalidate and rasterize again. + pub fn renderGlyph( + self: *State, + alloc: Allocator, + grid: *font.SharedGrid, + grid_metrics: font.Metrics, + cp: u21, + ) RenderError!?font.Glyph { + const codepoint = self.codepoints.getPtr(cp) orelse return null; + + // Fast path: the glyph was already rasterized into the current font + // atlas. `font.Glyph` is just atlas metadata, so returning it by value + // is cheap and avoids touching the cloned outline again. + if (codepoint.rasterized) |glyph| return glyph; + + const entry = &codepoint.entry; + + // Glyph Protocol `width` is the requested cell span. Use it for both + // the bitmap width and constraint span so sizing/alignment/padding are + // applied over the same 1- or 2-cell render span described by the spec. + const width_cells: u2 = switch (entry.width) { + .narrow => 1, + .wide => 2, + }; + + // Decode-time validation already guaranteed this is a supported glyf + // outline. Rasterization happens lazily because applications may + // register many glyphs that never become visible. + var bitmap = switch (entry.glyph) { + .glyf => |outline| glyf_rasterize.rasterize( + alloc, + outline, + entry.design, + .{ + .grid_metrics = grid_metrics, + .cell_width = width_cells, + .constraint = entry.constraint, + .constraint_width = width_cells, + }, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + + error.NoCurrentPoint, + error.InvalidMatrix, + error.PathNotClosed, + error.InvalidHeight, + error.InvalidWidth, + error.InvalidState, + => return error.RasterizationFailed, + }, + }; + defer bitmap.deinit(alloc); + + // Cache the empty result too. This prevents repeated rasterization for + // valid but visually-empty outlines while keeping downstream render code + // on the normal zero-sized-glyph skip path. + if (bitmap.width == 0 or bitmap.height == 0) { + codepoint.rasterized = .{ + .width = 0, + .height = 0, + .offset_x = 0, + .offset_y = 0, + .atlas_x = 0, + .atlas_y = 0, + }; + return codepoint.rasterized.?; + } + + // Atlas allocation and writes must hold the shared grid lock. The more + // expensive vector rasterization above intentionally happens outside the + // lock so other surfaces sharing this grid are blocked for less time. + grid.lock.lock(); + defer grid.lock.unlock(); + + // Reuse the normal font grayscale atlas. If it fills up, grow it and + // retry just like SharedGrid.renderGlyph does for regular font glyphs. + const region = region: while (true) { + break :region grid.atlas_grayscale.reserve( + alloc, + bitmap.width, + bitmap.height, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.AtlasFull => { + try grid.atlas_grayscale.grow(alloc, grid.atlas_grayscale.size * 2); + continue; + }, + }; + }; + grid.atlas_grayscale.set(region, bitmap.data); + + // A Glyph Protocol glyf bitmap is rasterized as a full render-span + // bitmap. A top bearing equal to bitmap height places the top of the + // quad at the top of the cell in the existing text shader convention. + codepoint.rasterized = .{ + .width = bitmap.width, + .height = bitmap.height, + .offset_x = 0, + .offset_y = @intCast(bitmap.height), + .atlas_x = region.x, + .atlas_y = region.y, + }; + + return codepoint.rasterized.?; + } + + /// Release a codepoint map and all cloned entries in it. + /// + /// This helper resets the map to `.empty` so it can be safely reused after + /// both successful replacement and error-path cleanup. + fn deinitMap(map: *CodepointMap, alloc: Allocator) void { + for (map.values()) |*entry| entry.deinit(alloc); + map.deinit(alloc); + map.* = .empty; + } +}; + +/// One registered codepoint in the renderer snapshot. +pub const Codepoint = struct { + /// Cloned terminal glossary entry. This is kept even after rasterization so + /// font grid changes can discard atlas metadata and rasterize again. + entry: Glossary.Entry, + + /// Cached atlas metadata for the current font grid, if visible before. + rasterized: ?font.Glyph = null, + + /// Release the cloned glossary entry owned by this codepoint. + pub fn deinit(self: *Codepoint, alloc: Allocator) void { + self.entry.deinit(alloc); + self.* = undefined; + } +}; + +fn testEntry(alloc: Allocator, x_offset: i32) !Glossary.Entry { + const contours = try alloc.dupe(u16, &.{2}); + errdefer alloc.free(contours); + + const points = try alloc.dupe(Glyf.Outline.Point, &.{ + .{ .x = x_offset, .y = 0, .on_curve = true }, + .{ .x = x_offset + 500, .y = 0, .on_curve = true }, + .{ .x = x_offset, .y = 500, .on_curve = true }, + }); + errdefer alloc.free(points); + + return .{ + .glyph = .{ .glyf = .{ + .contours = contours, + .points = points, + } }, + .design = .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, + .width = .narrow, + .constraint = .none, + }; +} + +test "State syncFromGlossary clones terminal entries" { + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + try glossary.register(alloc, 0xE000, try testEntry(alloc, 0)); + + var state: State = .empty; + defer state.deinit(alloc); + try state.syncFromGlossary(alloc, &glossary); + + try testing.expect(state.codepoints.contains(0xE000)); + try testing.expect(!state.codepoints.contains(0xE001)); + + const src = glossary.entries.getPtr(0xE000).?; + const dst = state.codepoints.getPtr(0xE000).?; + try testing.expect(src.glyph.glyf.points.ptr != dst.entry.glyph.glyf.points.ptr); + try testing.expectEqual(src.glyph.glyf.points[0], dst.entry.glyph.glyf.points[0]); +} + +test "State syncFromGlossary replaces removed and changed entries" { + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + try glossary.register(alloc, 0xE000, try testEntry(alloc, 0)); + + var state: State = .empty; + defer state.deinit(alloc); + try state.syncFromGlossary(alloc, &glossary); + + try glossary.register(alloc, 0xE001, try testEntry(alloc, 10)); + try glossary.delete(alloc, 0xE000); + try state.syncFromGlossary(alloc, &glossary); + + try testing.expect(!state.codepoints.contains(0xE000)); + try testing.expect(state.codepoints.contains(0xE001)); + try testing.expectEqual(@as(i32, 10), state.codepoints.getPtr(0xE001).?.entry.glyph.glyf.points[0].x); +} + +test "State invalidateRasterized clears cached glyph metadata" { + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + try glossary.register(alloc, 0xE000, try testEntry(alloc, 0)); + + var state: State = .empty; + defer state.deinit(alloc); + try state.syncFromGlossary(alloc, &glossary); + + const codepoint = state.codepoints.getPtr(0xE000).?; + codepoint.rasterized = .{ + .width = 1, + .height = 1, + .offset_x = 0, + .offset_y = 1, + .atlas_x = 2, + .atlas_y = 3, + }; + + state.invalidateRasterized(); + try testing.expect(codepoint.rasterized == null); +} + +test "State renderGlyph returns null for unregistered codepoint" { + var state: State = .empty; + + const grid: *font.SharedGrid = undefined; + const glyph = try state.renderGlyph( + testing.allocator, + grid, + undefined, + 0xE000, + ); + try testing.expect(glyph == null); +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 3a914794f..9350c5549 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3208,6 +3208,11 @@ pub fn fullReset(self: *Terminal) void { // Always mark dirty so we redraw everything self.flags.dirty.clear = true; + + // The clear dirty bit is enough to force a full render-state rebuild, but + // renderers keep their own cloned Glyph Protocol state. Set the glossary + // dirty bit too so they sync and drop any registrations cleared above. + self.flags.dirty.glyph_glossary = true; } /// Returns true if the point is dirty, used for testing.