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
2 changes: 1 addition & 1 deletion src/automation/protocol.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub const fingerprint: u64 = layout_fingerprint.hash(layoutDescription(semantic_
/// enum). Declared-shape changes — new verbs, renamed actions, budget
/// or naming changes — need NO action here: the fingerprint moves on
/// its own.
pub const semantic_epoch: u32 = 1;
pub const semantic_epoch: u32 = 2;

/// The canonical description the protocol fingerprint hashes: the
/// command vocabulary (the `Action` enum, reflected — names and values,
Expand Down
9 changes: 9 additions & 0 deletions src/automation/snapshot.zig
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ pub const Widget = struct {
/// fallback surface) and what `widget-context-menu` invokes by
/// index.
context_menu: []const WidgetContextMenuItem = &.{},
/// Non-default context-menu policy name. Empty means `automatic`.
context_menu_policy: []const u8 = "",
};

/// One status-item dropdown row as the runtime last applied it. Slices
Expand Down Expand Up @@ -585,6 +587,7 @@ pub fn writeText(input: Input, writer: anytype) !void {
try writeWidgetActions(widget.actions, writer);
try writeWidgetTextRanges(widget, writer);
try writeWidgetContextMenu(widget, writer);
try writeWidgetContextMenuPolicy(widget, writer);
try writer.writeByte('\n');
}
if (input.tray) |tray| {
Expand Down Expand Up @@ -706,6 +709,7 @@ pub fn writeA11yText(input: Input, writer: anytype) !void {
try writeWidgetActions(widget.actions, writer);
try writeWidgetTextRanges(widget, writer);
try writeWidgetContextMenu(widget, writer);
try writeWidgetContextMenuPolicy(widget, writer);
try writer.writeByte('\n');
}
}
Expand Down Expand Up @@ -853,6 +857,11 @@ fn writeWidgetContextMenu(widget: Widget, writer: anytype) !void {
try writer.writeByte(']');
}

fn writeWidgetContextMenuPolicy(widget: Widget, writer: anytype) !void {
if (widget.context_menu_policy.len == 0) return;
try writer.print(" context_menu_policy={s}", .{widget.context_menu_policy});
}

test "snapshot emits window and source" {
var buffer: [512]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
Expand Down
28 changes: 20 additions & 8 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 @@ -71,17 +72,28 @@ pub const Error = error{
/// 198 points (Yuji Mai), 10 contours (Geist Mono) — 5-12x headroom.
///
/// Stack shape: the simple-glyph parse buffers
/// (`flags`/`xs`/`ys`/`end_points`) total ~9.5 KiB and live in exactly
/// (`flags`/`xs`/`ys`/`end_points`) total 36.5 KiB and live in exactly
/// 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;
/// never these arrays. The reference renderer's 133 KiB path builder is
/// separate per-thread heap scratch, so it does not overlap this storage
/// on the render-thread stack.
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;
pub const max_composite_components: usize = 8;

const simple_glyph_stack_scratch_bytes =
max_glyph_contours * @sizeOf(u16) +
max_glyph_points * (@sizeOf(u8) + 2 * @sizeOf(f32));
comptime {
if (simple_glyph_stack_scratch_bytes > 40 * 1024) {
@compileError("TrueType simple-glyph stack scratch exceeds its supported 40 KiB bound");
}
}

/// The bundled Geist Regular face (OFL), embedded so the reference
/// renderer paints real text without any platform font machinery. It
/// serves the sans font ids (weight/italic span variants included) at
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
43 changes: 21 additions & 22 deletions src/primitives/canvas/reference.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,29 @@ 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
/// derivation keeps capacity honest if they ever diverge. Stack shape:
/// at 28 B per element this is ~39 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.
/// budgets are currently equal, so the max is 4864 either way; the
/// derivation keeps capacity honest if they ever diverge. At 28 B per
/// element the builder is ~133 KiB, so it lives beside the rasterizer in
/// per-thread heap scratch rather than in `drawGlyphOutline`'s stack.
const reference_glyph_path_capacity: usize = @max(
font_ttf.max_glyph_points + 3 * font_ttf.max_glyph_contours,
font_ttf.max_composite_points + 3 * font_ttf.max_composite_contours,
);

/// Per-thread rasterizer for glyph fills: `vector.GlyphRasterizer`'s
const ReferenceGlyphPathBuilder = vector.PathBuilder(reference_glyph_path_capacity);

/// Per-thread path and raster scratch for glyph fills: the path builder is
/// ~133 KiB and `vector.GlyphRasterizer` is ~1.9 MiB. The latter'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
/// 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
/// the stack `Rasterizer` it replaces; `vector.fillGlyphPath` resets it
/// per glyph.
const ReferenceGlyphRasterScratch = struct {
/// admits rasterizes (never a block fallback). Together they occupy ~2.0
/// MiB behind one lazy TLS pointer, not the render-thread stack or static
/// TLS, and only threads that ink a glyph allocate them. The arrays carry
/// no defaults and stay uninitialized; each operation resets their lengths.
const ReferenceGlyphScratch = struct {
path: ReferenceGlyphPathBuilder,
raster: vector.GlyphRasterizer,
};
const reference_glyph_raster_scratch = @import("lazy_tls.zig").LazyTls(ReferenceGlyphRasterScratch);
const reference_glyph_scratch = @import("lazy_tls.zig").LazyTls(ReferenceGlyphScratch);

const referenceBlurKernel = reference_blur.referenceBlurKernel;
const referenceBlurSampleWithKernel = reference_blur.referenceBlurSampleWithKernel;
Expand Down Expand Up @@ -866,9 +865,10 @@ pub const ReferenceRenderSurface = struct {
const local = Affine{ .a = scale, .b = 0, .c = 0, .d = -scale, .tx = pen_x + cell_inset, .ty = baseline };
const total = command.transform.multiply(local);

var builder = vector.PathBuilder(reference_glyph_path_capacity){};
face.glyphOutline(glyph, total, &builder) catch return false;
if (builder.slice().len == 0) return true; // Space: nothing to ink.
const scratch = reference_glyph_scratch.get();
scratch.path.reset();
face.glyphOutline(glyph, total, &scratch.path) catch return false;
if (scratch.path.slice().len == 0) return true; // Space: nothing to ink.

const pixel_rect = referencePixelRect(draw_bounds, self.width, self.height) orelse return true;
// Glyph coverage blends in sRGB, not linear light (see
Expand All @@ -890,8 +890,8 @@ pub const ReferenceRenderSurface = struct {
// `max_raster_width` (surface-shaped, not font-shaped) and
// nothing else.
vector.fillGlyphPath(
&reference_glyph_raster_scratch.get().raster,
builder.slice(),
&scratch.raster,
scratch.path.slice(),
Affine.identity(),
.nonzero,
vector.default_tolerance,
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
1 change: 1 addition & 0 deletions src/primitives/canvas/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ pub const WidgetActions = widget_model.WidgetActions;
pub const WidgetSemantics = widget_model.WidgetSemantics;
pub const WidgetContextMenuItem = widget_model.WidgetContextMenuItem;
pub const CodeDiffLines = widget_model.CodeDiffLines;
pub const WidgetContextMenuPolicy = widget_model.WidgetContextMenuPolicy;
pub const Widget = widget_model.Widget;
pub const BuiltinComponentOptions = widget_model.BuiltinComponentOptions;
pub const WidgetCommandPart = widget_model.WidgetCommandPart;
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
Loading
Loading