summaryrefslogtreecommitdiff
path: root/src/gui/gui.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-24 10:12:10 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commite5f764bd201df6554272427fc8cedffece18066f (patch)
treee90e186ac4f1d7c642232f4d97c5a2dcb547b3b3 /src/gui/gui.zig
parentd32452950262b4ec2b50b0e0af66f7870c8e5d5d (diff)
downloadpardes-e5f764bd201df6554272427fc8cedffece18066f.tar.gz
pardes-e5f764bd201df6554272427fc8cedffece18066f.zip
Shape text with HarfBuzz, so a font's programming ligatures draw across their cells
The SDL shell rasterized every cell by its own codepoint, so a font's `->`, `!=` or `<=` ligature never appeared. Text is now shaped with HarfBuzz 11.0.0, built from source like FreeType: a lazy build.zig.zon dependency (the tarball Ghostty pins) compiled only for the gui shell, its FreeType integration linked against the freetype package, so the binary carries no system HarfBuzz or FreeType and a tty build never compiles it. Shaping turns on only the font's ligature features (liga, calt, clig, rlig, rclt); composition, local forms and fractions stay off, so a cell draws either its nominal glyph, as it always did, or part of a ligature. A glyph no lookup of those features covers cannot change, so its cell is resolved alone, and that resolution is cached by codepoint: in a font without ligatures (Adwaita Mono) no cell is ever shaped, and the pipeline draws pixel for pixel as before. A cell whose glyph a ligature could replace is shaped with its word: the cells between spaces that share a decoration and a role. A word ends either side of the cursor, block or bar, so the cell being edited shows its own character (Ghostty's rule), and inside f|i, f|l and s|t, whose typographic ligatures do not belong on a grid (Ghostty again). Colours never end a word: a ligature spans a syntax colour change or a selection edge. A codepoint the primary font lacks shapes in the fallback face that has it. Taglines are not shaped: they are drawn at two pitches, and a strip would not line up in one. HarfBuzz only chooses glyphs; the grid places them. A substituted glyph whose ink reaches over neighbouring cells that draw nothing of their own (the spacer glyphs of Fira Code-style fonts such as Maple Mono, or the cells a merged ligature cluster swallowed) claims them: it is rasterized once as a strip that many cells wide, and each cell samples its own slice, so selection, the cursor and per-cell colours work unchanged. Anything the grid cannot place (several glyphs in one cell, a positioned mark) keeps its nominal glyphs. A frame must not pay for this. A shaped word's atlas slots are cached by its text (checked against the stored text, not just its hash), so a frame costs one lookup per word and a compare for the rest of the word being walked. The atlas is keyed by (face, glyph, role, decoration, lead, span), packed into 64 bits so a lookup hashes one word; ASCII glyph ids are a table, and a face's HarfBuzz state is made when it is first asked about a glyph. Measured with the latency trace, whose F line now also carries the submit time (CPU = submit - present entry), in 10 interleaved rounds against main with an A/A pair, at 160x50 cells: with Adwaita Mono every scenario (idle, scrolling, typing, terminal output) is as fast or 1-3% faster, and the first frame and time to first paint are unchanged. With Maple Mono, ligatures cost the first frame about 0.8 ms (HarfBuzz setup and shaping the screen) and scrolling new text about 1-2%. With it the codepoint raster path is gone: grips look up their glyph and stay centered, since a grip is not text. ui_font_scale_for_height, an identity kept as a "size token", is removed (px is the size everywhere), ui_font_has_glyph becomes ui_font_glyph, and the space slot is simply the first, cleared cell of the atlas rather than a rasterized space. CellStyle.ul gets an explicit u3 tag so a decoration fits in the packed keys. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Diffstat (limited to 'src/gui/gui.zig')
-rw-r--r--src/gui/gui.zig565
1 files changed, 437 insertions, 128 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 7ad9fe9a..5bcb2867 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -80,14 +80,22 @@ const p9_arrow_mask = blk: {
const atlas_w: u32 = 2048;
const atlas_h: u32 = 2048;
const Slot = struct { u: u32, v: u32 };
-const GlyphKey = struct {
- codepoint: u32,
- centered: bool = false,
+/// An atlas entry is a glyph of a face (0 the primary font, i the fallback
+/// i - 1) at a role's size, drawn `span` cells wide with its own cell `lead`
+/// cells in: a ligature is one strip whose cells sample their own slices.
+/// Packed, so the atlas map hashes one word instead of walking fields.
+const GlyphKey = packed struct(u64) {
+ face: u8,
+ glyph: u32,
role: pardes.FontRole,
decoration: GlyphDecoration = .{},
+ centered: bool = false,
+ lead: u4 = 0,
+ span: u4 = 1,
+ _: u2 = 0,
};
-const GlyphDecoration = struct {
+const GlyphDecoration = packed struct(u5) {
bold: bool = false,
underline: @FieldType(pardes.CellStyle, "ul") = .off,
strikethrough: bool = false,
@@ -103,7 +111,7 @@ const GlyphDecoration = struct {
};
const TaglineRaster = struct {
- scale: f32,
+ px: f32,
width: u32,
baseline: i32,
height: u32,
@@ -111,16 +119,15 @@ const TaglineRaster = struct {
fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u32, percent: u8) TaglineRaster {
const px = body_px * @as(f32, @floatFromInt(percent)) / 100.0;
- const scale = c.ui_font_scale_for_height(font, px);
var own_w: c_int = 1;
var own_h: c_int = 1;
var own_ascent: c_int = 1;
- c.ui_font_cell_metrics(font, scale, &own_w, &own_h, &own_ascent);
+ c.ui_font_cell_metrics(font, px, &own_w, &own_h, &own_ascent);
const fixed_w: c_int = @intCast(body_cell_w);
const fixed_h: c_int = @intCast(body_cell_h);
const band_h = std.math.clamp(own_h, 1, fixed_h);
return .{
- .scale = scale,
+ .px = px,
.width = @intCast(std.math.clamp(own_w, 1, fixed_w)),
.baseline = std.math.clamp(own_ascent, 1, band_h),
.height = @intCast(band_h),
@@ -227,8 +234,8 @@ test "every font size step moves the cell, and the ends are reachable exactly" {
var asc: c_int = 0;
var pw: c_int = 0;
var ph: c_int = 0;
- c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px), &cw, &ch, &asc);
- c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px - font_px_step), &pw, &ph, &asc);
+ c.ui_font_cell_metrics(font, px, &cw, &ch, &asc);
+ c.ui_font_cell_metrics(font, px - font_px_step, &pw, &ph, &asc);
try std.testing.expect(cw > pw and ch > ph);
}
try std.testing.expectEqual(font_px_max, std.math.clamp(font_px_max + font_px_step, font_px_min, font_px_max));
@@ -238,10 +245,11 @@ test "every font size step moves the cell, and the ends are reachable exactly" {
test "FreeType atlas raster is grayscale, bounded, and clears blank cells" {
const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
defer c.ui_font_free(font);
- try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(font, 'H'));
- try std.testing.expectEqual(@as(c_int, 0), c.ui_font_has_glyph(font, 0x10ffff));
+ const h = c.ui_font_glyph(font, 'H');
+ try std.testing.expect(h != 0);
+ try std.testing.expectEqual(@as(u32, 0), c.ui_font_glyph(font, 0x10ffff));
- const size = c.ui_font_scale_for_height(font, 13.0);
+ const size: f32 = 13.0;
var cell_w: c_int = 0;
var cell_h: c_int = 0;
var ascent: c_int = 0;
@@ -253,7 +261,7 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" {
var bitmap: [128 * 128]u8 = @splat(0xaa);
try std.testing.expectEqual(
@as(c_int, 1),
- c.ui_font_raster(font, size, 'H', 0, &bitmap, stride, cell_w, cell_h, ascent),
+ c.ui_font_raster(font, size, h, 0, &bitmap, stride, cell_w, cell_h, 0, cell_w, ascent),
);
var ink: usize = 0;
@@ -272,7 +280,7 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" {
// Synthetic bold thickens the outline inside the same cell.
try std.testing.expectEqual(
@as(c_int, 1),
- c.ui_font_raster(font, size, 'H', 1, &bitmap, stride, cell_w, cell_h, ascent),
+ c.ui_font_raster(font, size, h, 1, &bitmap, stride, cell_w, cell_h, 0, cell_w, ascent),
);
var bold_coverage: usize = 0;
for (0..@intCast(cell_h)) |y| {
@@ -284,7 +292,7 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" {
try std.testing.expectEqual(
@as(c_int, 0),
- c.ui_font_raster(font, size, ' ', 0, &bitmap, stride, cell_w, cell_h, ascent),
+ c.ui_font_raster(font, size, c.ui_font_glyph(font, ' '), 0, &bitmap, stride, cell_w, cell_h, 0, cell_w, ascent),
);
for (0..@intCast(cell_h)) |y|
for (0..@intCast(cell_w)) |x|
@@ -314,40 +322,21 @@ test "tagline glyph shrinks into its own cell and stays vertically centered" {
const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
defer c.ui_font_free(font);
- const body_scale = c.ui_font_scale_for_height(font, 27.0);
+ const body_px: f32 = 27.0;
var cell_w: c_int = 1;
var cell_h: c_int = 1;
var body_ascent: c_int = 1;
- c.ui_font_cell_metrics(font, body_scale, &cell_w, &cell_h, &body_ascent);
- const tag = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), config.gui_tagline_font_percent);
- try std.testing.expect(tag.scale < body_scale);
+ c.ui_font_cell_metrics(font, body_px, &cell_w, &cell_h, &body_ascent);
+ const tag = taglineRaster(font, body_px, @intCast(cell_w), @intCast(cell_h), config.gui_tagline_font_percent);
+ try std.testing.expect(tag.px < body_px);
try std.testing.expect(tag.width < @as(u32, @intCast(cell_w)));
const stride: c_int = 128;
var body: [128 * 128]u8 = @splat(0);
var tagline: [128 * 128]u8 = @splat(0);
- try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(
- font,
- body_scale,
- 'H',
- 0,
- &body,
- stride,
- cell_w,
- cell_h,
- body_ascent,
- ));
- try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(
- font,
- tag.scale,
- 'H',
- 0,
- &tagline,
- stride,
- @intCast(tag.width),
- @intCast(tag.height),
- tag.baseline,
- ));
+ const h = c.ui_font_glyph(font, 'H');
+ try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(font, body_px, h, 0, &body, stride, cell_w, cell_h, 0, cell_w, body_ascent));
+ try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(font, tag.px, h, 0, &tagline, stride, @intCast(tag.width), @intCast(tag.height), 0, @intCast(tag.width), tag.baseline));
const b = inkBounds(&body, @intCast(stride), @intCast(cell_w), @intCast(cell_h)).?;
const t = inkBounds(&tagline, @intCast(stride), @intCast(tag.width), @intCast(tag.height)).?;
try std.testing.expect(t.max_x - t.min_x < b.max_x - b.min_x);
@@ -367,11 +356,10 @@ test "tagline glyph shrinks into its own cell and stays vertically centered" {
test "tagline percentage changes measured band height without body metrics" {
const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
defer c.ui_font_free(font);
- const body_scale = c.ui_font_scale_for_height(font, 27.0);
var cell_w: c_int = 1;
var cell_h: c_int = 1;
var ascent: c_int = 1;
- c.ui_font_cell_metrics(font, body_scale, &cell_w, &cell_h, &ascent);
+ c.ui_font_cell_metrics(font, 27.0, &cell_w, &cell_h, &ascent);
const small = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 40);
const configured = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 82);
const full = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 100);
@@ -381,18 +369,17 @@ test "tagline percentage changes measured band height without body metrics" {
try std.testing.expect(small.height < configured.height);
try std.testing.expect(configured.height <= full.height);
try std.testing.expect(full.height <= @as(u32, @intCast(cell_h)));
- try std.testing.expectEqual(body_scale, c.ui_font_scale_for_height(font, 27.0));
}
test "glyph atlas cache separates body and tagline roles" {
var glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator);
defer glyphs.deinit();
- try glyphs.put(.{ .codepoint = 'A', .role = .body }, .{ .u = 0, .v = 0 });
- try glyphs.put(.{ .codepoint = 'A', .role = .tagline }, .{ .u = 10, .v = 0 });
- try glyphs.put(.{ .codepoint = 'A', .role = .body, .decoration = .{ .bold = true } }, .{ .u = 20, .v = 0 });
+ try glyphs.put(.{ .face = 0, .glyph = 36, .role = .body }, .{ .u = 0, .v = 0 });
+ try glyphs.put(.{ .face = 0, .glyph = 36, .role = .tagline }, .{ .u = 10, .v = 0 });
+ try glyphs.put(.{ .face = 0, .glyph = 36, .role = .body, .decoration = .{ .bold = true } }, .{ .u = 20, .v = 0 });
try std.testing.expectEqual(@as(usize, 3), glyphs.count());
- try std.testing.expectEqual(@as(u32, 0), glyphs.get(.{ .codepoint = 'A', .role = .body }).?.u);
- try std.testing.expectEqual(@as(u32, 10), glyphs.get(.{ .codepoint = 'A', .role = .tagline }).?.u);
+ try std.testing.expectEqual(@as(u32, 0), glyphs.get(.{ .face = 0, .glyph = 36, .role = .body }).?.u);
+ try std.testing.expectEqual(@as(u32, 10), glyphs.get(.{ .face = 0, .glyph = 36, .role = .tagline }).?.u);
}
test "installed Nerd Symbols fallback covers Yazi directory icons" {
@@ -408,7 +395,7 @@ test "installed Nerd Symbols fallback covers Yazi directory icons" {
defer std.testing.allocator.free(bytes);
const face = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit;
defer c.ui_font_free(face);
- try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(face, 0xe5ff));
+ try std.testing.expect(c.ui_font_glyph(face, 0xe5ff) != 0);
}
const CellInstance = extern struct {
@@ -1767,8 +1754,7 @@ const Gui = struct {
fallbacks: [max_fallback_fonts]?LoadedFallback = @splat(null),
fallback_count: usize = 0,
px: f32,
- scale: f32,
- tagline_scale: f32,
+ tagline_px: f32,
tagline_percent: u8,
tagline_width: u32,
tagline_height: u32,
@@ -1785,6 +1771,7 @@ const Gui = struct {
pen_y: u32 = 0,
atlas_dirty: bool = true,
space_slot: Slot = .{ .u = 0, .v = 0 },
+ words: Words = .{},
touch: Touch = .{},
pet_state: pet.State = .{},
@@ -1894,13 +1881,19 @@ fn deinitFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void {
}
}
-fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont {
- if (c.ui_font_has_glyph(g.font, @intCast(cp)) != 0) return g.font;
- for (g.fallbacks[0..g.fallback_count]) |loaded| {
- const face = loaded.?.face;
- if (c.ui_font_has_glyph(face, @intCast(cp)) != 0) return face;
+const FaceGlyph = struct { face: u8, glyph: u32 };
+
+/// The face and glyph that draw `cp`, the face as a GlyphKey face: 0 is the
+/// primary font and i the fallback i - 1. A codepoint no face has draws the
+/// primary's .notdef.
+fn glyphForCodepoint(g: *const Gui, cp: u32) FaceGlyph {
+ const primary = c.ui_font_glyph(g.font, @intCast(cp));
+ if (primary != 0) return .{ .face = 0, .glyph = primary };
+ for (g.fallbacks[0..g.fallback_count], 1..) |loaded, face| {
+ const glyph = c.ui_font_glyph(loaded.?.face, @intCast(cp));
+ if (glyph != 0) return .{ .face = @intCast(face), .glyph = glyph };
}
- return g.font;
+ return .{ .face = 0, .glyph = 0 };
}
const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 };
@@ -2193,11 +2186,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
return error.FontInit;
};
const px: f32 = 27.0;
- const scale = c.ui_font_scale_for_height(font, px);
var cw: c_int = 10;
var chh: c_int = 20;
var asc: c_int = 16;
- c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc);
+ c.ui_font_cell_metrics(font, px, &cw, &chh, &asc);
const cell_w: u32 = @intCast(@max(cw, 1));
const cell_h: u32 = @intCast(@max(chh, 1));
const tagline = taglineRaster(font, px, cell_w, cell_h, config.gui_tagline_font_percent);
@@ -2282,8 +2274,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.image_capacity = initial_image_capacity,
.font = font,
.px = px,
- .scale = scale,
- .tagline_scale = tagline.scale,
+ .tagline_px = tagline.px,
.tagline_percent = config.gui_tagline_font_percent,
.tagline_width = tagline.width,
.tagline_height = tagline.height,
@@ -2305,6 +2296,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
loadFallbackFonts(&g, gpa);
defer deinitFallbackFonts(&g, gpa);
defer g.glyphs.deinit();
+ defer g.words.deinit(gpa);
defer {
clearNativeImages(&g);
g.native_images.deinit(gpa);
@@ -2317,8 +2309,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran
defer c.ui_font_free(g.font);
defer gpa.free(g.scroll_edge); // grown on demand by stepScroll
- _ = c.ui_font_raster(font, scale, ' ', 0, atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc);
- g.pen_x = cell_w;
+ resetGlyphAtlas(&g);
if (attach) |requested| return attachRequested(gpa, &g, requested);
var attached: ?detached_client.Client = null;
@@ -4392,10 +4383,12 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
var n: u32 = 0;
var row = y0;
while (row < y0 + bh) : (row += 1) {
+ const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols];
+ const row_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null;
var col = x0;
while (col < x0 + bw) : (col += 1) {
const sidx: u32 = @as(u32, row) * surface.cols + col;
- emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), surface.at(col, row), false, sidx == cursor_idx and !cursor_bar, page);
+ emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), line, col, row_cursor, false, sidx == cursor_idx and !cursor_bar, page);
n += 1;
}
}
@@ -4403,7 +4396,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1;
var i: u16 = 0;
while (i < bw) : (i += 1) {
- emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, cellFontRole(&g.scroll_edge[i]), &g.scroll_edge[i], false, false, page);
+ emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, cellFontRole(&g.scroll_edge[i]), g.scroll_edge[0..bw], i, null, false, false, page);
n += 1;
}
}
@@ -4911,6 +4904,7 @@ fn renderFrame(
if (cursor) |value| _ = c.SDL_SetCursor(value);
}
g.scene_target_failed = false;
+ g.words.memo = .{}; // positional, and this frame's cells are new
const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return false;
var command_consumed = false;
defer if (!command_consumed) {
@@ -5111,6 +5105,8 @@ fn renderFrame(
const cursor_bar = if (surface.cursor) |cu| cu.bar else false;
row = 0;
while (row < surface.rows) : (row += 1) {
+ const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols];
+ const line_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null;
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
if (bodyLayerAt(surface, col, row) != null or tagLayerIn(surface.tagLayers(), col, row) != null) continue;
@@ -5134,7 +5130,9 @@ fn renderFrame(
win_w,
win_h,
track,
- &surface.previous_cells[logical_idx],
+ surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols],
+ col,
+ null,
&tagline_base,
paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row),
true,
@@ -5153,7 +5151,9 @@ fn renderFrame(
win_w,
win_h,
if (data_effect and !data_diff) null else track,
- surface.at(col, row),
+ line,
+ col,
+ line_cursor,
&tagline_base,
paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row),
false,
@@ -5204,7 +5204,6 @@ fn renderFrame(
while (col < surface.cols) : (col += 1) {
if (!boxContains(track.contentBox(), col, row)) continue;
if (bodyLayerIn(surface.previous_body_layers, col, row) != null or tagLayerIn(surface.previous_tag_layers, col, row) != null) continue;
- const logical_idx: u32 = @as(u32, row) * surface.cols + col;
const destination: usize = if (duplicate_under) 0 else batch_index;
emitSurfaceCell(
g,
@@ -5217,7 +5216,9 @@ fn renderFrame(
win_w,
win_h,
if (duplicate_under) null else track,
- &surface.previous_cells[logical_idx],
+ surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols],
+ col,
+ null,
&tagline_base,
paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row),
false,
@@ -5381,12 +5382,14 @@ test "tagline bands center in their cells and Tagbottom faces the window edge" {
try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h));
}
-fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell {
+/// The colours and atlas slot of `line[at]`. `cursor` is where the cursor sits
+/// in `line`, block or bar: shaping breaks around it.
+fn resolveCell(g: *Gui, line: []const pardes.Cell, at: usize, cursor: ?usize, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell {
+ const cell = &line[at];
var fg = fg_default;
var bg = page.rgb;
var clear_bg = page.clear;
var reverse = is_cursor;
- var blank = false;
if (!cell.default) {
const st = cell.style;
fg = switch (st.fg) {
@@ -5409,8 +5412,8 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs
if (st.invisible) {
fg = bg;
// With a translucent background, painting bg-colored glyphs at
- // full opacity would expose text that the terminal concealed.
- blank = true;
+ // full opacity would expose text that the terminal concealed, so
+ // cellSlot draws a concealed cell blank.
}
if (st.dim) for (&fg) |*ch| {
ch.* = @intCast(@as(u16, ch.*) * 6 / 10);
@@ -5421,10 +5424,8 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs
clear_bg = false;
}
- const cp = if (blank) ' ' else cellCodepoint(cell);
- const decoration = GlyphDecoration.fromCell(cell);
return .{
- .slot = if (cp == ' ' and !decoration.drawsBlank()) g.space_slot else ensureGlyph(g, cp, role, decoration),
+ .slot = cellSlot(g, line, at, cursor, role),
.fg = fg,
.bg = bg,
.role = role,
@@ -5439,10 +5440,11 @@ fn cellFontRole(cell: *const pardes.Cell) pardes.FontRole {
test "concealed terminal text emits no ink on opaque or transparent ground" {
var g: Gui = undefined;
g.space_slot = .{ .u = 0, .v = 0 };
+ g.words = .{};
var cell: pardes.Cell = .{ .default = false, .style = .{ .invisible = true, .ul = .single } };
cell.text[0] = 'X';
for ([_]Ground{ .opaqueRgb(bg_default), .{ .rgb = bg_default, .clear = true } }) |page| {
- const resolved = resolveCell(&g, &cell, .body, false, page);
+ const resolved = resolveCell(&g, (&cell)[0..1], 0, null, .body, false, page);
try std.testing.expectEqualDeep(g.space_slot, resolved.slot);
try std.testing.expectEqual(page.rgb, resolved.bg);
}
@@ -5512,12 +5514,13 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p
// A window is rarely a whole number of cells wide: a band that ends at
// the last column runs on through the leftover pixels to the edge.
const band_right = if (win_w - right < bw) @max(right, win_w) else right;
- emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, &background, old_layer, false, page);
+ emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page);
if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h);
next.* += 1;
+ const line_cursor: ?usize = if (layer.cursor) |cu| cu.col else null;
for (0..tagLayerCellCount(g, layer)) |col| {
const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false;
- emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page);
+ emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, layer.cells[0..layer.cols], col, line_cursor, old_layer, cursor, page);
clipInstanceRight(&instances[next.*], right, win_w);
if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h);
next.* += 1;
@@ -5579,17 +5582,18 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const
if (header) {
var background: pardes.Cell = .{ .default = false, .style = layer.cells[index * layer.cols].style };
background.style.font_role = .body;
- emitInstance(g, instances, next.*, 0, 0, .{ .w = @as(f32, @floatFromInt(layer.viewport.w)) * bw, .h = height, .x_off = left, .y_off = top }, win_w, win_h, track, .body, &background, old_layer, false, page);
+ emitInstance(g, instances, next.*, 0, 0, .{ .w = @as(f32, @floatFromInt(layer.viewport.w)) * bw, .h = height, .x_off = left, .y_off = top }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page);
clipBodyInstance(&instances[next.*], bottom, win_h);
clipInstanceRight(&instances[next.*], right, win_w);
next.* += 1;
}
+ const line = layer.cells[index * layer.cols ..][0..layer.cols];
+ const line_cursor: ?usize = if (layer.cursor) |cu| if (cu.y == row) cu.x else null else null;
for (0..layer.cols) |col| {
if (left + @as(f32, @floatFromInt(col)) * width >= right) break;
- const cell = &layer.cells[index * layer.cols + col];
const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.x == col and cu.y == row else false;
const glyph_top = top - (if (header) @as(f32, @floatFromInt(taglineBandOffset(0, win_h, g.cell_h, g.tagline_height))) else 0);
- emitInstance(g, instances, next.*, 0, 0, .{ .w = width, .h = height, .x_off = left + @as(f32, @floatFromInt(col)) * width, .y_off = glyph_top }, win_w, win_h, track, role, cell, old_layer, cursor, page);
+ emitInstance(g, instances, next.*, 0, 0, .{ .w = width, .h = height, .x_off = left + @as(f32, @floatFromInt(col)) * width, .y_off = glyph_top }, win_w, win_h, track, role, line, col, line_cursor, old_layer, cursor, page);
clipBodyInstance(&instances[next.*], bottom, win_h);
clipInstanceRight(&instances[next.*], right, win_w);
next.* += 1;
@@ -5601,7 +5605,7 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const
.h = 1,
.x_off = left,
.y_off = top + height - 1,
- }, win_w, win_h, track, .body, &border, old_layer, false, page);
+ }, win_w, win_h, track, .body, (&border)[0..1], 0, null, old_layer, false, page);
clipBodyInstance(&instances[next.*], bottom, win_h);
clipInstanceRight(&instances[next.*], right, win_w);
next.* += 1;
@@ -5653,33 +5657,38 @@ fn emitSurfaceCell(
win_w: f32,
win_h: f32,
track: ?pardes.layout.Track,
- cell: *const pardes.Cell,
+ line: []const pardes.Cell,
+ at: usize,
+ cursor: ?usize,
tagline_base: *const pardes.Cell,
grip: ?GripCell,
old_layer: bool,
is_cursor: bool,
page: Ground,
) void {
- const role = cellFontRole(cell);
+ const role = cellFontRole(&line[at]);
if (role == .tagline) {
var band_base = tagline_base.*;
if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) };
// The smaller font changes the ink, not the chrome's coverage. Painting
// this underlay at tagline height leaves page-colored gutters between
// adjacent workspace, column and pane tags at TaglineSize < 100.
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, &band_base, old_layer, false, page);
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page);
next.* += 1;
// Tag glyphs keep their font size inside a full physical-width grip.
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page);
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page);
// An anchor starts at its column's rule: its left edge is inset by
// its top margin so the band frames it on the left as above and
// below, and its mark moves right by half that to stay centered.
const inset: u32 = if (core) |p| if (std.mem.indexOfScalar(u16, p.col_x[0..p.ncol], col -| (if (grip) |indicator| indicator.offset else 0)) != null) anchorInset(g, row, win_h) else 0 else 0;
if (grip) |indicator| {
- const cached = cachedGlyph(g, cellCodepoint(indicator.cell), .tagline, GlyphDecoration.fromCell(indicator.cell), true);
+ // A grip is one glyph centered across the gutter, not text: it is
+ // looked up by its codepoint's glyph, never shaped.
+ const drawn = glyphForCodepoint(g, cellCodepoint(indicator.cell));
+ const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(indicator.cell), .centered = true });
const slot = cached orelse g.space_slot;
const offset: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, indicator.offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0;
- const resolved = resolveCell(g, indicator.cell, .tagline, false, page);
+ const resolved = resolveCell(g, indicator.cell[0..1], 0, null, .tagline, false, page);
instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255;
instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255;
instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255;
@@ -5695,7 +5704,7 @@ fn emitSurfaceCell(
next.* += 1;
return;
}
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, role, cell, old_layer, is_cursor, page);
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, role, line, at, cursor, old_layer, is_cursor, page);
next.* += 1;
}
@@ -5703,6 +5712,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" {
const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
defer core.deinit();
var g: Gui = undefined;
+ g.words = .{};
g.cell_w = 10;
g.cell_h = 20;
g.tagline_width = 8;
@@ -5713,7 +5723,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" {
var next: u32 = 0;
for (0..3) |row| {
// Column 1: past the anchor's inset edge at the column's left.
- emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, false, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default));
const base = instances[row * 2];
const ink = instances[row * 2 + 1];
try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001);
@@ -5726,12 +5736,12 @@ test "small tagline fonts leave no background gutters between adjacent tags" {
// Column 0 is an anchor's edge: its ink is inset by the band's margin
// (2 px here) and its underlay still covers the whole cell.
next = 0;
- emitSurfaceCell(&g, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, false, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default));
try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), instances[0].x1 - instances[0].x0, 0.0001);
try std.testing.expectApproxEqAbs(@as(f32, 8.0 / 800.0 * 2), instances[1].x1 - instances[1].x0, 0.0001);
try std.testing.expectApproxEqAbs(@as(f32, 8) / atlas_w, instances[1].u1 - instances[1].u0, 0.0001);
next = 0;
- emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, true, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, true, false, Ground.opaqueRgb(bg_default));
try std.testing.expectEqual(@as(u32, 2), next);
try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), instances[1].y0 - instances[1].y1, 0.0001);
}
@@ -5747,12 +5757,14 @@ fn emitInstance(
win_h: f32,
track: ?pardes.layout.Track,
role: pardes.FontRole,
- cell: *const pardes.Cell,
+ line: []const pardes.Cell,
+ at: usize,
+ cursor: ?usize,
old_layer: bool,
is_cursor: bool,
page: Ground,
) void {
- const resolved = resolveCell(g, cell, role, is_cursor, page);
+ const resolved = resolveCell(g, line, at, cursor, role, is_cursor, page);
const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w;
const visual_h: f32 = if (resolved.role == .tagline)
@@ -5818,13 +5830,14 @@ fn emitInstance(
test "block cursor alone overrides transparent cell background" {
var g: Gui = undefined;
+ g.words = .{};
g.cell_w = 10;
g.cell_h = 20;
g.space_slot = .{ .u = 0, .v = 0 };
const cell: pardes.Cell = .{};
var instances: [2]CellInstance = undefined;
for ([_]bool{ false, true }, 0..) |cursor, i| {
- emitInstance(&g, &instances, @intCast(i), 0, 0, fixedCellLayout(&g), 800, 480, null, .body, &cell, false, cursor, Ground.opaqueRgb(bg_default));
+ emitInstance(&g, &instances, @intCast(i), 0, 0, fixedCellLayout(&g), 800, 480, null, .body, (&cell)[0..1], 0, null, false, cursor, Ground.opaqueRgb(bg_default));
try std.testing.expectEqual(cursor, instances[i].effect & opaque_bg_bit != 0);
}
}
@@ -5875,16 +5888,15 @@ fn firstCp(s: []const u8) u32 {
}
fn refitFont(g: *Gui, core: ?*pardes.Pardes) void {
- g.scale = c.ui_font_scale_for_height(g.font, g.px);
var cw: c_int = 10;
var chh: c_int = 20;
var asc: c_int = 16;
- c.ui_font_cell_metrics(g.font, g.scale, &cw, &chh, &asc);
+ c.ui_font_cell_metrics(g.font, g.px, &cw, &chh, &asc);
g.cell_w = @intCast(@max(cw, 1));
g.cell_h = @intCast(@max(chh, 1));
g.ascent = @intCast(@max(asc, 1));
const tagline = taglineRaster(g.font, g.px, g.cell_w, g.cell_h, g.tagline_percent);
- g.tagline_scale = tagline.scale;
+ g.tagline_px = tagline.px;
g.tagline_width = tagline.width;
g.tagline_baseline = tagline.baseline;
g.tagline_height = tagline.height;
@@ -5899,10 +5911,11 @@ fn refitFont(g: *Gui, core: ?*pardes.Pardes) void {
resetScroll(g);
}
+// The first cell of the cleared atlas stays blank: the space slot.
fn resetGlyphAtlas(g: *Gui) void {
g.glyphs.clearRetainingCapacity();
+ g.words.clear();
@memset(g.atlas_stage, 0);
- _ = c.ui_font_raster(g.font, g.scale, ' ', 0, g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent);
g.space_slot = .{ .u = 0, .v = 0 };
g.pen_x = g.cell_w;
g.pen_y = 0;
@@ -5914,7 +5927,7 @@ fn syncTaglineFont(g: *Gui, core: *pardes.Pardes) void {
if (percent == g.tagline_percent) return;
g.tagline_percent = percent;
const tagline = taglineRaster(g.font, g.px, g.cell_w, g.cell_h, percent);
- g.tagline_scale = tagline.scale;
+ g.tagline_px = tagline.px;
g.tagline_width = tagline.width;
g.tagline_baseline = tagline.baseline;
g.tagline_height = tagline.height;
@@ -5923,14 +5936,294 @@ fn syncTaglineFont(g: *Gui, core: *pardes.Pardes) void {
_ = updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height);
}
-fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecoration) Slot {
- return cachedGlyph(g, cp, role, decoration, false) orelse g.space_slot;
+/// What text draws, so a frame does not work it out again. `cells` is the
+/// atlas slot of a codepoint no ligature can touch (`shape_slot` for one that
+/// is shaped with its word); `map` is the atlas slot of every cell of a word
+/// holding one, found by the word's text. Bounded: past `max_cells` word cells
+/// it starts over, and the next frames look up what they show again (the
+/// glyphs stay in the atlas).
+const Words = struct {
+ cells: std.AutoHashMapUnmanaged(CellKey, Slot) = .empty,
+ map: std.AutoHashMapUnmanaged(u64, Word) = .empty,
+ /// Every word's codepoints and slots, end to end; a Word indexes both.
+ text: std.ArrayListUnmanaged(u32) = .empty,
+ slots: std.ArrayListUnmanaged(Slot) = .empty,
+ shaped: std.ArrayListUnmanaged(c.UIShapedCell) = .empty,
+ /// The word the last cell was in, so the rest of it costs a compare. It
+ /// is positional, so every frame forgets it: the cells change under it.
+ memo: Memo = .{},
+
+ const max_cells = 1 << 17;
+ const CellKey = packed struct(u64) { cp: u21, role: pardes.FontRole, decoration: GlyphDecoration, _: u30 = 0 };
+ const shape_slot: Slot = .{ .u = std.math.maxInt(u32), .v = std.math.maxInt(u32) };
+ const Word = struct { start: u32, len: u32, decoration: GlyphDecoration };
+ const Memo = struct {
+ line: ?[*]const pardes.Cell = null,
+ len: usize = 0,
+ start: usize = 0,
+ end: usize = 0,
+ cursor: ?usize = null,
+ slots: usize = 0,
+ };
+
+ fn clear(w: *Words) void {
+ w.cells.clearRetainingCapacity();
+ w.map.clearRetainingCapacity();
+ w.text.clearRetainingCapacity();
+ w.slots.clearRetainingCapacity();
+ w.memo = .{};
+ }
+
+ fn deinit(w: *Words, gpa: std.mem.Allocator) void {
+ w.cells.deinit(gpa);
+ w.map.deinit(gpa);
+ w.text.deinit(gpa);
+ w.slots.deinit(gpa);
+ w.shaped.deinit(gpa);
+ }
+};
+
+/// What decides where a word ends, for one cell. A concealed cell is a space.
+const WordCell = struct { cp: u32, decoration: GlyphDecoration, role: pardes.FontRole };
+
+fn wordCell(cell: *const pardes.Cell) WordCell {
+ return .{
+ .cp = if (!cell.default and cell.style.invisible) ' ' else cellCodepoint(cell),
+ .decoration = GlyphDecoration.fromCell(cell),
+ .role = cellFontRole(cell),
+ };
+}
+
+/// Whether a word ends between `a`, cell `i`, and `b`, cell `i + 1`: at a
+/// space, a decoration or role change, either side of the cursor (Ghostty's
+/// rule: the cell being edited shows its own glyph, so a ligature under the
+/// cursor comes apart), and inside f|i, f|l and s|t, whose typographic
+/// ligatures do not belong on a grid (Ghostty again). Colours never end a
+/// word: a ligature spans a syntax colour change or a selection edge, and each
+/// cell tints its own slice.
+fn wordBreak(a: WordCell, b: WordCell, i: usize, cursor: ?usize) bool {
+ if (a.cp == ' ' or b.cp == ' ') return true;
+ if (cursor) |at| if (at == i or at == i + 1) return true;
+ if (a.role != b.role or !std.meta.eql(a.decoration, b.decoration)) return true;
+ return (a.cp == 'f' and (b.cp == 'i' or b.cp == 'l')) or (a.cp == 's' and b.cp == 't');
}
-fn cachedGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecoration, centered: bool) ?Slot {
- const key: GlyphKey = .{ .codepoint = cp, .role = role, .decoration = decoration, .centered = centered };
- const slot_w = g.cell_w * @as(u32, if (centered) config.GUTTER else 1);
+/// The atlas slot `line[at]` draws: blank for a plain space (or concealed
+/// text), its own glyph when no ligature could replace that glyph (every
+/// cell, in a font without ligatures), else its slice of the word around it,
+/// shaped once and cached by the word's text. The cells of a word being walked
+/// cost a compare. Taglines are not shaped: they are drawn at two pitches
+/// (tagline_width in a tag layer, a whole cell in the grid), and a strip would
+/// not line up in one of them.
+fn cellSlot(g: *Gui, line: []const pardes.Cell, at: usize, cursor: ?usize, role: pardes.FontRole) Slot {
+ const words = &g.words;
+ const memo = words.memo;
+ if (memo.line == line.ptr and memo.len == line.len and memo.cursor == cursor and at >= memo.start and at < memo.end and role == .body)
+ return words.slots.items[memo.slots + at - memo.start];
+ var here = wordCell(&line[at]);
+ // An underlined or struck-out space is a glyph of its own: it draws its line.
+ if (here.cp == ' ' and !here.decoration.drawsBlank()) return g.space_slot;
+ const gpa = g.glyphs.allocator;
+ const cell_key: Words.CellKey = .{ .cp = @intCast(here.cp), .role = role, .decoration = here.decoration };
+ const known = words.cells.getOrPut(gpa, cell_key) catch return g.space_slot;
+ if (!known.found_existing) {
+ const drawn = glyphForCodepoint(g, here.cp);
+ const font = if (drawn.face == 0) g.font else g.fallbacks[drawn.face - 1].?.face;
+ known.value_ptr.* = if (role == .body and c.ui_font_substitutes(font, drawn.glyph) != 0)
+ Words.shape_slot
+ else
+ cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = role, .decoration = here.decoration }) orelse g.space_slot;
+ }
+ const plain = known.value_ptr.*;
+ if (!std.meta.eql(plain, Words.shape_slot)) return plain;
+
+ if (words.text.items.len > Words.max_cells) words.clear();
+ var start = at;
+ while (start > 0) {
+ const before = wordCell(&line[start - 1]);
+ if (wordBreak(before, here, start - 1, cursor)) break;
+ start -= 1;
+ here = before;
+ }
+ // The word's text goes on the end of `text`: a new word keeps it there,
+ // a known one gives it back.
+ const text_start = words.text.items.len;
+ var end = start;
+ while (true) {
+ words.text.append(gpa, here.cp) catch return g.space_slot;
+ end += 1;
+ if (end == line.len) break;
+ const next = wordCell(&line[end]);
+ if (wordBreak(here, next, end - 1, cursor)) break;
+ here = next;
+ }
+ const text = words.text.items[text_start..];
+ const decoration = here.decoration;
+ const hash = std.hash.Wyhash.hash(@as(u5, @bitCast(decoration)), std.mem.sliceAsBytes(text));
+ if (words.map.get(hash)) |word| {
+ if (word.len == text.len and std.meta.eql(word.decoration, decoration) and
+ std.mem.eql(u32, words.text.items[word.start..][0..word.len], text))
+ {
+ words.text.shrinkRetainingCapacity(text_start);
+ words.memo = .{ .line = line.ptr, .len = line.len, .start = start, .end = end, .cursor = cursor, .slots = word.start };
+ return words.slots.items[word.start + at - start];
+ }
+ }
+
+ // A new word (a hash collision takes the map entry over). Runs of one
+ // face are shaped in that face; fallback faces shape like the primary.
+ const n = text.len;
+ words.slots.ensureUnusedCapacity(gpa, n) catch {
+ words.text.shrinkRetainingCapacity(text_start);
+ return g.space_slot;
+ };
+ words.shaped.resize(gpa, n) catch {
+ words.text.shrinkRetainingCapacity(text_start);
+ return g.space_slot;
+ };
+ const shaped = words.shaped.items;
+ var run_start: usize = 0;
+ var face = glyphForCodepoint(g, text[0]).face;
+ while (run_start < n) {
+ var run_end = run_start + 1;
+ var next_face = face;
+ while (run_end < n) : (run_end += 1) {
+ next_face = glyphForCodepoint(g, text[run_end]).face;
+ if (next_face != face) break;
+ }
+ const run_font = if (face == 0) g.font else g.fallbacks[face - 1].?.face;
+ c.ui_font_shape(run_font, g.px, @intCast(g.cell_w), text[run_start..].ptr, @intCast(run_end - run_start), shaped[run_start..].ptr);
+ for (shaped[run_start..run_end]) |cell| {
+ const key: GlyphKey = .{ .face = face, .glyph = cell.glyph, .role = .body, .decoration = decoration, .lead = @intCast(cell.lead), .span = @intCast(cell.span) };
+ const slot: Slot = if (cachedGlyph(g, key)) |strip| .{ .u = strip.u + g.cell_w * cell.slice, .v = strip.v } else g.space_slot;
+ words.slots.appendAssumeCapacity(slot);
+ }
+ run_start = run_end;
+ face = next_face;
+ }
+ words.map.put(gpa, hash, .{ .start = @intCast(text_start), .len = @intCast(n), .decoration = decoration }) catch {};
+ words.memo = .{ .line = line.ptr, .len = line.len, .start = start, .end = end, .cursor = cursor, .slots = text_start };
+ return words.slots.items[text_start + at - start];
+}
+
+/// A Gui that can shape and rasterize `font` at `px`, and nothing else.
+fn textTestGui(font: *c.UIFont, px: f32) !Gui {
+ var g: Gui = undefined;
+ g.font = font;
+ g.fallback_count = 0;
+ g.px = px;
+ var cw: c_int = 0;
+ var ch: c_int = 0;
+ c.ui_font_cell_metrics(font, px, &cw, &ch, &g.ascent);
+ g.cell_w = @intCast(cw);
+ g.cell_h = @intCast(ch);
+ const tag = taglineRaster(font, px, g.cell_w, g.cell_h, 82);
+ g.tagline_px = tag.px;
+ g.tagline_width = tag.width;
+ g.tagline_height = tag.height;
+ g.tagline_baseline = tag.baseline;
+ g.atlas_stage = try std.testing.allocator.alloc(u8, atlas_w * atlas_h);
+ g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator);
+ g.words = .{};
+ resetGlyphAtlas(&g);
+ return g;
+}
+
+fn textTestLine(comptime text: []const u8) [text.len]pardes.Cell {
+ var line: [text.len]pardes.Cell = @splat(.{ .default = false });
+ for (&line, text) |*cell, byte| cell.text[0] = byte;
+ return line;
+}
+
+test "a ligature draws across its cells, one slice each, and comes apart under the cursor" {
+ const bytes = filesystem.readFile(std.testing.allocator, "assets/MapleMono-NF-Regular.ttf") catch return error.SkipZigTest;
+ defer std.testing.allocator.free(bytes);
+ const font = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit;
+ defer c.ui_font_free(font);
+ var g = try textTestGui(font, 27);
+ defer std.testing.allocator.free(g.atlas_stage);
+ defer g.glyphs.deinit();
+ defer g.words.deinit(std.testing.allocator);
+
+ const line = textTestLine("a->b");
+ var slots: [4]Slot = undefined;
+ for (&slots, 0..) |*slot, i| slot.* = cellSlot(&g, &line, i, null, .body);
+ // `->` is one glyph strip two cells wide: each cell samples its own half.
+ try std.testing.expectEqual(slots[1].v, slots[2].v);
+ try std.testing.expectEqual(slots[1].u + g.cell_w, slots[2].u);
+ var strips: usize = 0;
+ var it = g.glyphs.iterator();
+ while (it.next()) |entry| if (entry.key_ptr.span == 2) {
+ strips += 1;
+ try std.testing.expectEqual(@as(u4, 1), entry.key_ptr.lead);
+ try std.testing.expectEqual(slots[1], entry.value_ptr.*);
+ };
+ try std.testing.expectEqual(@as(usize, 1), strips);
+ // The arrow runs through the seam: both halves carry ink at it.
+ const seam = g.atlas_stage[@as(usize, slots[2].v) * atlas_w ..];
+ var left_ink: usize = 0;
+ var right_ink: usize = 0;
+ for (0..g.cell_h) |y| {
+ left_ink += seam[y * atlas_w + slots[2].u - 1];
+ right_ink += seam[y * atlas_w + slots[2].u];
+ }
+ try std.testing.expect(left_ink > 0 and right_ink > 0);
+ // `a` and `b` are their plain glyphs.
+ try std.testing.expectEqual(g.glyphs.get(.{ .face = 0, .glyph = c.ui_font_glyph(font, 'a'), .role = .body }).?, slots[0]);
+ try std.testing.expectEqual(g.glyphs.get(.{ .face = 0, .glyph = c.ui_font_glyph(font, 'b'), .role = .body }).?, slots[3]);
+
+ // A second line with the same word reuses the shaped slots.
+ const again = textTestLine("a->b");
+ const text_len = g.words.text.items.len;
+ for (0..4) |i| try std.testing.expectEqual(slots[i], cellSlot(&g, &again, i, null, .body));
+ try std.testing.expectEqual(text_len, g.words.text.items.len);
+
+ // The cursor on either half shows plain `-` and `>`.
+ const plain_minus = cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, '-'), .role = .body }).?;
+ const plain_greater = cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, '>'), .role = .body }).?;
+ for ([_]usize{ 1, 2 }) |cursor| {
+ try std.testing.expectEqual(plain_minus, cellSlot(&g, &line, 1, cursor, .body));
+ try std.testing.expectEqual(plain_greater, cellSlot(&g, &line, 2, cursor, .body));
+ }
+ // A tagline is never shaped.
+ try std.testing.expectEqual(c.ui_font_glyph(font, '-'), (try glyphKeyAt(&g, cellSlot(&g, &line, 1, null, .tagline))).glyph);
+}
+
+fn glyphKeyAt(g: *Gui, slot: Slot) !GlyphKey {
+ var it = g.glyphs.iterator();
+ while (it.next()) |entry| if (std.meta.eql(entry.value_ptr.*, slot)) return entry.key_ptr.*;
+ return error.NoGlyph;
+}
+
+test "a font without ligatures draws every cell as its own glyph, exactly as before shaping" {
+ const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
+ defer c.ui_font_free(font);
+ var g = try textTestGui(font, 18);
+ defer std.testing.allocator.free(g.atlas_stage);
+ defer g.glyphs.deinit();
+ defer g.words.deinit(std.testing.allocator);
+ const text = "a->b != c => d <= e |> www == :: 0x1F";
+ var line = textTestLine(text);
+ line[3].style.bold = true; // a style change inside a word
+ var expected: [64 * 64]u8 = undefined;
+ for (line, 0..) |cell, i| {
+ if (text[i] == ' ') continue;
+ const slot = cellSlot(&g, &line, i, null, .body);
+ const key = try glyphKeyAt(&g, slot);
+ try std.testing.expectEqual(c.ui_font_glyph(font, text[i]), key.glyph);
+ try std.testing.expectEqual(@as(u4, 1), key.span);
+ _ = c.ui_font_raster(font, 18, key.glyph, @intFromBool(cell.style.bold), &expected, 64, @intCast(g.cell_w), @intCast(g.cell_h), 0, @intCast(g.cell_w), g.ascent);
+ for (0..g.cell_h) |y|
+ try std.testing.expectEqualSlices(u8, expected[y * 64 ..][0..g.cell_w], g.atlas_stage[(slot.v + y) * atlas_w + slot.u ..][0..g.cell_w]);
+ }
+}
+
+/// The atlas slot of `key`, rasterized on first use. A strip is `span` cells
+/// wide (a grip glyph GUTTER cells, centered); a cell samples its slice of it.
+/// Null when the atlas is full.
+fn cachedGlyph(g: *Gui, key: GlyphKey) ?Slot {
if (g.glyphs.get(key)) |s| return s;
+ const slot_w = g.cell_w * @as(u32, if (key.centered) config.GUTTER else key.span);
if (g.pen_x + slot_w > atlas_w) {
g.pen_x = 0;
g.pen_y += g.cell_h;
@@ -5938,18 +6231,20 @@ fn cachedGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecorat
if (g.pen_y + g.cell_h > atlas_h) return null; // atlas full: fall back
const s = Slot{ .u = g.pen_x, .v = g.pen_y };
const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u;
- const face = fontForCodepoint(g, cp);
- const scale = if (role == .tagline) g.tagline_scale else g.scale;
- const baseline = if (role == .tagline) g.tagline_baseline else g.ascent;
- const raster_w = if (centered) slot_w else if (role == .tagline) g.tagline_width else g.cell_w;
- const raster_h = if (role == .tagline) g.tagline_height else g.cell_h;
- if (centered) {
- _ = c.ui_font_raster_centered(face, scale, @intCast(cp), @intFromBool(decoration.bold), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h));
+ const face = if (key.face == 0) g.font else g.fallbacks[key.face - 1].?.face;
+ const px = if (key.role == .tagline) g.tagline_px else g.px;
+ const baseline = if (key.role == .tagline) g.tagline_baseline else g.ascent;
+ const cell_w = if (key.role == .tagline) g.tagline_width else g.cell_w;
+ const raster_w = if (key.centered) slot_w else cell_w * key.span;
+ const raster_h = if (key.role == .tagline) g.tagline_height else g.cell_h;
+ const bold = @intFromBool(key.decoration.bold);
+ if (key.centered) {
+ _ = c.ui_font_raster_centered(face, px, key.glyph, bold, out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h));
} else {
- _ = c.ui_font_raster(face, scale, @intCast(cp), @intFromBool(decoration.bold), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), baseline);
+ _ = c.ui_font_raster(face, px, key.glyph, bold, out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), @intCast(cell_w * key.lead), @intCast(cell_w), baseline);
}
- decorateLine(out, atlas_w, raster_w, raster_h, baseline, decoration);
- if (centered) centerGlyphInk(out, raster_w, raster_h);
+ decorateLine(out, atlas_w, raster_w, raster_h, baseline, key.decoration);
+ if (key.centered) centerGlyphInk(out, raster_w, raster_h);
g.glyphs.put(key, s) catch return null;
g.pen_x += slot_w;
g.atlas_dirty = true;
@@ -5988,23 +6283,25 @@ test "pane indicator cached ink centers bearings styles and sizes" {
defer std.testing.allocator.free(g.atlas_stage);
g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator);
defer g.glyphs.deinit();
+ g.words = .{};
for ([_]f32{ 13, 27, 40 }) |px| {
- g.scale = c.ui_font_scale_for_height(font, px);
+ g.px = px;
var cw: c_int = 0;
var ch: c_int = 0;
- c.ui_font_cell_metrics(font, g.scale, &cw, &ch, &g.ascent);
+ c.ui_font_cell_metrics(font, px, &cw, &ch, &g.ascent);
g.cell_w = @intCast(cw);
g.cell_h = @intCast(ch);
for ([_]u8{ 40, 82, 100 }) |percent| {
const tag = taglineRaster(font, px, g.cell_w, g.cell_h, percent);
g.tagline_width = tag.width;
g.tagline_height = tag.height;
- g.tagline_scale = tag.scale;
+ g.tagline_px = tag.px;
g.tagline_baseline = tag.baseline;
resetGlyphAtlas(&g);
try std.testing.expectEqual(@as(usize, 0), g.glyphs.count());
for ([_]u32{ '^', '$', 'j' }) |cp| for ([_]bool{ false, true }) |bold| {
- const slot = cachedGlyph(&g, cp, .tagline, .{ .bold = bold }, true).?;
+ const key: GlyphKey = .{ .face = 0, .glyph = c.ui_font_glyph(font, @intCast(cp)), .role = .tagline, .decoration = .{ .bold = bold }, .centered = true };
+ const slot = cachedGlyph(&g, key).?;
const bounds = inkBounds(g.atlas_stage[@as(usize, slot.v) * atlas_w + slot.u ..], atlas_w, g.cell_w * config.GUTTER, tag.height).?;
const center_x: isize = @intCast(bounds.min_x + bounds.max_x);
const center_y: isize = @intCast(bounds.min_y + bounds.max_y);
@@ -6012,7 +6309,7 @@ test "pane indicator cached ink centers bearings styles and sizes" {
try std.testing.expect(@abs(center_y - @as(isize, @intCast(tag.height - 1))) <= 1);
const count = g.glyphs.count();
const pen = g.pen_x;
- try std.testing.expectEqualDeep(slot, cachedGlyph(&g, cp, .tagline, .{ .bold = bold }, true).?);
+ try std.testing.expectEqualDeep(slot, cachedGlyph(&g, key).?);
try std.testing.expectEqual(count, g.glyphs.count());
try std.testing.expectEqual(pen, g.pen_x);
};
@@ -6020,7 +6317,7 @@ test "pane indicator cached ink centers bearings styles and sizes" {
}
resetGlyphAtlas(&g);
g.pen_y = atlas_h;
- try std.testing.expectEqual(@as(?Slot, null), cachedGlyph(&g, '^', .tagline, .{}, true));
+ try std.testing.expectEqual(@as(?Slot, null), cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, '^'), .role = .tagline, .centered = true }));
}
test "pane indicator halves share dissolve changes and safe exhausted atlas" {
@@ -6040,7 +6337,13 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" {
layers[0].kind = .column;
try std.testing.expect(paneGripCell(&layers, &cells, 6, 0, 0) == null);
+ const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
+ defer c.ui_font_free(font);
var g: Gui = undefined;
+ g.font = font;
+ g.fallback_count = 0;
+ g.px = 20;
+ g.tagline_px = 16;
g.cell_w = 10;
g.cell_h = 20;
g.tagline_width = 8;
@@ -6050,11 +6353,13 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" {
g.pen_y = atlas_h;
g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator);
defer g.glyphs.deinit();
+ g.words = .{};
+ defer g.words.deinit(std.testing.allocator);
for ([_]bool{ false, true }) |old| {
var instances: [4]CellInstance = undefined;
var next: u32 = 0;
for (0..2) |col| {
- emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, &cells[col], &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], col, null, &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default));
try std.testing.expectEqual(@as(f32, 0), instances[col * 2 + 1].u0);
try std.testing.expectEqual(old, instances[col * 2 + 1].effect & old_layer_bit != 0);
}
@@ -6472,8 +6777,9 @@ test "background opacity retains exact endpoints and percentage precision" {
}
/// PARDES_TEST_LATENCY trace for an external driver, CLOCK_MONOTONIC ns:
-/// `R t` when stdin input is read, `F start fence` per captured frame (present
-/// entry, and the GPU fence of that frame). Nothing is written without it.
+/// `R t` when stdin input is read, `F start fence submit` per captured frame
+/// (present entry, the GPU fence of that frame, and its submission: submit -
+/// start is the frame's CPU cost). Nothing is written without it.
var latency_fd: c_int = -1;
var frame_started_ns: u64 = 0;
@@ -6513,11 +6819,12 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t
c.SDL_DownloadFromGPUTexture(copy, &src, &dst);
c.SDL_EndGPUCopyPass(copy);
+ const submit_ns = monoNs();
const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit;
defer c.SDL_ReleaseGPUFence(g.device, fence);
var fences = [_]*c.SDL_GPUFence{fence};
if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit;
- latencyNote("F {d} {d}\n", .{ frame_started_ns, monoNs() });
+ latencyNote("F {d} {d} {d}\n", .{ frame_started_ns, monoNs(), submit_ns });
const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap;
defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer);
@@ -7260,6 +7567,7 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 {
test "compact body layer rendering fills recovered rows and clips the final glyph" {
var g: Gui = undefined;
+ g.words = .{};
g.cell_w = 10;
g.cell_h = 20;
g.tagline_width = 6;
@@ -7299,6 +7607,7 @@ test "compact body layer rendering fills recovered rows and clips the final glyp
test "compact tag layers fill available width and clip at their physical edge" {
var g: Gui = undefined;
+ g.words = .{};
g.cell_w = 10;
g.cell_h = 20;
g.tagline_width = 6;