Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions src/primitives/canvas/font_ttf.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/primitives/canvas/font_ttf_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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];
Expand Down
7 changes: 3 additions & 4 deletions src/primitives/canvas/reference.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
129 changes: 79 additions & 50 deletions src/primitives/canvas/terminal_grid.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 + @as(usize, @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 + @as(usize, @intFromBool(underline));
i += span;
}
return total;
}
Expand Down
58 changes: 56 additions & 2 deletions src/primitives/canvas/terminal_grid_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -750,10 +767,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);
Expand Down Expand Up @@ -922,6 +940,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 }};
Expand Down
17 changes: 8 additions & 9 deletions src/primitives/canvas/vector.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading