diff options
Diffstat (limited to 'src/gui/gui.zig')
| -rw-r--r-- | src/gui/gui.zig | 565 |
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; |
