From 68a52c5930ac75408e92da524d6b413839592d17 Mon Sep 17 00:00:00 2001 From: phall Date: Wed, 5 Aug 2026 02:51:47 -0400 Subject: [PATCH 1/3] fix(canvas): preserve full terminal viewport --- src/primitives/canvas/terminal_grid.zig | 129 +++++++++++------- src/primitives/canvas/terminal_grid_tests.zig | 41 +++++- 2 files changed, 118 insertions(+), 52 deletions(-) diff --git a/src/primitives/canvas/terminal_grid.zig b/src/primitives/canvas/terminal_grid.zig index f36fa7d8c..95b9e21bf 100644 --- a/src/primitives/canvas/terminal_grid.zig +++ b/src/primitives/canvas/terminal_grid.zig @@ -28,14 +28,14 @@ const canvas = @import("root.zig"); const geometry = @import("geometry"); const box = @import("terminal_box.zig"); -/// Grid ceilings, derived from the per-view canvas budgets: the glyph -/// budget bounds how many cells can hold ink at once, and the command -/// budget bounds per-row style runs. A viewport is clamped to these -/// before it reaches an emulator, so a huge window degrades to a -/// bounded grid instead of a budget error. +/// Grid ceilings bound allocation and command-id geometry. Cell count is not a +/// glyph-atlas proxy: the atlas charges distinct glyph/subpixel keys, while a +/// terminal commonly repeats a small alphabet across tens of thousands of +/// cells. The painter's own distinct-glyph, text, path, and command preflights +/// remain the resource fences. pub const max_cols: usize = 320; pub const max_rows: usize = 96; -pub const max_cells: usize = 7168; +pub const max_cells: usize = max_cols * max_rows; /// Painter budgets when the grid rides the WIDGET tree, where it shares /// the per-view stores with every other widget. The command reserve is @@ -225,15 +225,14 @@ pub fn cellMetrics(tokens: canvas.DesignTokens) TerminalCellMetrics { return .{ .width = width, .height = height, .font_size = font_size }; } -/// Clamp a proposed grid to the canvas budgets: the glyph budget bounds -/// total cells, so a very wide viewport trades rows for columns to stay -/// under the cell ceiling instead of overflowing the frame. +/// Clamp a proposed grid to the allocation and command-id geometry bounds. +/// Painter resources are content-dependent and are fenced during paint; they +/// must not shrink the PTY to a fraction of its visible pane. pub fn clampGrid(proposed_cols: usize, proposed_rows: usize) TerminalCellPos { - var c = std.math.clamp(proposed_cols, 2, max_cols); - var r = std.math.clamp(proposed_rows, 2, max_rows); - if (c * r > max_cells) r = @max(2, max_cells / c); - if (c * r > max_cells) c = @max(2, max_cells / r); - return .{ .x = @intCast(c), .y = @intCast(r) }; + return .{ + .x = @intCast(std.math.clamp(proposed_cols, 2, max_cols)), + .y = @intCast(std.math.clamp(proposed_rows, 2, max_rows)), + }; } // ------------------------------------------------------------- painting @@ -444,53 +443,83 @@ fn displayListPathElements(list: canvas.DisplayList) usize { return total; } -/// An UPPER BOUND on the display-list commands one row emits, assuming -/// no runs merge (the pathological worst case): a background run per -/// cell that carries one, plus the cell's ink — eight commands for a -/// pure-double box joint, two (text run + underline) for a styled -/// character, none for an empty cell — plus one selection wash. Real -/// rows merge runs and cost far less; this bound is what the per-row -/// preflight checks so a row is only started when it can finish whole, -/// and it is content-accurate (a cheap ASCII row costs ~2/column, not a -/// flat worst-case-per-column reserve that would starve wide grids). +fn multiCodepointCluster(cell: TerminalCell) bool { + if (cell.cp == 0 or cell.cluster.len == 0) return false; + const primary_len = std.unicode.utf8CodepointSequenceLength(cell.cp) catch 1; + return cell.cluster.len > primary_len; +} + +/// Exact upper bound on the commands emitted for one row. This mirrors the +/// painter's background and ink run boundaries instead of pricing every cell +/// as an independent background + text command. Full-screen TUIs often carry +/// hundreds of sparse colored cells per row; pessimistically charging their +/// empty spans was truncating otherwise representable screens halfway down. fn rowCommandCost(row: TerminalRow) usize { - var total: usize = 1; // the selection wash + var total: usize = if (row.selection != null) 1 else 0; + + // Backgrounds emit one command per contiguous same-color run. + var run_color: ?canvas.Color = null; + var background_index: usize = 0; + while (background_index <= row.cells.len) : (background_index += 1) { + const bg: ?canvas.Color = if (background_index < row.cells.len) + row.cells[background_index].bg + else + null; + if (run_color) |color| { + const same = if (bg) |next| colorEql(color, next) else false; + if (!same) { + total += 1; + run_color = bg; + } + } else if (bg != null) { + run_color = bg; + } + } + + // Ink emits merged text or box runs plus an underline only when present. var i: usize = 0; - while (i < row.cells.len) : (i += 1) { + while (i < row.cells.len) { const cell = row.cells[i]; - if (cell.wide == .spacer) continue; - if (cell.bg != null) total += 1; - if (cell.cp == 0) continue; + if (cell.wide == .spacer or cell.cp == 0) { + i += 1; + continue; + } if (box.isBoxDrawing(cell.cp)) { + var span: usize = 1; if (box.mergesHorizontally(cell.cp)) { - // A horizontally-merged run (a long `─` border) paints - // ONE geometry command plus a possible underline, no - // matter how wide — mirror the paint loop's merge (same - // code point, foreground, and underline) so the estimate - // does not charge nine per column for what collapses to - // two commands. Backgrounds still count per cell (their - // own run pass is separate). - var span: usize = 1; while (i + span < row.cells.len) : (span += 1) { const next = row.cells[i + span]; if (next.cp != cell.cp or !colorEql(next.fg, cell.fg) or next.underline != cell.underline) break; - if (next.bg != null) total += 1; } - // A merged run's worst-case ink: a double piece (═) - // paints TWO parallel bars, plus a possible underline. - total += 3; - i += span - 1; - } else { - // The glyph's own worst case (kept lockstep with the - // paint switch by `box.maxCommands`) plus a possible - // underline — a row of one-bar pieces costs what it - // paints, never a flat joint-worst-case that starves - // wide grids. - total += box.maxCommands(cell.cp) + 1; } - } else { - total += 2; // the text run plus its underline + total += box.maxCommands(cell.cp) + @intFromBool(cell.underline); + i += span; + continue; + } + + // Combining clusters deliberately paint alone. Plain cells merge while + // foreground, decoration, and scratch capacity agree. + if (multiCodepointCluster(cell)) { + total += 1 + @intFromBool(cell.underline); + i += 1; + continue; + } + const fg = cell.fg; + const underline = cell.underline; + var text_bytes = cell.cluster.len; + var span: usize = 1; + while (i + span < row.cells.len) : (span += 1) { + const next = row.cells[i + span]; + if (next.wide == .spacer or next.cp == 0 or box.isBoxDrawing(next.cp) or + multiCodepointCluster(next) or !colorEql(next.fg, fg) or + next.underline != underline or text_bytes + next.cluster.len > text_scratch_bytes) + { + break; + } + text_bytes += next.cluster.len; } + total += 1 + @intFromBool(underline); + i += span; } return total; } diff --git a/src/primitives/canvas/terminal_grid_tests.zig b/src/primitives/canvas/terminal_grid_tests.zig index ef7c02776..2b8c5e10a 100644 --- a/src/primitives/canvas/terminal_grid_tests.zig +++ b/src/primitives/canvas/terminal_grid_tests.zig @@ -750,10 +750,11 @@ test "the widget diff reports paint damage for a changed bound grid" { try testing.expect(saw_paint_dirty); } -test "clampGrid trades rows for columns under the cell ceiling" { +test "clampGrid preserves full bounded viewport geometry" { const clamped = grid_model.clampGrid(400, 100); try testing.expectEqual(@as(u16, grid_model.max_cols), clamped.x); - try testing.expect(@as(usize, clamped.x) * @as(usize, clamped.y) <= grid_model.max_cells); + try testing.expectEqual(@as(u16, grid_model.max_rows), clamped.y); + try testing.expectEqual(grid_model.max_cols * grid_model.max_rows, grid_model.max_cells); const tiny = grid_model.clampGrid(0, 0); try testing.expectEqual(@as(u16, 2), tiny.x); try testing.expectEqual(@as(u16, 2), tiny.y); @@ -922,6 +923,42 @@ test "a wide cheap terminal paints its rows under the widget command budget" { try testing.expectEqual(@as(usize, 6), text_rows); } +test "a tall sparse colored terminal paints every representable row" { + // cmatrix-like content: a tall viewport with sparse colored streaks. The + // old per-cell upper bound charged every empty span and stopped around the + // middle even though actual merged runs fit comfortably in the envelope. + const row_count = 60; + const col_count = 200; + var storage: [row_count][col_count]grid_model.TerminalCell = @splat(@splat(.{})); + var rows: [row_count]grid_model.TerminalRow = undefined; + for (&storage, 0..) |*row_cells, row_index| { + for (0..10) |streak| { + const col = (streak * 19 + row_index * 3) % col_count; + row_cells[col] = cell('x', "x", if (streak % 3 == 0) white else red); + } + rows[row_index] = .{ .cells = row_cells }; + } + + var commands: [2048]canvas.CanvasCommand = undefined; + var builder = try paintInto(baseGrid(&rows), &commands, .{ + .frame = geometry.RectF.init(0, 0, 1600, 1200), + .tokens = .{}, + .command_budget = 1792, + }); + var painted_rows: usize = 0; + var last_y: ?f32 = null; + for (builder.displayList().commands) |command| { + if (command != .draw_text) continue; + const y = command.draw_text.origin.y; + if (last_y == null or last_y.? != y) { + painted_rows += 1; + last_y = y; + } + } + try testing.expectEqual(@as(usize, row_count), painted_rows); + try testing.expect(builder.displayList().commands.len <= 1792); +} + test "a focused bound terminal's ring id never collides with a grid command" { const row_cells = comptime asciiRow("shell", white); const rows = [_]grid_model.TerminalRow{.{ .cells = &row_cells }}; From e2313c6b7eb7e65c8f9cb7cd5330e97fbcce2cb7 Mon Sep 17 00:00:00 2001 From: phall Date: Wed, 5 Aug 2026 03:05:52 -0400 Subject: [PATCH 2/3] fix(canvas): admit complete terminal fonts --- src/primitives/canvas/font_ttf.zig | 13 +++++++------ src/primitives/canvas/font_ttf_tests.zig | 8 ++++++-- src/primitives/canvas/reference.zig | 7 +++---- src/primitives/canvas/vector.zig | 17 ++++++++--------- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/primitives/canvas/font_ttf.zig b/src/primitives/canvas/font_ttf.zig index e43b35790..4cc91d3a6 100644 --- a/src/primitives/canvas/font_ttf.zig +++ b/src/primitives/canvas/font_ttf.zig @@ -52,11 +52,12 @@ pub const Error = error{ /// Noto Sans KR 479 61 0 0 0 0 /// Noto Serif JP 465 40 0 0 0 0 /// Yuji Mai (brush kanji) 738 22 198 5 1 3 +/// JetBrainsMono Nerd 4050 132 79 4 1 3 /// -/// 1024 points / 128 contours cover the densest measured glyph (Yuji -/// Mai's 738-point brush kanji; Noto Sans TC's 685) with ~1.4x headroom; -/// the depth/element budgets carry 1.3-2x over the deepest measured -/// use (the bundled Geist's own accent stacking). +/// 4096 points / 256 contours cover the complete patched terminal face as +/// well as the measured CJK/brush faces. The high point count comes from a +/// handful of dense icon outlines, not ordinary text; registration remains +/// bounded and rejects anything beyond this measured production ceiling. /// /// The composite budgets bound a composite glyph's FLATTENED outline /// (`maxp.maxCompositePoints`/`maxCompositeContours`: totals across the @@ -75,8 +76,8 @@ pub const Error = error{ /// ONE frame at a time — simple glyphs are leaves, so composite /// recursion stacks only the small component-walk frames (depth <= 4), /// never these arrays. -pub const max_glyph_points: usize = 1024; -pub const max_glyph_contours: usize = 128; +pub const max_glyph_points: usize = 4096; +pub const max_glyph_contours: usize = 256; pub const max_composite_points: usize = max_glyph_points; pub const max_composite_contours: usize = max_glyph_contours; pub const max_composite_depth: usize = 4; diff --git a/src/primitives/canvas/font_ttf_tests.zig b/src/primitives/canvas/font_ttf_tests.zig index c1fe0ac61..5fbe70520 100644 --- a/src/primitives/canvas/font_ttf_tests.zig +++ b/src/primitives/canvas/font_ttf_tests.zig @@ -279,7 +279,7 @@ test "out of range glyph ids error instead of reading wild" { /// Fixed-buffer big-endian byte builder for synthetic tables. const ByteBuilder = struct { - bytes: [32768]u8 = undefined, + bytes: [131072]u8 = undefined, len: usize = 0, fn appendU8(self: *ByteBuilder, value: u8) void { @@ -532,7 +532,11 @@ test "synthetic dense glyphs beyond the old Latin-sized budgets parse and outlin // The dense glyph outlines completely: every on-curve ring is // moveTo + 12 lineTo (the walk returns to the start point) + close, // so 84 * 14 elements, starting at the first ring's origin. - var builder = vector.PathBuilder(2048){}; + const path_capacity = @max( + font_ttf.max_glyph_points + 3 * font_ttf.max_glyph_contours, + font_ttf.max_composite_points + 3 * font_ttf.max_composite_contours, + ); + var builder = vector.PathBuilder(path_capacity){}; try face.glyphOutline(1, Affine.identity(), &builder); try std.testing.expectEqual(@as(usize, 84 * 14), builder.slice().len); const first = builder.slice()[0]; diff --git a/src/primitives/canvas/reference.zig b/src/primitives/canvas/reference.zig index bbf819b58..4fdaf945c 100644 --- a/src/primitives/canvas/reference.zig +++ b/src/primitives/canvas/reference.zig @@ -51,9 +51,9 @@ const font_ttf = @import("font_ttf.zig"); /// admits — a simple glyph's maxima and a composite's flattened maxima /// (`maxp.maxCompositePoints`/`maxCompositeContours`, which is what /// this builder actually receives when a composite renders). The -/// budgets are currently equal, so the max is 1408 either way; the +/// budgets are currently equal, so the max is 4864 either way; the /// derivation keeps capacity honest if they ever diverge. Stack shape: -/// at 28 B per element this is ~39 KiB in `drawGlyphOutline`; the edge +/// at 28 B per element this is ~133 KiB in `drawGlyphOutline`; the edge /// accumulator below it is the per-thread heap-resident /// `vector.GlyphRasterizer` (see `reference_glyph_raster_scratch`), so /// the builder is the only glyph raster state on the stack. @@ -65,7 +65,7 @@ const reference_glyph_path_capacity: usize = @max( /// Per-thread rasterizer for glyph fills: `vector.GlyphRasterizer`'s /// derived budgets guarantee every outline the font registration gate /// admits rasterizes (never a block fallback), which sizes it at -/// ~508 KiB — a per-thread heap slot behind one TLS pointer (the +/// ~1.9 MiB — a per-thread heap slot behind one TLS pointer (the /// lazy_tls pattern), not a stack temporary and not static TLS. Only /// threads that ink a glyph through the reference renderer allocate it. /// The array carries no default and stays uninitialized, exactly like @@ -1124,7 +1124,6 @@ fn referenceScaleRect(rect: geometry.RectF, scale: f32) geometry.RectF { return geometry.RectF.init(rect.x * scale, rect.y * scale, rect.width * scale, rect.height * scale); } - fn referencePixelCenter(x: usize, y: usize) geometry.PointF { return geometry.PointF.init(@as(f32, @floatFromInt(x)) + 0.5, @as(f32, @floatFromInt(y)) + 0.5); } diff --git a/src/primitives/canvas/vector.zig b/src/primitives/canvas/vector.zig index 9c7ed609f..32659e0e2 100644 --- a/src/primitives/canvas/vector.zig +++ b/src/primitives/canvas/vector.zig @@ -92,18 +92,18 @@ const max_piece_points: usize = 4 * max_curve_segments + 4; // so the arithmetic is written out and lockstep-pinned by a test in // font_ttf_tests.zig against the source constants). // -// Worst admitted glyph (1024 points / 128 contours, simple and +// Worst admitted glyph (4096 points / 256 contours, simple and // flattened-composite maxima are gated equal; glyph outlines emit only // move/line/quad/close): // -// quads <= points + contours = 1024 + 128 = 1152 +// quads <= points + contours = 4096 + 256 = 4352 // (each point emits at most one line or quad; each contour adds at // most one closing quad — the same walk the glyph path capacity in // reference.zig is derived from) -// edges <= quads * segments + contours = 1152 * 16 + 128 = 18,560 +// edges <= quads * segments + contours = 4352 * 16 + 256 = 69,888 // (each quad flattens to at most `max_glyph_curve_segments` line // segments, lines and the per-contour close edge are one each) -// scanline crossings <= edges = 18,560 +// scanline crossings <= edges = 69,888 // (an edge crosses a scanline at most once — the half-open span // test in `sweep` — so the edge bound is also the airtight crossing // bound, with no reliance on curve monotonicity under float @@ -115,8 +115,7 @@ const max_piece_points: usize = 4 * max_curve_segments + 4; // flattening has no finite bound at all — a full-em quad at an // S-pixel em wants ceil(sqrt((S/2) / 0.25)) segments (128 at S = 8192) // — and even the generic `max_curve_segments` cap of 48 would put the -// worst case at 1152 * 48 + 128 = 55,424 edges = 1.06 MiB of edge -// storage plus 433 KiB of crossings per rasterizer: memory-unreasonable +// worst case at 4352 * 48 + 256 = 209,152 edges, which is memory-unreasonable // for a per-thread buffer. Flattening tolerance is a quality knob, not // correctness, so glyph fills clamp subdivision instead: at 16 segments // the clamp only binds when a curve's flatness deviation exceeds @@ -127,13 +126,13 @@ const max_piece_points: usize = 4 * max_curve_segments + 4; // span fractions of the em, so everything the golden suite renders // stays below the clamp and byte-identical. // -// Storage: 18,560 edges * 20 B = 362.5 KiB plus 18,560 crossings * 8 B -// = 145 KiB, ~508 KiB per `GlyphRasterizer`. The glyph raster caller +// Storage: 69,888 edges * 20 B plus 69,888 crossings * 8 B is ~1.9 MiB per +// `GlyphRasterizer`. The glyph raster caller // (reference.zig) keeps ONE per render thread behind a lazily // heap-allocated pointer (`canvas.lazy_tls`), so it costs neither stack // nor static TLS, and threads that never ink a glyph never allocate it. pub const max_glyph_curve_segments: usize = 16; -pub const max_glyph_fill_edges: usize = 18_560; +pub const max_glyph_fill_edges: usize = 69_888; pub const max_glyph_scanline_crossings: usize = max_glyph_fill_edges; /// Half-open device-pixel clip window: pixels with `x0 <= x < x1` and From aae1c7481d3e05f8d0ac80c96dce0edbc5efbf70 Mon Sep 17 00:00:00 2001 From: phall Date: Wed, 5 Aug 2026 03:17:59 -0400 Subject: [PATCH 3/3] fix(canvas): cost underlined terminal runs safely --- src/primitives/canvas/terminal_grid.zig | 4 ++-- src/primitives/canvas/terminal_grid_tests.zig | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/primitives/canvas/terminal_grid.zig b/src/primitives/canvas/terminal_grid.zig index 95b9e21bf..8c241a583 100644 --- a/src/primitives/canvas/terminal_grid.zig +++ b/src/primitives/canvas/terminal_grid.zig @@ -500,7 +500,7 @@ fn rowCommandCost(row: TerminalRow) usize { // Combining clusters deliberately paint alone. Plain cells merge while // foreground, decoration, and scratch capacity agree. if (multiCodepointCluster(cell)) { - total += 1 + @intFromBool(cell.underline); + total += 1 + @as(usize, @intFromBool(cell.underline)); i += 1; continue; } @@ -518,7 +518,7 @@ fn rowCommandCost(row: TerminalRow) usize { } text_bytes += next.cluster.len; } - total += 1 + @intFromBool(underline); + total += 1 + @as(usize, @intFromBool(underline)); i += span; } return total; diff --git a/src/primitives/canvas/terminal_grid_tests.zig b/src/primitives/canvas/terminal_grid_tests.zig index 2b8c5e10a..87aa1e94d 100644 --- a/src/primitives/canvas/terminal_grid_tests.zig +++ b/src/primitives/canvas/terminal_grid_tests.zig @@ -534,6 +534,23 @@ test "an underlined merged double run stays within the command budget" { try testing.expect(builder.displayList().commands.len <= 1000); } +test "ordinary underlined text is costed without integer overflow" { + var cells: [320]grid_model.TerminalCell = undefined; + for (&cells, 0..) |*c, i| { + c.* = cell('x', "x", if (i % 2 == 0) white else red); + c.underline = true; + } + const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; + + var commands: [1024]canvas.CanvasCommand = undefined; + var builder = try paintInto(baseGrid(&rows), &commands, .{ + .frame = geometry.RectF.init(0, 0, 2600, 40), + .tokens = .{}, + .command_budget = 700, + }); + try testing.expect(builder.displayList().commands.len <= 700); +} + test "an anonymous grid keeps the unkeyed convention: every command id 0" { const cells = [_]grid_model.TerminalCell{ cell('x', "x", white),