a73x

8f9e9953

Extract cell_instance helpers into shared module

a73x   2026-04-17 15:44

appendCellInstances and glyphTopOffset were duplicated between
main.zig and capture.zig. Move both to src/cell_instance.zig so
both callers (and future test_render) share one implementation.

Also drop unused alloc param from playScript and rename the
visibility constant to VISIBILITY_TIMEOUT_NS to reflect it is
a duration, not a deadline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/build.zig b/build.zig
index ebc0043..b11cf46 100644
--- a/build.zig
+++ b/build.zig
@@ -302,6 +302,18 @@ pub fn build(b: *std.Build) void {
    const png_tests = b.addTest(.{ .root_module = png_test_mod });
    test_step.dependOn(&b.addRunArtifact(png_tests).step);

    // cell_instance module — shared appendCellInstances / glyphTopOffset helpers
    const cell_instance_mod = b.createModule(.{
        .root_source_file = b.path("src/cell_instance.zig"),
        .target = target,
        .optimize = optimize,
    });
    cell_instance_mod.addImport("renderer", renderer_mod);
    cell_instance_mod.addImport("font", font_mod);
    cell_instance_mod.addImport("vt", vt_mod);
    exe_mod.addImport("cell_instance", cell_instance_mod);
    main_test_mod.addImport("cell_instance", cell_instance_mod);

    // capture module — --capture mode (render a VT script to PNG)
    const capture_mod = b.createModule(.{
        .root_source_file = b.path("src/capture.zig"),
@@ -317,5 +329,6 @@ pub fn build(b: *std.Build) void {
    capture_mod.addImport("config", config_mod);
    capture_mod.addImport("png", png_mod);
    capture_mod.addImport("vulkan", vulkan_module);
    capture_mod.addImport("cell_instance", cell_instance_mod);
    exe_mod.addImport("capture", capture_mod);
}
diff --git a/src/capture.zig b/src/capture.zig
index 843f2f0..1c82e21 100644
--- a/src/capture.zig
+++ b/src/capture.zig
@@ -36,9 +36,13 @@ pub const CaptureError = error{
    PngEncodeFailed,
};

const cell_instance = @import("cell_instance");
const appendCellInstances = cell_instance.appendCellInstances;
const glyphTopOffset = cell_instance.glyphTopOffset;

const CAPTURE_COLS: u16 = 80;
const CAPTURE_ROWS: u16 = 24;
const VISIBILITY_DEADLINE_NS: i128 = 3 * std.time.ns_per_s;
const VISIBILITY_TIMEOUT_NS: i128 = 3 * std.time.ns_per_s;

/// Entry point. `argv[0]` is `--capture`; argv[1] = script path, argv[2] = out path.
pub fn run(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void {
@@ -147,7 +151,7 @@ pub fn run(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void {
    }

    // === play script through /bin/cat ===
    try playScript(alloc, term, script_path);
    try playScript(term, script_path);

    // === snapshot + build instances ===
    try term.snapshot();
@@ -195,14 +199,14 @@ pub fn run(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void {
    std.debug.print("capture: wrote {s} ({d}x{d})\n", .{ out_path, px_w, px_h });
}

/// Wait up to VISIBILITY_DEADLINE_NS for the Wayland compositor to `configure`
/// Wait up to VISIBILITY_TIMEOUT_NS for the Wayland compositor to `configure`
/// the surface. For `--capture` we don't need the surface to actually be
/// mapped onto an output (which would require committing a presentable
/// buffer via the swapchain — we deliberately skip that since rendering is
/// offscreen). A configured surface is enough to know our fixed 80x24
/// geometry was accepted.
fn waitUntilVisible(conn: *wayland_client.Connection, window: *wayland_client.Window) !void {
    const deadline = @as(i128, std.time.nanoTimestamp()) + VISIBILITY_DEADLINE_NS;
    const deadline = @as(i128, std.time.nanoTimestamp()) + VISIBILITY_TIMEOUT_NS;
    while (std.time.nanoTimestamp() < deadline) {
        _ = conn.display.roundtrip();
        if (window.state.configured) return;
@@ -218,13 +222,13 @@ fn waitUntilVisible(conn: *wayland_client.Connection, window: *wayland_client.Wi
/// Spawn `/bin/cat <script>` on a PTY; feed all its output into `term`.
/// Returns once the child has exited AND two consecutive 20 ms polls
/// produce no new bytes (drain).
///
/// Note: spawns cat with script as argv rather than piping stdin+^D —
/// avoids EOF-signalling races with VT escape sequences.
fn playScript(
    alloc: std.mem.Allocator,
    term: *vt.Terminal,
    script_path: [:0]const u8,
) !void {
    _ = alloc;

    var p = try pty.Pty.spawn(.{
        .cols = CAPTURE_COLS,
        .rows = CAPTURE_ROWS,
@@ -320,52 +324,6 @@ fn buildInstancesForSnapshot(
    }
}

/// Mirror of main.zig's `appendCellInstances` (kept local to avoid making
/// that function public just for this module). Appends 0-2 instances per
/// cell: a filled-background quad if the bg differs from the terminal
/// default, plus the glyph quad if the cell has a printable codepoint.
fn appendCellInstances(
    alloc: std.mem.Allocator,
    instances: *std.ArrayListUnmanaged(renderer.Instance),
    row_idx: u32,
    col_idx: u32,
    cell_w: u32,
    cell_h: u32,
    baseline: u32,
    glyph_uv: ?font.GlyphUV,
    bg_uv: font.GlyphUV,
    colors: vt.CellColors,
    default_bg: [4]f32,
) !void {
    if (!std.meta.eql(colors.bg, default_bg)) {
        try instances.append(alloc, .{
            .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
            .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
            .glyph_bearing = .{ 0, 0 },
            .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 },
            .fg = colors.bg,
            .bg = colors.bg,
        });
    }

    const uv = glyph_uv orelse return;
    try instances.append(alloc, .{
        .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
        .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) },
        .glyph_bearing = .{
            @floatFromInt(uv.bearing_x),
            glyphTopOffset(baseline, uv.bearing_y),
        },
        .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
        .fg = colors.fg,
        .bg = colors.bg,
    });
}

fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 {
    return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y));
}

/// Encode `rgba` as a PNG to a brand-new file at `path`. Buffers the full
/// encoded byte stream in memory (fine for 80x24@16px: under 200 KB) and
/// writes it in one shot.
diff --git a/src/cell_instance.zig b/src/cell_instance.zig
new file mode 100644
index 0000000..53a78e9
--- /dev/null
+++ b/src/cell_instance.zig
@@ -0,0 +1,58 @@
//! Shared cell-instance helpers used by both the live renderer (main.zig)
//! and the capture renderer (capture.zig).

const std = @import("std");
const renderer = @import("renderer");
const font = @import("font");
const vt = @import("vt");

/// Appends 0-2 `renderer.Instance` entries for a single terminal cell:
///   - a filled-background quad when the cell's bg differs from the terminal
///     default bg (so transparent cells don't draw a quad at all);
///   - a glyph quad when `glyph_uv` is non-null (i.e. the cell has a
///     printable codepoint that was found in the atlas).
pub fn appendCellInstances(
    alloc: std.mem.Allocator,
    instances: *std.ArrayListUnmanaged(renderer.Instance),
    row_idx: u32,
    col_idx: u32,
    cell_w: u32,
    cell_h: u32,
    baseline: u32,
    glyph_uv: ?font.GlyphUV,
    bg_uv: font.GlyphUV,
    colors: vt.CellColors,
    default_bg: [4]f32,
) !void {
    if (!std.meta.eql(colors.bg, default_bg)) {
        try instances.append(alloc, .{
            .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
            .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
            .glyph_bearing = .{ 0, 0 },
            .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 },
            .fg = colors.bg,
            .bg = colors.bg,
        });
    }

    const uv = glyph_uv orelse return;
    try instances.append(alloc, .{
        .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
        .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) },
        .glyph_bearing = .{
            @floatFromInt(uv.bearing_x),
            glyphTopOffset(baseline, uv.bearing_y),
        },
        .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
        .fg = colors.fg,
        .bg = colors.bg,
    });
}

/// Returns the number of pixels from the top of the cell to the top of the
/// glyph bitmap, given the cell `baseline` (pixels from cell top to the
/// typographic baseline) and the glyph's `bearing_y` (pixels from baseline
/// to the top of the glyph bitmap, positive = up).
pub fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 {
    return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y));
}
diff --git a/src/main.zig b/src/main.zig
index 10dc1a1..868fca4 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -8,6 +8,9 @@ const font = @import("font");
const config = @import("config");
const vk = @import("vulkan");
const bench_stats = @import("bench_stats");
const cell_instance = @import("cell_instance");
const appendCellInstances = cell_instance.appendCellInstances;
const glyphTopOffset = cell_instance.glyphTopOffset;
const FrameTiming = bench_stats.FrameTiming;
const FrameTimingRing = bench_stats.FrameTimingRing;
const SectionStats = bench_stats.SectionStats;
@@ -1494,48 +1497,6 @@ fn cursorTouchesDirtyRow(dirty_rows: []const bool, cursor: CursorRefreshContext)
    return false;
}

fn appendCellInstances(
    alloc: std.mem.Allocator,
    instances: *std.ArrayListUnmanaged(renderer.Instance),
    row_idx: u32,
    col_idx: u32,
    cell_w: u32,
    cell_h: u32,
    baseline: u32,
    glyph_uv: ?font.GlyphUV,
    bg_uv: font.GlyphUV,
    colors: vt.CellColors,
    default_bg: [4]f32,
) !void {
    if (!std.meta.eql(colors.bg, default_bg)) {
        try instances.append(alloc, .{
            .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
            .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
            .glyph_bearing = .{ 0, 0 },
            .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 },
            .fg = colors.bg,
            .bg = colors.bg,
        });
    }

    const uv = glyph_uv orelse return;
    try instances.append(alloc, .{
        .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) },
        .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) },
        .glyph_bearing = .{
            @floatFromInt(uv.bearing_x),
            glyphTopOffset(baseline, uv.bearing_y),
        },
        .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
        .fg = colors.fg,
        .bg = colors.bg,
    });
}

fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 {
    return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y));
}

fn encodeKeyboardEvent(
    term: *const vt.Terminal,
    ev: wayland_client.KeyboardEvent,