Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Shaping a row of terminal cells into glyphs on the grid.//!//! A row is cut into runs of consecutive cells drawn with the same face;//! each run is shaped whole, so that a ligature or a combining mark sees its//! neighbours. Each cell's code points go into the buffer with the cell's//! index in the run as their cluster, so a glyph names its cell directly and//! nothing has to be mapped back from byte offsets.//!//! The grid is fixed: a glyph is drawn from the left edge of its cluster's//! cell, offset by whatever the shaper moved it within the cluster. Where the//! font's advances disagree with the cell's width — a proportional fallback//! font, say — the glyphs still land one per cell rather than drifting.//!//! A cell holding one of the code points harrier draws itself — box//! drawing, blocks, braille — is not shaped at all, whatever font is//! configured: it becomes a glyph with no face, drawn to fill the cell.
const std = @import("std");const Allocator = std.mem.Allocator;const shaper = @import("font_shaper");
const Grid = @import("aerie").text.Family;const Face = Grid.Face;const Sprite = @import("sprite/Sprite.zig");const emoji = @import("aerie").text.emoji;
pub const Options = struct { /// OpenType features, in HarfBuzz's syntax: `"-calt"`, `"ss01"`. features: []const []const u8 = &.{}, /// Off adds `-liga`, `-calt` and `-dlig`. ligatures: bool = true, /// Draw box drawing, blocks, braille and the like ourselves rather than /// taking them from the font. builtin_glyphs: bool = true,};
/// One cell of a row, as the shaper needs it.pub const Cell = struct { /// The cell's code points: the base and any that combine with it. Empty /// for a blank cell. text: []const u21, style: Grid.Style = .regular, /// The right half of a wide character, which draws nothing of its own. spacer: bool = false,};
/// A glyph placed on the grid.pub const Glyph = struct { col: u16, /// Null for one harrier draws itself. face: ?*const Face, /// The glyph ID in `face`, or the code point when there is none. glyph: u32, /// How many cells its grapheme takes: two for a wide one. One of ours /// is drawn across them, and a color glyph wider than them is shrunk /// to fit. span: u8 = 1, /// Pixels right of the cell's left edge. x: i32, /// Pixels down from the baseline. y: i32,};
pub const Shaper = struct { gpa: Allocator, buffer: shaper.Buffer, /// On the heap, each: a plan keeps a pointer to its face, and a hash /// map moves its values whenever it grows — the first time a fallback /// font is needed, say — which left every plan made before it reading /// freed memory. faces: std.AutoHashMapUnmanaged(*const Face, *shaper.Face) = .empty, plans: std.AutoHashMapUnmanaged(PlanKey, *shaper.Plan) = .empty, features: []shaper.Feature, builtin_glyphs: bool, /// Each cell's face; null for a cell drawn as one of ours. faces_of: std.ArrayList(?*const Face) = .empty,
const PlanKey = struct { face: *const Face, script: shaper.Script };
pub fn init(gpa: Allocator, options: Options) Allocator.Error!Shaper { var list: std.ArrayList(shaper.Feature) = .empty; errdefer list.deinit(gpa); if (!options.ligatures) for ([_][]const u8{ "-liga", "-calt", "-dlig" }) |f| try list.append(gpa, shaper.Feature.parse(f).?); for (options.features) |f| { if (shaper.Feature.parse(f)) |parsed| try list.append(gpa, parsed) else std.log.scoped(.font).warn("unknown font feature \"{s}\"", .{f}); } return .{ .gpa = gpa, .buffer = .init(gpa), .features = try list.toOwnedSlice(gpa), .builtin_glyphs = options.builtin_glyphs, }; }
pub fn deinit(s: *Shaper) void { var plans = s.plans.valueIterator(); while (plans.next()) |p| { p.*.deinit(); s.gpa.destroy(p.*); } s.plans.deinit(s.gpa); var faces = s.faces.valueIterator(); while (faces.next()) |f| s.gpa.destroy(f.*); s.faces.deinit(s.gpa); s.buffer.deinit(); s.faces_of.deinit(s.gpa); s.gpa.free(s.features); s.* = undefined; }
/// Shapes `cells` and appends what is to be drawn to `out`. pub fn shapeRow(s: *Shaper, grid: *Grid, cells: []const Cell, out: *std.ArrayList(Glyph)) Allocator.Error!void { return s.shapeRowAt(grid, grid.size_px, cells, out); }
/// `shapeRow`, with the grid's faces at `size_px` rather than its own /// size. pub fn shapeRowAt(s: *Shaper, grid: *Grid, size_px: f32, cells: []const Cell, out: *std.ArrayList(Glyph)) Allocator.Error!void { const notdef_face = try grid.primary() orelse return;
// The face for each cell. try s.faces_of.resize(s.gpa, cells.len); for (cells, s.faces_of.items) |cell, *face| { face.* = if (blank(cell)) notdef_face // never drawn; see below else if (s.builtin_glyphs and Sprite.has(cell.text[0])) null else try grid.faceFor(cell.style, cell.text[0], emoji.presentation(cell.text)) orelse notdef_face; }
var start: usize = 0; while (start < cells.len) { if (blank(cells[start])) { start += 1; continue; } const face = s.faces_of.items[start] orelse { // One of ours, alone: anything combining with it is // dropped, since there is no font to place it. try out.append(s.gpa, .{ .col = @intCast(start), .face = null, .glyph = cells[start].text[0], .span = spanAt(cells, start), .x = 0, .y = 0 }); start += 1; continue; }; var end = start + 1; while (end < cells.len and !blank(cells[end]) and s.faces_of.items[end] == face) end += 1; try s.shapeRun(size_px, face, cells, start, end, out); start = end; } }
/// How many cells the grapheme in `cells[i]` takes: it and the spacer /// after it, when it is wide. fn spanAt(cells: []const Cell, i: usize) u8 { const next = i + 1; return if (next < cells.len and cells[next].spacer and cells[next].text.len == 0) 2 else 1; }
fn blank(cell: Cell) bool { return cell.spacer or cell.text.len == 0 or (cell.text.len == 1 and (cell.text[0] == ' ' or cell.text[0] == 0)); }
fn shapeRun(s: *Shaper, size_px: f32, face: *const Face, cells: []const Cell, start: usize, end: usize, out: *std.ArrayList(Glyph)) Allocator.Error!void { const b = &s.buffer; b.clear(); for (cells[start..end], 0..) |cell, i| { for (cell.text) |cp| try b.add(cp, @intCast(i)); } b.flags.bot = true; b.flags.eot = true; b.direction = .ltr; b.guessSegmentProperties(); // A terminal is laid out left to right whatever the script. b.direction = .ltr;
const plan = try s.planFor(face, b.script); try shaper.shape.shape(plan, b);
const scale = face.scale(size_px); var pen: f32 = 0; var cluster_pen: f32 = 0; var cluster: ?u32 = null; for (b.info.items, b.pos.items) |info, pos| { if (cluster != info.cluster) { cluster = info.cluster; cluster_pen = pen; } const x_off: f32 = @floatFromInt(pos.x_offset); const y_off: f32 = @floatFromInt(pos.y_offset); try out.append(s.gpa, .{ .col = @intCast(start + info.cluster), .face = face, .glyph = std.math.cast(u16, info.codepoint) orelse 0, .span = spanAt(cells, start + info.cluster), .x = @intFromFloat(@round(pen - cluster_pen + x_off * scale)), .y = @intFromFloat(@round(-y_off * scale)), }); pen += @as(f32, @floatFromInt(pos.x_advance)) * scale; } }
fn planFor(s: *Shaper, face: *const Face, script: shaper.Script) Allocator.Error!*const shaper.Plan { const key: PlanKey = .{ .face = face, .script = script }; if (s.plans.get(key)) |p| return p; const sface = blk: { if (s.faces.get(face)) |f| break :blk f; const f = try s.gpa.create(shaper.Face); errdefer s.gpa.destroy(f); f.* = .init(s.gpa, face.font, face.coords); try s.faces.put(s.gpa, face, f); break :blk f; }; const plan = try s.gpa.create(shaper.Plan); errdefer s.gpa.destroy(plan); plan.* = try shaper.Plan.init(s.gpa, sface, .{ .direction = .ltr, .script = script, .features = s.features, }); errdefer plan.deinit(); try s.plans.put(s.gpa, key, plan); return plan; }};
// ---------------------------------------------------------------------------
const testing = std.testing;const cell_glyphs = @import("cell_glyphs.zig");
/// One grid for every test that wants fonts, made on first use and never/// freed: scanning fontconfig's cache takes seconds in a Debug build, where/// std itself is unoptimized. It is allocated outside the testing allocator,/// which would otherwise report it as leaked by whichever test made it.var shared_fonts: ?@import("aerie").text.Fonts = null;var shared_grid: ?Grid = null;
fn testGrid() !*Grid { if (shared_grid) |*g| return g; if (shared_fonts == null) { var env: std.process.Environ.Map = .init(std.heap.smp_allocator); defer env.deinit(); if (std.testing.environ.getAlloc(std.heap.smp_allocator, "HOME")) |home| { try env.put("HOME", home); } else |_| {} shared_fonts = try .init(std.heap.smp_allocator, testing.io, &env, .{ .windows_conf_dir = "harrier" }); } shared_grid = Grid.init(std.heap.smp_allocator, &shared_fonts.?, "monospace", 16) catch |err| switch (err) { // A build sandbox has no fonts at all. error.NoFont => return error.SkipZigTest, else => return err, }; return &shared_grid.?;}
fn cellsOf(comptime s: []const u8) [s.len]Cell { var cells: [s.len]Cell = undefined; inline for (s, 0..) |c, i| cells[i] = .{ .text = &.{c} }; return cells;}
test "plain text is a glyph per cell, each at its cell's left edge" { const grid = try testGrid(); var s = try Shaper.init(testing.allocator, .{ .ligatures = false }); defer s.deinit();
var out: std.ArrayList(Glyph) = .empty; defer out.deinit(testing.allocator); const cells = cellsOf("ab c"); try s.shapeRow(grid, &cells, &out);
try testing.expectEqual(@as(usize, 3), out.items.len); try testing.expectEqual(@as(u16, 0), out.items[0].col); try testing.expectEqual(@as(u16, 1), out.items[1].col); try testing.expectEqual(@as(u16, 3), out.items[2].col); for (out.items) |g| try testing.expectEqual(@as(i32, 0), g.x);}
test "a combining mark stays in its base's cell" { const grid = try testGrid(); var s = try Shaper.init(testing.allocator, .{}); defer s.deinit();
var out: std.ArrayList(Glyph) = .empty; defer out.deinit(testing.allocator); const cells = [_]Cell{ .{ .text = &.{ 'e', 0x301 } }, .{ .text = &.{'x'} } }; try s.shapeRow(grid, &cells, &out); for (out.items) |g| if (g.col != 0) try testing.expectEqual(@as(u16, 1), g.col); try testing.expectEqual(@as(u16, 1), out.items[out.items.len - 1].col);}
test "a glyph knows how many cells its grapheme takes" { const grid = try testGrid(); var s = try Shaper.init(testing.allocator, .{}); defer s.deinit();
var out: std.ArrayList(Glyph) = .empty; defer out.deinit(testing.allocator); // A wide character and its spacer, then a narrow one. const cells = [_]Cell{ .{ .text = &.{0x306b} }, .{ .text = &.{}, .spacer = true }, .{ .text = &.{'x'} } }; try s.shapeRow(grid, &cells, &out); for (out.items) |g| try testing.expectEqual(@as(u8, if (g.col == 0) 2 else 1), g.span); try testing.expectEqual(@as(u16, 2), out.items[out.items.len - 1].col);}
test "the cell has a sensible size" { const grid = try testGrid(); const m = grid.metrics; try testing.expect(m.cell_width >= 4 and m.cell_width < 16); try testing.expect(m.cell_height > m.cell_width); try testing.expect(m.baseline < m.cell_height); try testing.expect(m.underline_top + m.underline_thickness <= m.cell_height);}
test "shaped glyphs rasterize into the atlas" { const grid = try testGrid(); var s = try Shaper.init(testing.allocator, .{}); defer s.deinit(); // The shared grid's allocator: a hinted face keeps what it sets up for // a size, made with the allocator that rasterized with it, for as long // as the grid lives, which is past this test. var glyphs = try @import("aerie").text.Glyphs.init(std.heap.smp_allocator); defer glyphs.deinit();
var out: std.ArrayList(Glyph) = .empty; defer out.deinit(testing.allocator); const cells = cellsOf("Hg"); try s.shapeRow(grid, &cells, &out); for (out.items) |g| { const e = try cell_glyphs.of(&glyphs, testing.io, g, grid); const region = e.region.?; try testing.expect(region.width > 0 and region.height > 0); // Ink above the baseline for both; "g" also has a descender. try testing.expect(e.top < 0); } // Asking again is a cache hit, not another region. const before = glyphs.alpha.modified; _ = try cell_glyphs.of(&glyphs, testing.io, out.items[0], grid); try testing.expectEqual(before, glyphs.alpha.modified);}
test "box drawing is ours, not the font's, unless turned off" { const grid = try testGrid(); const cells = [_]Cell{ .{ .text = &.{'a'} }, .{ .text = &.{0x2500} }, .{ .text = &.{0x28FF} }, .{ .text = &.{'b'} } };
var out: std.ArrayList(Glyph) = .empty; defer out.deinit(testing.allocator);
var s = try Shaper.init(testing.allocator, .{}); defer s.deinit(); try s.shapeRow(grid, &cells, &out); try testing.expectEqual(@as(usize, 4), out.items.len); try testing.expect(out.items[0].face != null); try testing.expectEqual(@as(?*const Face, null), out.items[1].face); try testing.expectEqual(@as(u32, 0x2500), out.items[1].glyph); try testing.expectEqual(@as(?*const Face, null), out.items[2].face); try testing.expect(out.items[3].face != null);
var glyphs = try @import("aerie").text.Glyphs.init(testing.allocator); defer glyphs.deinit(); const line = try cell_glyphs.of(&glyphs, testing.io, out.items[1], grid); // A horizontal line from one edge of the cell to the other. try testing.expectEqual(@as(i32, 0), line.left); try testing.expectEqual(grid.metrics.cell_width, line.region.?.width);
var off = try Shaper.init(testing.allocator, .{ .builtin_glyphs = false }); defer off.deinit(); out.clearRetainingCapacity(); try off.shapeRow(grid, &cells, &out); for (out.items) |g| try testing.expect(g.face != null);}
test "a plan made before the shaper met more fonts still shapes" { const grid = try testGrid(); var s = try Shaper.init(testing.allocator, .{}); defer s.deinit(); var out: std.ArrayList(Glyph) = .empty; defer out.deinit(testing.allocator);
const latin = cellsOf("abc"); try s.shapeRow(grid, &latin, &out); // Characters from far apart blocks, each likely to want a fallback font // of its own: enough faces to make the shaper's maps grow, and move // whatever they hold. const others = [_]u21{ 0x3042, 0x0915, 0x0627, 0x05D0, 0x0E01, 0x10A0, 0x1200, 0x13A0, 0x1780, 0xAC00, 0x0F40, 0x1820, 0x0D05, 0x0B85, 0x0C85, 0x2603, 0x1F600, 0x0531 }; for (others) |cp| { const cell = [_]Cell{.{ .text = &.{cp} }}; out.clearRetainingCapacity(); try s.shapeRow(grid, &cell, &out); } // The first plan again, through whatever the maps did. out.clearRetainingCapacity(); try s.shapeRow(grid, &latin, &out); try testing.expectEqual(@as(usize, 3), out.items.len);}