a73x

src/main.zig

Ref:   Size: 116.3 KiB

const std = @import("std");
const vt = @import("vt");
const pty = @import("pty");
const wayland_client = @import("wayland-client");
const renderer = @import("renderer");
const font = @import("font");
const config = @import("config");
const vk = @import("vulkan");

const c = @cImport({
    @cInclude("xkbcommon/xkbcommon-keysyms.h");
});

const GridSize = struct {
    cols: u16,
    rows: u16,
};

const ScaledGeometry = struct {
    buffer_scale: i32,
    px_size: u32,
    cell_w_px: u32, // buffer pixels
    cell_h_px: u32, // buffer pixels
    baseline_px: u32,
};

fn rebuildFaceForScale(
    face: *font.Face,
    atlas: *font.Atlas,
    font_path: [:0]const u8,
    font_index: c_int,
    base_px_size: u32,
    buffer_scale: i32,
) !ScaledGeometry {
    const scale: u32 = @intCast(@max(@as(i32, 1), buffer_scale));
    const new_px = base_px_size * scale;
    try face.reinit(font_path, font_index, new_px);
    atlas.reset();
    return .{
        .buffer_scale = @intCast(scale),
        .px_size = new_px,
        .cell_w_px = face.cellWidth(),
        .cell_h_px = face.cellHeight(),
        .baseline_px = face.baseline(),
    };
}

fn writePtyFromTerminal(_: *vt.Terminal, ctx: ?*anyopaque, data: []const u8) void {
    const p: *pty.Pty = @ptrCast(@alignCast(ctx orelse return));
    _ = p.write(data) catch |err| {
        std.log.warn("pty write callback failed: {}", .{err});
        return;
    };
}

fn updateWindowTitle(_: *vt.Terminal, ctx: ?*anyopaque, title: ?[:0]const u8) void {
    const window: *wayland_client.Window = @ptrCast(@alignCast(ctx orelse return));
    window.setTitle(title);
}

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    const args = try std.process.argsAlloc(alloc);
    defer std.process.argsFree(alloc, args);

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--headless")) {
        return runHeadless(alloc);
    }

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--wayland-smoke-test")) {
        return runWaylandSmokeTest(alloc);
    }

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--vulkan-smoke-test")) {
        return runVulkanSmokeTest(alloc);
    }

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--render-smoke-test")) {
        return runRenderSmokeTest(alloc);
    }

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--draw-smoke-test")) {
        return runDrawSmokeTest(alloc);
    }

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--text-compare")) {
        return runTextCoverageCompare(alloc);
    }

    return runTerminal(alloc);
}

fn runTerminal(alloc: std.mem.Allocator) !void {
    // === font first, to know cell size ===
    var font_lookup = try font.lookupConfiguredFont(alloc);
    defer font_lookup.deinit(alloc);

    const font_size: u32 = config.font_size_px;
    var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, font_size);
    defer face.deinit();

    var geom: ScaledGeometry = .{
        .buffer_scale = 1,
        .px_size = font_size,
        .cell_w_px = face.cellWidth(),
        .cell_h_px = face.cellHeight(),
        .baseline_px = face.baseline(),
    };
    // Mutable aliases so the scale-change path inside the main loop can
    // refresh them after a rebuildFaceForScale call without renaming every
    // downstream reference.
    var cell_w = geom.cell_w_px;
    var cell_h = geom.cell_h_px;
    var baseline = geom.baseline_px;

    // === grid size ===
    const initial_grid: GridSize = .{ .cols = 80, .rows = 24 };
    var cols: u16 = initial_grid.cols;
    var rows: u16 = initial_grid.rows;
    const initial_w: u32 = @as(u32, cols) * cell_w;
    const initial_h: u32 = @as(u32, rows) * cell_h;

    // === wayland ===
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();

    const window = try conn.createWindow(alloc, "waystty");
    defer window.deinit();

    // Set window to desired terminal dimensions
    window.width = initial_w;
    window.height = initial_h;

    _ = conn.display.roundtrip();

    // === keyboard ===
    var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?);
    defer keyboard.deinit();

    // === pointer ===
    var pointer = try wayland_client.Pointer.init(alloc, conn.globals.seat.?);
    defer pointer.deinit();

    // === selection UI state ===
    var selection: SelectionState = .{};

    // === clipboard ===
    const clipboard: ?*wayland_client.Clipboard = if (conn.globals.data_device_manager) |manager|
        try wayland_client.Clipboard.init(alloc, conn.display, manager, conn.globals.seat.?)
    else
        null;
    defer if (clipboard) |cb| cb.deinit();

    // === renderer ===
    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width,
        window.height,
    );
    defer ctx.deinit();

    // === glyph atlas ===
    var atlas = try font.Atlas.init(alloc, 1024, 1024);
    defer atlas.deinit();

    // Precompute printable ASCII glyphs (32-126) into atlas
    for (32..127) |cp| {
        _ = atlas.getOrInsert(&face, @intCast(cp)) catch |err| switch (err) {
            error.AtlasFull => break,
            else => return err,
        };
    }
    // Upload warm atlas (full upload — descriptor set needs valid data)
    try ctx.uploadAtlas(atlas.pixels);
    atlas.last_uploaded_y = atlas.cursor_y;
    atlas.needs_full_upload = false;
    atlas.dirty = false;

    // === terminal ===
    var term = try vt.Terminal.init(alloc, .{
        .cols = cols,
        .rows = rows,
        .max_scrollback = 1000,
    });
    defer term.deinit();
    term.setTitleChangedCallback(window, &updateWindowTitle);
    term.setReportedSize(.{
        .rows = rows,
        .columns = cols,
        .cell_width = cell_w,
        .cell_height = cell_h,
    });

    // === pty ===
    const shell: [:0]const u8 = blk: {
        if (std.posix.getenv("WAYSTTY_BENCH") != null) {
            break :blk try alloc.dupeZ(u8, "/bin/sh");
        }
        const shell_env = std.posix.getenv("SHELL") orelse "/bin/sh";
        break :blk try alloc.dupeZ(u8, shell_env);
    };
    defer alloc.free(shell);

    const bench_script: ?[:0]const u8 = if (std.posix.getenv("WAYSTTY_BENCH") != null)
        "echo warmup; sleep 0.2; seq 1 50000; find /usr/lib -name '*.so' 2>/dev/null | head -500; yes 'hello world' | head -2000; exit 0"
    else
        null;

    var p = try pty.Pty.spawn(.{
        .cols = cols,
        .rows = rows,
        .shell = shell,
        .shell_args = if (bench_script) |script| &.{ "-c", script } else null,
    });
    defer p.deinit();
    term.setWritePtyCallback(&p, &writePtyFromTerminal);

    // === render cache ===
    var render_cache = RenderCache.empty;
    defer render_cache.deinit(alloc);
    try render_cache.resizeRows(alloc, rows);

    // === frame timing ===
    var frame_ring = FrameTimingRing{};
    installSigusr1Handler();

    // === main loop ===
    const wl_fd = conn.display.getFd();
    var pollfds = [_]std.posix.pollfd{
        .{ .fd = wl_fd,       .events = std.posix.POLL.IN, .revents = 0 },
        .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
    };

    var read_buf: [8192]u8 = undefined;
    var key_buf: [32]u8 = undefined;
    var last_window_w = window.width;
    var last_window_h = window.height;
    var last_scale: i32 = geom.buffer_scale;
    var render_pending = true;

    while (!window.should_close and p.isChildAlive()) {
        // Flush any pending wayland requests
        _ = conn.display.flush();

        const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
        _ = std.posix.poll(&pollfds, computePollTimeoutMs(repeat_timeout_ms, render_pending)) catch {};

        // Wayland events: prepare_read / read_events / dispatch_pending
        if (pollfds[0].revents & std.posix.POLL.IN != 0) {
            if (conn.display.prepareRead()) {
                _ = conn.display.readEvents();
            }
        }
        _ = conn.display.dispatchPending();

        // PTY output
        if (pollfds[1].revents & std.posix.POLL.IN != 0) {
            while (true) {
                const n = p.read(&read_buf) catch |err| switch (err) {
                    error.WouldBlock => break,
                    error.InputOutput => break, // child likely exited
                    else => return err,
                };
                if (n == 0) break;
                term.write(read_buf[0..n]);
                render_pending = true;
            }
        }

        // Pointer events → update selection state
        const ptr_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
        const ptr_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale));
        const prev_selection = activeSelectionSpan(selection);
        for (pointer.event_queue.items) |ev| {
            handlePointerSelectionEvent(&selection, ev, ptr_cell_w, ptr_cell_h, cols, rows);
        }
        const selection_changed = !std.meta.eql(activeSelectionSpan(selection), prev_selection);
        if (pointer.event_queue.items.len > 0) {
            pointer.event_queue.clearRetainingCapacity();
            render_pending = true;
        }

        // Keyboard events → write to pty
        keyboard.tickRepeat();
        for (keyboard.event_queue.items) |ev| {
            if (ev.action == .release) continue;
            if (isClipboardPasteEvent(ev)) {
                if (clipboard) |cb| {
                    if (try cb.receiveSelectionText(alloc)) |text| {
                        defer alloc.free(text);
                        const encoded = term.encodePaste(text);
                        for (encoded) |chunk| {
                            if (chunk.len == 0) continue;
                            _ = try p.write(chunk);
                        }
                    }
                }
                continue;
            }
            if (isClipboardCopyEvent(ev)) {
                _ = try copySelectionText(alloc, clipboard, term, activeSelectionSpan(selection), ev.serial);
                continue;
            }
            if (ev.utf8_len > 0) {
                _ = try p.write(ev.utf8[0..ev.utf8_len]);
            } else if (try encodeKeyboardEvent(term, ev, &key_buf)) |encoded| {
                _ = try p.write(encoded);
            }
        }
        keyboard.event_queue.clearRetainingCapacity();

        const current_scale = window.bufferScale();
        if (current_scale != last_scale) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);

            geom = try rebuildFaceForScale(
                &face,
                &atlas,
                font_lookup.path,
                font_lookup.index,
                font_size,
                current_scale,
            );
            cell_w = geom.cell_w_px;
            cell_h = geom.cell_h_px;
            baseline = geom.baseline_px;

            // Wipe all cached row instances + mark the terminal fully dirty so that
            // every glyph gets re-inserted into the freshly reset atlas next frame.
            render_cache.invalidateAfterResize();
            term.render_state.dirty = .full;

            window.surface.setBufferScale(geom.buffer_scale);

            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);

            last_scale = current_scale;
            render_pending = true;
        }

        if (window.width != last_window_w or window.height != last_window_h) {
            // Grid is sized in surface coordinates. cell_w/cell_h are in buffer
            // pixels, so divide by buffer_scale to get surface-pixel cell dims.
            const surf_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
            const surf_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale));
            const new_grid = gridSizeForWindow(window.width, window.height, surf_cell_w, surf_cell_h);
            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            if (new_grid.cols != cols or new_grid.rows != rows) {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                try ctx.recreateSwapchain(buf_w, buf_h);
                try term.resize(new_grid.cols, new_grid.rows);
                try p.resize(new_grid.cols, new_grid.rows);
                cols = new_grid.cols;
                rows = new_grid.rows;
                term.setReportedSize(.{
                    .rows = rows,
                    .columns = cols,
                    .cell_width = cell_w,
                    .cell_height = cell_h,
                });
                selection.committed = if (selection.committed) |span| clampSelectionSpan(span, cols, rows) else null;
                selection.active = if (selection.active) |span| clampSelectionSpan(span, cols, rows) else null;
                selection.anchor = if (selection.anchor) |point| clampGridPoint(point, cols, rows) else null;
                selection.hover = if (selection.hover) |point| clampGridPoint(point, cols, rows) else null;
            } else {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                try ctx.recreateSwapchain(buf_w, buf_h);
            }
            last_window_w = window.width;
            last_window_h = window.height;
            render_pending = true;
        }

        if (!shouldRenderFrame(render_pending, false, false)) continue;

        var frame_timing: FrameTiming = .{};

        // === render ===
        const previous_cursor = term.render_state.cursor;
        var section_timer = std.time.Timer.start() catch unreachable;
        try term.snapshot();
        frame_timing.snapshot_us = usFromTimer(&section_timer);

        section_timer = std.time.Timer.start() catch unreachable;
        const default_bg = term.backgroundColor();
        const bg_uv = atlas.cursorUV();

        const term_rows = term.render_state.row_data.items(.cells);
        const dirty_rows = term.render_state.row_data.items(.dirty);
        try render_cache.resizeRows(alloc, term_rows.len);

        const refresh_plan = planRowRefresh(
            if (term.render_state.dirty == .full or selection_changed) .full else .partial,
            dirty_rows,
            .{
                .cursor = .{
                    .old_row = if (previous_cursor.viewport) |cursor| @intCast(cursor.y) else null,
                    .new_row = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.y) else null,
                    .old_col = if (previous_cursor.viewport) |cursor| @intCast(cursor.x) else null,
                    .new_col = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.x) else null,
                    .old_visible = previous_cursor.visible,
                    .new_visible = term.render_state.cursor.visible,
                },
            },
        );

        var rows_rebuilt: usize = 0;
        var row_idx: usize = 0;
        const current_selection = activeSelectionSpan(selection);
        while (row_idx < term_rows.len) : (row_idx += 1) {
            if (!refresh_plan.full_rebuild and !refresh_plan.rows_to_rebuild.isSet(row_idx)) continue;

            const previous_gpu_offset = render_cache.rows[row_idx].gpu_offset_instances;
            const previous_gpu_len = render_cache.rows[row_idx].gpu_len_instances;
            const rebuilt = try rebuildRowInstances(
                alloc,
                &render_cache.rows[row_idx],
                @intCast(row_idx),
                term_rows[row_idx],
                term,
                &face,
                &atlas,
                cell_w,
                cell_h,
                baseline,
                default_bg,
                bg_uv,
                current_selection,
            );
            if (rebuilt.len_changed) {
                render_cache.layout_dirty = true;
            } else {
                render_cache.rows[row_idx].gpu_offset_instances = previous_gpu_offset;
                render_cache.rows[row_idx].gpu_len_instances = previous_gpu_len;
            }
            rows_rebuilt += 1;
        }

        var cursor_rebuilt = false;
        if (refresh_plan.cursor_rebuild) {
            var cursor_instances_buf: [1]renderer.Instance = undefined;
            var cursor_instances: []const renderer.Instance = &.{};
            if (term.render_state.cursor.viewport) |cursor| {
                const cursor_uv = atlas.cursorUV();
                cursor_instances_buf[0] = .{
                    .cell_pos = .{
                        @floatFromInt(cursor.x),
                        @floatFromInt(cursor.y),
                    },
                    .glyph_size = .{
                        @floatFromInt(cell_w),
                        @floatFromInt(cell_h),
                    },
                    .glyph_bearing = .{ 0, 0 },
                    .uv_rect = .{
                        cursor_uv.u0,
                        cursor_uv.v0,
                        cursor_uv.u1,
                        cursor_uv.v1,
                    },
                    .fg = .{ 1.0, 1.0, 1.0, 0.5 },
                    .bg = .{ 0, 0, 0, 0 },
                };
                cursor_instances = cursor_instances_buf[0..1];
            }
            const previous_total_instance_count = render_cache.total_instance_count;
            const previous_layout_dirty = render_cache.layout_dirty;
            const rebuilt = try render_cache.rebuildCursorInstances(alloc, cursor_instances);
            if (!rebuilt.len_changed) {
                render_cache.layout_dirty = previous_layout_dirty;
                render_cache.total_instance_count = previous_total_instance_count;
            }
            cursor_rebuilt = true;
        }

        frame_timing.row_rebuild_us = usFromTimer(&section_timer);

        section_timer = std.time.Timer.start() catch unreachable;
        // Re-upload atlas if new glyphs were added (incremental)
        if (atlas.dirty) {
            const y_start = atlas.last_uploaded_y;
            const y_end = atlas.cursor_y + atlas.row_height;
            if (y_start < y_end) {
                try ctx.uploadAtlasRegion(
                    atlas.pixels,
                    y_start,
                    y_end,
                    atlas.needs_full_upload,
                );
                atlas.last_uploaded_y = atlas.cursor_y;
                atlas.needs_full_upload = false;
                render_cache.layout_dirty = true;
            }
            atlas.dirty = false;
        }
        frame_timing.atlas_upload_us = usFromTimer(&section_timer);

        section_timer = std.time.Timer.start() catch unreachable;
        const upload_plan = applyRenderPlan(.{
            .layout_dirty = render_cache.layout_dirty,
            .rows_rebuilt = rows_rebuilt,
            .cursor_rebuilt = cursor_rebuilt,
        });

        if (upload_plan.full_upload) {
            const pack_result = try repackRowCaches(
                alloc,
                &render_cache.packed_instances,
                render_cache.rows,
                render_cache.cursor_instances.items,
            );
            render_cache.total_instance_count = pack_result.total_instances;
            render_cache.layout_dirty = false;
            if (render_cache.packed_instances.items.len > 0) {
                try ctx.uploadInstances(render_cache.packed_instances.items);
            }
        } else if (upload_plan.partial_upload) {
            var fallback_to_full_upload = false;

            var upload_row_idx: usize = 0;
            while (upload_row_idx < term_rows.len) : (upload_row_idx += 1) {
                if (!refresh_plan.full_rebuild and !refresh_plan.rows_to_rebuild.isSet(upload_row_idx)) continue;
                const row_cache = &render_cache.rows[upload_row_idx];
                if (try ctx.uploadInstanceRange(
                    row_cache.gpu_offset_instances,
                    row_cache.instances.items,
                )) {
                    fallback_to_full_upload = true;
                    break;
                }
            }

            if (!fallback_to_full_upload and cursor_rebuilt) {
                if (try ctx.uploadInstanceRange(
                    cursorOffsetInstances(render_cache.rows),
                    render_cache.cursor_instances.items,
                )) {
                    fallback_to_full_upload = true;
                }
            }

            if (fallback_to_full_upload) {
                const pack_result = try repackRowCaches(
                    alloc,
                    &render_cache.packed_instances,
                    render_cache.rows,
                    render_cache.cursor_instances.items,
                );
                render_cache.total_instance_count = pack_result.total_instances;
                render_cache.layout_dirty = false;
                if (render_cache.packed_instances.items.len > 0) {
                    try ctx.uploadInstances(render_cache.packed_instances.items);
                }
            }
        }

        frame_timing.instance_upload_us = usFromTimer(&section_timer);

        section_timer = std.time.Timer.start() catch unreachable;
        const baseline_coverage = renderer.coverageVariantParams(.baseline);
        ctx.drawCells(
            render_cache.total_instance_count,
            .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
            default_bg,
            baseline_coverage,
        ) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
                const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
                try ctx.recreateSwapchain(buf_w, buf_h);
                render_pending = true;
                continue;
            },
            else => return err,
        };
        frame_timing.gpu_submit_us = usFromTimer(&section_timer);

        frame_ring.push(frame_timing);

        // Check for SIGUSR1 stats dump request
        if (sigusr1_received.swap(false, .acq_rel)) {
            printFrameStats(computeFrameStats(&frame_ring));
        }

        clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
        render_pending = false;
    }

    // Dump timing stats on exit
    printFrameStats(computeFrameStats(&frame_ring));

    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
}

fn gridSizeForWindow(window_w: u32, window_h: u32, cell_w: u32, cell_h: u32) GridSize {
    const cols = @max(@as(u32, 1), if (cell_w == 0) 1 else window_w / cell_w);
    const rows = @max(@as(u32, 1), if (cell_h == 0) 1 else window_h / cell_h);
    return .{
        .cols = @intCast(cols),
        .rows = @intCast(rows),
    };
}

fn isClipboardPasteEvent(ev: wayland_client.KeyboardEvent) bool {
    return ev.action == .press and
        ev.modifiers.ctrl and
        ev.modifiers.shift and
        ev.keysym == c.XKB_KEY_V;
}

fn isClipboardCopyEvent(ev: wayland_client.KeyboardEvent) bool {
    return ev.action == .press and
        ev.modifiers.ctrl and
        ev.modifiers.shift and
        ev.keysym == c.XKB_KEY_C;
}

fn copySelectionText(
    alloc: std.mem.Allocator,
    clipboard: ?*wayland_client.Clipboard,
    term: *vt.Terminal,
    selection: ?SelectionSpan,
    serial: u32,
) !bool {
    // Guards fire in order:
    //   1. clipboard == null  → false (no Wayland clipboard available)
    //   2. selection == null  → false (no active selection span)
    //   3. text.len == 0     → false (selection covers only blank cells)
    // Guard 3 can only be reached with a real clipboard; it cannot be unit-tested
    // without a live Wayland connection.
    const cb = clipboard orelse return false;
    const span = selection orelse return false;
    const text = try extractSelectedText(alloc, term.render_state.row_data.items(.cells), span);
    defer alloc.free(text);
    if (text.len == 0) return false;
    try cb.setSelectionText(text, serial);
    return true;
}

fn remainingRepeatTimeoutMs(deadline_ns: ?i128) ?i32 {
    const deadline = deadline_ns orelse return null;
    const now = std.time.nanoTimestamp();
    if (deadline <= now) return 0;
    const remaining_ns = deadline - now;
    return @intCast(@divTrunc(remaining_ns + std.time.ns_per_ms - 1, std.time.ns_per_ms));
}

fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool) i32 {
    if (render_pending) return 0;
    return next_repeat_in_ms orelse -1;
}

fn shouldRenderFrame(terminal_dirty: bool, window_dirty: bool, forced: bool) bool {
    return terminal_dirty or window_dirty or forced;
}

fn extractSelectedText(
    alloc: std.mem.Allocator,
    row_data: anytype,
    span: SelectionSpan,
) ![]u8 {
    const normalized = span.normalized();
    if (row_data.len == 0) return try alloc.alloc(u8, 0);

    const max_row = row_data.len - 1;
    if (normalized.start.row > max_row) return try alloc.alloc(u8, 0);

    const visible_end_row = @min(normalized.end.row, max_row);
    if (normalized.start.row > visible_end_row) return try alloc.alloc(u8, 0);

    var out: std.ArrayListUnmanaged(u8) = .empty;
    errdefer out.deinit(alloc);

    const start_row: usize = @intCast(normalized.start.row);
    const end_row: usize = @intCast(visible_end_row);
    var row_idx = start_row;
    while (row_idx <= end_row) : (row_idx += 1) {
        if (row_idx > start_row) try out.append(alloc, '\n');
        try appendSelectedRowText(alloc, &out, row_data[row_idx], normalized, row_idx);
    }

    return out.toOwnedSlice(alloc);
}

fn appendSelectedRowText(
    alloc: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    row_cells: anytype,
    span: SelectionSpan,
    row_idx: usize,
) !void {
    if (row_cells.len == 0) return;

    const start_col: usize = if (row_idx == span.start.row)
        @intCast(span.start.col)
    else
        0;
    if (start_col >= row_cells.len) return;

    var end_col: usize = if (row_idx == span.end.row)
        @intCast(span.end.col)
    else
        row_cells.len - 1;
    if (end_col >= row_cells.len) end_col = row_cells.len - 1;

    while (end_col >= start_col) {
        const cell = row_cells.get(end_col);
        if (!isTrailingBlankCell(cell)) break;
        if (end_col == 0) return;
        end_col -= 1;
    }

    var col = start_col;
    while (col <= end_col) : (col += 1) {
        const cell = row_cells.get(col);
        try appendSelectedCellText(alloc, out, cell);
    }
}

fn isTrailingBlankCell(cell: anytype) bool {
    return !cell.raw.hasText() and cell.raw.wide != .spacer_tail and cell.raw.wide != .spacer_head;
}

fn appendSelectedCellText(
    alloc: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    cell: anytype,
) !void {
    if (cell.raw.wide == .spacer_tail or cell.raw.wide == .spacer_head) return;

    if (!cell.raw.hasText()) {
        try out.append(alloc, ' ');
        return;
    }

    try appendCodepoint(alloc, out, cell.raw.codepoint());
    if (cell.raw.hasGrapheme()) {
        for (cell.grapheme) |cp| {
            try appendCodepoint(alloc, out, cp);
        }
    }
}

fn appendCodepoint(
    alloc: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    cp: u21,
) !void {
    if (cp == 0) return;

    var utf8_buf: [4]u8 = undefined;
    const utf8_len = try std.unicode.utf8Encode(cp, &utf8_buf);
    try out.appendSlice(alloc, utf8_buf[0..utf8_len]);
}

const BTN_LEFT: u32 = 0x110;

const GridPoint = struct {
    col: u32,
    row: u32,
};

const SelectionState = struct {
    hover: ?GridPoint = null,
    anchor: ?GridPoint = null,
    active: ?SelectionSpan = null,
    committed: ?SelectionSpan = null,
};

fn clampGridPoint(point: GridPoint, cols: u16, rows: u16) ?GridPoint {
    if (cols == 0 or rows == 0) return null;
    const max_col = @as(u32, cols) - 1;
    const max_row = @as(u32, rows) - 1;
    return .{
        .col = @min(point.col, max_col),
        .row = @min(point.row, max_row),
    };
}

fn surfacePointToGrid(
    surface_x: f64,
    surface_y: f64,
    cell_w: u32,
    cell_h: u32,
    cols: u16,
    rows: u16,
) ?GridPoint {
    if (cell_w == 0 or cell_h == 0 or cols == 0 or rows == 0) return null;
    if (surface_x < 0 or surface_y < 0) return null;
    const col: u32 = @intFromFloat(@floor(surface_x / @as(f64, @floatFromInt(cell_w))));
    const row: u32 = @intFromFloat(@floor(surface_y / @as(f64, @floatFromInt(cell_h))));
    if (col >= cols or row >= rows) return null;
    return .{ .col = col, .row = row };
}

fn handlePointerSelectionEvent(
    state: *SelectionState,
    ev: wayland_client.PointerEvent,
    cell_w: u32,
    cell_h: u32,
    cols: u16,
    rows: u16,
) void {
    switch (ev) {
        .motion => |m| {
            state.hover = surfacePointToGrid(m.x, m.y, cell_w, cell_h, cols, rows);
            if (state.anchor) |anchor| {
                if (state.hover) |hover| {
                    state.active = .{ .start = anchor, .end = hover };
                }
            }
        },
        .button_press => |b| {
            if (b.button == BTN_LEFT) {
                state.committed = null;
                if (state.hover) |hover| {
                    state.anchor = hover;
                    state.active = .{ .start = hover, .end = hover };
                }
            }
        },
        .button_release => |b| {
            if (b.button == BTN_LEFT) {
                if (state.active) |span| {
                    state.committed = span;
                }
                state.active = null;
                state.anchor = null;
            }
        },
        .enter => |e| {
            state.hover = surfacePointToGrid(e.x, e.y, cell_w, cell_h, cols, rows);
        },
        .leave => {
            state.hover = null;
        },
    }
}

fn activeSelectionSpan(state: SelectionState) ?SelectionSpan {
    return state.active orelse state.committed;
}

fn selectionColors(base: vt.CellColors, selected: bool) vt.CellColors {
    if (!selected) return base;
    return .{
        .fg = .{ 0.08, 0.08, 0.08, 1.0 },
        .bg = .{ 0.78, 0.82, 0.88, 1.0 },
    };
}

const SelectionSpan = struct {
    start: GridPoint,
    end: GridPoint,

    fn normalized(self: SelectionSpan) SelectionSpan {
        if (self.start.row < self.end.row) return self;
        if (self.start.row == self.end.row and self.start.col <= self.end.col) return self;
        return .{ .start = self.end, .end = self.start };
    }

    fn containsCell(self: SelectionSpan, col: u32, row: u32) bool {
        const span = self.normalized();
        if (row < span.start.row or row > span.end.row) return false;
        if (span.start.row == span.end.row) {
            return row == span.start.row and col >= span.start.col and col <= span.end.col;
        }
        if (row == span.start.row) return col >= span.start.col;
        if (row == span.end.row) return col <= span.end.col;
        return true;
    }
};

fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan {
    if (cols == 0 or rows == 0) return null;

    const normalized = span.normalized();
    const max_col = @as(u32, cols) - 1;
    const max_row = @as(u32, rows) - 1;
    if (normalized.start.row > max_row) return null;

    const visible_end_row = @min(normalized.end.row, max_row);
    if (normalized.start.row > visible_end_row) return null;

    var visible_start: ?GridPoint = null;
    var visible_end: ?GridPoint = null;

    var row = normalized.start.row;
    while (row <= visible_end_row) : (row += 1) {
        const row_start_col = if (row == normalized.start.row) normalized.start.col else 0;
        const row_end_col = if (row == normalized.end.row) @min(normalized.end.col, max_col) else max_col;
        if (row_start_col > max_col or row_start_col > row_end_col) continue;

        if (visible_start == null) {
            visible_start = .{
                .col = row_start_col,
                .row = row,
            };
        }
        visible_end = .{
            .col = row_end_col,
            .row = row,
        };
    }

    return if (visible_start) |start_point| .{
        .start = start_point,
        .end = visible_end.?,
    } else null;
}

const FrameTiming = struct {
    snapshot_us: u32 = 0,
    row_rebuild_us: u32 = 0,
    atlas_upload_us: u32 = 0,
    instance_upload_us: u32 = 0,
    gpu_submit_us: u32 = 0,

    fn total(self: FrameTiming) u32 {
        return self.snapshot_us +
            self.row_rebuild_us +
            self.atlas_upload_us +
            self.instance_upload_us +
            self.gpu_submit_us;
    }
};

const FrameTimingRing = struct {
    const capacity = 256;

    entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity,
    head: usize = 0,
    count: usize = 0,

    fn push(self: *FrameTimingRing, timing: FrameTiming) void {
        const idx = if (self.count < capacity) self.count else self.head;
        self.entries[idx] = timing;
        if (self.count < capacity) {
            self.count += 1;
        } else {
            self.head = (self.head + 1) % capacity;
        }
    }

    /// Return a slice of valid entries in insertion order.
    /// Caller must provide a scratch buffer of `capacity` entries.
    fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming {
        if (self.count < capacity) {
            return self.entries[0..self.count];
        }
        // Ring has wrapped — copy from head..end then 0..head
        const tail_len = capacity - self.head;
        @memcpy(buf[0..tail_len], self.entries[self.head..capacity]);
        @memcpy(buf[tail_len..capacity], self.entries[0..self.head]);
        return buf[0..capacity];
    }
};

const SectionStats = struct {
    min: u32 = 0,
    avg: u32 = 0,
    p99: u32 = 0,
    max: u32 = 0,
};

const FrameTimingStats = struct {
    snapshot: SectionStats = .{},
    row_rebuild: SectionStats = .{},
    atlas_upload: SectionStats = .{},
    instance_upload: SectionStats = .{},
    gpu_submit: SectionStats = .{},
    total: SectionStats = .{},
    frame_count: usize = 0,
};

fn computeSectionStats(values: []u32) SectionStats {
    if (values.len == 0) return .{};
    std.mem.sort(u32, values, {}, std.sort.asc(u32));
    var sum: u64 = 0;
    for (values) |v| sum += v;
    const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100;
    return .{
        .min = values[0],
        .avg = @intCast(sum / values.len),
        .p99 = values[p99_idx],
        .max = values[values.len - 1],
    };
}

fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats {
    if (ring.count == 0) return .{};

    var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined;
    const entries = ring.orderedSlice(&ordered_buf);
    const n = entries.len;

    var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined;
    var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined;
    var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
    var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined;
    var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined;
    var total_vals: [FrameTimingRing.capacity]u32 = undefined;

    for (entries, 0..) |e, i| {
        snapshot_vals[i] = e.snapshot_us;
        row_rebuild_vals[i] = e.row_rebuild_us;
        atlas_upload_vals[i] = e.atlas_upload_us;
        instance_upload_vals[i] = e.instance_upload_us;
        gpu_submit_vals[i] = e.gpu_submit_us;
        total_vals[i] = e.total();
    }

    return .{
        .snapshot = computeSectionStats(snapshot_vals[0..n]),
        .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]),
        .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]),
        .instance_upload = computeSectionStats(instance_upload_vals[0..n]),
        .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]),
        .total = computeSectionStats(total_vals[0..n]),
        .frame_count = n,
    };
}

fn printFrameStats(stats: FrameTimingStats) void {
    const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n";
    std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count});
    std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6}  (us)\n", .{ "section", "min", "avg", "p99", "max" });
    std.debug.print(row_fmt, .{ "snapshot",        stats.snapshot.min,        stats.snapshot.avg,        stats.snapshot.p99,        stats.snapshot.max });
    std.debug.print(row_fmt, .{ "row_rebuild",     stats.row_rebuild.min,     stats.row_rebuild.avg,     stats.row_rebuild.p99,     stats.row_rebuild.max });
    std.debug.print(row_fmt, .{ "atlas_upload",    stats.atlas_upload.min,    stats.atlas_upload.avg,    stats.atlas_upload.p99,    stats.atlas_upload.max });
    std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max });
    std.debug.print(row_fmt, .{ "gpu_submit",      stats.gpu_submit.min,      stats.gpu_submit.avg,      stats.gpu_submit.p99,      stats.gpu_submit.max });
    std.debug.print("----------------------------------------------------\n", .{});
    std.debug.print(row_fmt, .{ "total",           stats.total.min,           stats.total.avg,           stats.total.p99,           stats.total.max });
}

var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);

fn sigusr1Handler(_: c_int) callconv(.c) void {
    sigusr1_received.store(true, .release);
}

fn installSigusr1Handler() void {
    const act = std.posix.Sigaction{
        .handler = .{ .handler = sigusr1Handler },
        .mask = std.posix.sigemptyset(),
        .flags = std.posix.SA.RESTART,
    };
    std.posix.sigaction(std.posix.SIG.USR1, &act, null);
}

fn usFromTimer(timer: *std.time.Timer) u32 {
    const ns = timer.read();
    const us = ns / std.time.ns_per_us;
    return std.math.cast(u32, us) orelse std.math.maxInt(u32);
}

test "SelectionSpan.normalized orders endpoints in reading order" {
    const span = (SelectionSpan{
        .start = .{ .col = 7, .row = 4 },
        .end = .{ .col = 2, .row = 1 },
    }).normalized();

    try std.testing.expectEqual(@as(u32, 2), span.start.col);
    try std.testing.expectEqual(@as(u32, 1), span.start.row);
    try std.testing.expectEqual(@as(u32, 7), span.end.col);
    try std.testing.expectEqual(@as(u32, 4), span.end.row);
}

test "SelectionSpan.containsCell includes the normalized endpoints" {
    const span = SelectionSpan{
        .start = .{ .col = 3, .row = 2 },
        .end = .{ .col = 1, .row = 1 },
    };

    try std.testing.expect(span.containsCell(1, 1));
    try std.testing.expect(span.containsCell(2, 1));
    try std.testing.expect(span.containsCell(0, 2));
    try std.testing.expect(span.containsCell(3, 2));
    try std.testing.expect(!span.containsCell(0, 0));
    try std.testing.expect(!span.containsCell(4, 2));
}

test "SelectionSpan.containsCell includes a same-cell span" {
    const span = SelectionSpan{
        .start = .{ .col = 5, .row = 5 },
        .end = .{ .col = 5, .row = 5 },
    };

    try std.testing.expect(span.containsCell(5, 5));
    try std.testing.expect(!span.containsCell(4, 5));
    try std.testing.expect(!span.containsCell(5, 4));
}

test "clampSelectionSpan preserves a same-cell visible span" {
    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 5, .row = 5 },
        .end = .{ .col = 5, .row = 5 },
    }, 80, 24).?;

    try std.testing.expectEqual(@as(u32, 5), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 5), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 5), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 5), clamped.end.row);
    try std.testing.expect(clamped.containsCell(5, 5));
}

test "clampSelectionSpan clears offscreen spans and trims resized spans" {
    try std.testing.expect(clampSelectionSpan(.{
        .start = .{ .col = 5, .row = 30 },
        .end = .{ .col = 10, .row = 40 },
    }, 80, 24) == null);

    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 78, .row = 22 },
        .end = .{ .col = 99, .row = 30 },
    }, 80, 24).?;

    try std.testing.expectEqual(@as(u32, 78), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 22), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 79), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 23), clamped.end.row);
}

test "clampSelectionSpan starts the visible span on the next row when the first row is clipped on the right" {
    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 5, .row = 0 },
        .end = .{ .col = 2, .row = 2 },
    }, 4, 3).?;

    try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 1), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 2), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 2), clamped.end.row);
}

test "clampSelectionSpan extends the clipped bottom row to the last visible column" {
    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 2, .row = 5 },
    }, 4, 2).?;

    try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 0), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 3), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 1), clamped.end.row);
}

test "clampSelectionSpan preserves a larger span that collapses to one visible cell" {
    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 120, .row = 80 },
    }, 1, 1).?;

    try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 0), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 0), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 0), clamped.end.row);
    try std.testing.expect(clamped.containsCell(0, 0));
}

test "SelectionState starts drag on left-button press and commits on release" {
    var state = SelectionState{};
    handlePointerSelectionEvent(&state, .{ .motion = .{ .time = 0, .x = 24.0, .y = 16.0 } }, 8, 16, 80, 24);
    handlePointerSelectionEvent(&state, .{ .button_press = .{ .serial = 0, .time = 0, .button = BTN_LEFT } }, 8, 16, 80, 24);
    handlePointerSelectionEvent(&state, .{ .motion = .{ .time = 0, .x = 56.0, .y = 16.0 } }, 8, 16, 80, 24);
    handlePointerSelectionEvent(&state, .{ .button_release = .{ .serial = 0, .time = 0, .button = BTN_LEFT } }, 8, 16, 80, 24);

    try std.testing.expect(state.active == null);
    try std.testing.expect(state.committed != null);
}

test "selectionColors overrides terminal colors for selected cells" {
    const selected = selectionColors(.{
        .fg = .{ 1.0, 1.0, 1.0, 1.0 },
        .bg = .{ 0.0, 0.0, 0.0, 1.0 },
    }, true);
    try std.testing.expectEqualDeep([4]f32{ 0.08, 0.08, 0.08, 1.0 }, selected.fg);
    try std.testing.expectEqualDeep([4]f32{ 0.78, 0.82, 0.88, 1.0 }, selected.bg);
}

const ComparisonVariant = struct {
    label: []const u8,
    coverage: [2]f32,
};

fn comparisonVariants() [4]ComparisonVariant {
    return .{
        .{ .label = "baseline", .coverage = renderer.coverageVariantParams(.baseline) },
        .{ .label = "mild", .coverage = renderer.coverageVariantParams(.mild) },
        .{ .label = "medium", .coverage = renderer.coverageVariantParams(.medium) },
        .{ .label = "crisp", .coverage = renderer.coverageVariantParams(.crisp) },
    };
}

fn comparisonSpecimenLines() []const []const u8 {
    return &.{
        "abcdefghijklmnopqrstuvwxyz",
        "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
        "0123456789",
        "{}[]()/\\|,.;:_-=+",
        "~/code/rad/waystty $ zig build test",
    };
}

fn comparisonPanelOrigins(panel_cols: u32, top_margin_rows: u32) [4][2]f32 {
    var origins = [_][2]f32{.{ 0.0, @floatFromInt(top_margin_rows) }} ** 4;
    var idx: usize = 0;
    while (idx < origins.len) : (idx += 1) {
        origins[idx] = .{
            @floatFromInt(idx * @as(usize, panel_cols)),
            @floatFromInt(top_margin_rows),
        };
    }

    return origins;
}

const ComparisonPanelDraw = struct {
    instance_offset_instances: u32,
    instance_count: u32,
    coverage: [2]f32,
    origin_col: u32,
};

const TextCoverageCompareScene = struct {
    instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
    panel_draws: [4]ComparisonPanelDraw,
    panel_cols: u32,
    window_cols: u32,
    window_rows: u32,

    fn deinit(self: *TextCoverageCompareScene, alloc: std.mem.Allocator) void {
        self.instances.deinit(alloc);
    }
};

fn comparisonPanelCols(lines: []const []const u8) u32 {
    var max_cols: u32 = 0;
    for (lines) |line| {
        max_cols = @max(max_cols, @as(u32, @intCast(line.len)));
    }
    return max_cols + 4;
}

fn comparisonVisibleGlyphCount(lines: []const []const u8) u32 {
    var count: u32 = 0;
    for (lines) |line| {
        for (line) |char| {
            if (char != ' ') count += 1;
        }
    }
    return count;
}

fn buildTextCoverageCompareScene(
    alloc: std.mem.Allocator,
    face: *font.Face,
    atlas: *font.Atlas,
) !TextCoverageCompareScene {
    const specimen_lines = comparisonSpecimenLines();
    const variants = comparisonVariants();
    const top_margin_rows: u32 = 2;
    const panel_cols = comparisonPanelCols(specimen_lines);
    const panel_origins = comparisonPanelOrigins(panel_cols, top_margin_rows);
    const panel_glyph_count = comparisonVisibleGlyphCount(specimen_lines);
    var total_glyph_count = panel_glyph_count * @as(u32, @intCast(variants.len));
    for (variants) |variant| {
        total_glyph_count += comparisonVisibleGlyphCount(&.{variant.label});
    }

    var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    errdefer instances.deinit(alloc);
    try instances.ensureTotalCapacity(alloc, total_glyph_count);

    const baseline = face.baseline();
    const fg = [4]f32{ 1.0, 1.0, 1.0, 1.0 };
    const bg = [4]f32{ 0.0, 0.0, 0.0, 1.0 };

    var panel_draws: [4]ComparisonPanelDraw = undefined;
    for (variants, 0..) |variant, panel_idx| {
        const origin = panel_origins[panel_idx];
        const instance_offset_instances: u32 = @intCast(instances.items.len);
        const label_row = origin[1] - @as(f32, @floatFromInt(top_margin_rows));

        for (variant.label, 0..) |char, col_idx| {
            if (char == ' ') continue;

            const glyph_uv = try atlas.getOrInsert(face, char);
            try instances.append(alloc, .{
                .cell_pos = .{
                    origin[0] + @as(f32, @floatFromInt(col_idx)),
                    label_row,
                },
                .glyph_size = .{
                    @floatFromInt(glyph_uv.width),
                    @floatFromInt(glyph_uv.height),
                },
                .glyph_bearing = .{
                    @floatFromInt(glyph_uv.bearing_x),
                    glyphTopOffset(baseline, glyph_uv.bearing_y),
                },
                .uv_rect = .{
                    glyph_uv.u0,
                    glyph_uv.v0,
                    glyph_uv.u1,
                    glyph_uv.v1,
                },
                .fg = fg,
                .bg = bg,
            });
        }

        for (specimen_lines, 0..) |line, row_idx| {
            for (line, 0..) |char, col_idx| {
                if (char == ' ') continue;

                const glyph_uv = try atlas.getOrInsert(face, char);
                try instances.append(alloc, .{
                    .cell_pos = .{
                        origin[0] + @as(f32, @floatFromInt(col_idx)),
                        origin[1] + @as(f32, @floatFromInt(row_idx)),
                    },
                    .glyph_size = .{
                        @floatFromInt(glyph_uv.width),
                        @floatFromInt(glyph_uv.height),
                    },
                    .glyph_bearing = .{
                        @floatFromInt(glyph_uv.bearing_x),
                        glyphTopOffset(baseline, glyph_uv.bearing_y),
                    },
                    .uv_rect = .{
                        glyph_uv.u0,
                        glyph_uv.v0,
                        glyph_uv.u1,
                        glyph_uv.v1,
                    },
                    .fg = fg,
                    .bg = bg,
                });
            }
        }

        panel_draws[panel_idx] = .{
            .instance_offset_instances = instance_offset_instances,
            .instance_count = @as(u32, @intCast(instances.items.len)) - instance_offset_instances,
            .coverage = variant.coverage,
            .origin_col = @as(u32, @intCast(panel_idx)) * panel_cols,
        };
    }

    return .{
        .instances = instances,
        .panel_draws = panel_draws,
        .panel_cols = panel_cols,
        .window_cols = panel_cols * @as(u32, @intCast(variants.len)),
        .window_rows = top_margin_rows + @as(u32, @intCast(specimen_lines.len)) + 2,
    };
}

fn drawTextCoverageCompareFrame(
    ctx: *renderer.Context,
    scene: *const TextCoverageCompareScene,
    cell_w_px: u32,
    cell_h_px: u32,
    clear_color: [4]f32,
) !void {
    _ = try ctx.vkd.waitForFences(
        ctx.device,
        1,
        @ptrCast(&ctx.in_flight_fence),
        .true,
        std.math.maxInt(u64),
    );
    try ctx.vkd.resetFences(ctx.device, 1, @ptrCast(&ctx.in_flight_fence));

    const acquire = ctx.vkd.acquireNextImageKHR(
        ctx.device,
        ctx.swapchain,
        std.math.maxInt(u64),
        ctx.image_available,
        .null_handle,
    ) catch |err| switch (err) {
        error.OutOfDateKHR => return error.OutOfDateKHR,
        else => return err,
    };
    if (acquire.result == .suboptimal_khr) return error.OutOfDateKHR;
    const image_index = acquire.image_index;

    try ctx.vkd.resetCommandBuffer(ctx.command_buffer, .{});
    try ctx.vkd.beginCommandBuffer(ctx.command_buffer, &vk.CommandBufferBeginInfo{
        .flags = .{ .one_time_submit_bit = true },
    });

    const clear_value = vk.ClearValue{ .color = .{ .float_32 = clear_color } };
    ctx.vkd.cmdBeginRenderPass(ctx.command_buffer, &vk.RenderPassBeginInfo{
        .render_pass = ctx.render_pass,
        .framebuffer = ctx.framebuffers[image_index],
        .render_area = .{
            .offset = .{ .x = 0, .y = 0 },
            .extent = ctx.swapchain_extent,
        },
        .clear_value_count = 1,
        .p_clear_values = @ptrCast(&clear_value),
    }, .@"inline");

    ctx.vkd.cmdBindPipeline(ctx.command_buffer, .graphics, ctx.pipeline);

    const viewport = vk.Viewport{
        .x = 0.0,
        .y = 0.0,
        .width = @floatFromInt(ctx.swapchain_extent.width),
        .height = @floatFromInt(ctx.swapchain_extent.height),
        .min_depth = 0.0,
        .max_depth = 1.0,
    };
    ctx.vkd.cmdSetViewport(ctx.command_buffer, 0, 1, @ptrCast(&viewport));

    ctx.vkd.cmdBindDescriptorSets(
        ctx.command_buffer,
        .graphics,
        ctx.pipeline_layout,
        0,
        1,
        @ptrCast(&ctx.descriptor_set),
        0,
        null,
    );

    const buffers = [_]vk.Buffer{ ctx.quad_vertex_buffer, ctx.instance_buffer };
    const panel_width_px = scene.panel_cols * cell_w_px;

    for (scene.panel_draws) |panel_draw| {
        const scissor_x_px = panel_draw.origin_col * cell_w_px;
        if (scissor_x_px >= ctx.swapchain_extent.width) continue;

        const scissor = vk.Rect2D{
            .offset = .{ .x = @intCast(scissor_x_px), .y = 0 },
            .extent = .{
                .width = @min(panel_width_px, ctx.swapchain_extent.width - scissor_x_px),
                .height = ctx.swapchain_extent.height,
            },
        };
        ctx.vkd.cmdSetScissor(ctx.command_buffer, 0, 1, @ptrCast(&scissor));

        const push_constants = renderer.PushConstants{
            .viewport_size = .{
                @floatFromInt(ctx.swapchain_extent.width),
                @floatFromInt(ctx.swapchain_extent.height),
            },
            .cell_size = .{
                @floatFromInt(cell_w_px),
                @floatFromInt(cell_h_px),
            },
            .coverage_params = panel_draw.coverage,
        };
        ctx.vkd.cmdPushConstants(
            ctx.command_buffer,
            ctx.pipeline_layout,
            .{ .vertex_bit = true, .fragment_bit = true },
            0,
            @sizeOf(renderer.PushConstants),
            @ptrCast(&push_constants),
        );

        const offsets = [_]vk.DeviceSize{
            0,
            @as(vk.DeviceSize, panel_draw.instance_offset_instances) * @sizeOf(renderer.Instance),
        };
        ctx.vkd.cmdBindVertexBuffers(ctx.command_buffer, 0, 2, &buffers, &offsets);
        ctx.vkd.cmdDraw(ctx.command_buffer, 6, panel_draw.instance_count, 0, 0);
    }

    ctx.vkd.cmdEndRenderPass(ctx.command_buffer);
    try ctx.vkd.endCommandBuffer(ctx.command_buffer);

    const wait_stage = vk.PipelineStageFlags{ .color_attachment_output_bit = true };
    try ctx.vkd.queueSubmit(ctx.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
        .wait_semaphore_count = 1,
        .p_wait_semaphores = @ptrCast(&ctx.image_available),
        .p_wait_dst_stage_mask = @ptrCast(&wait_stage),
        .command_buffer_count = 1,
        .p_command_buffers = @ptrCast(&ctx.command_buffer),
        .signal_semaphore_count = 1,
        .p_signal_semaphores = @ptrCast(&ctx.render_finished),
    }), ctx.in_flight_fence);

    const present_result = ctx.vkd.queuePresentKHR(ctx.present_queue, &vk.PresentInfoKHR{
        .wait_semaphore_count = 1,
        .p_wait_semaphores = @ptrCast(&ctx.render_finished),
        .swapchain_count = 1,
        .p_swapchains = @ptrCast(&ctx.swapchain),
        .p_image_indices = @ptrCast(&image_index),
    }) catch |err| switch (err) {
        error.OutOfDateKHR => return error.OutOfDateKHR,
        else => return err,
    };
    if (present_result == .suboptimal_khr) return error.OutOfDateKHR;
}

const RowRefreshState = enum {
    full,
    partial,
};

const CursorRefreshContext = struct {
    old_row: ?usize,
    new_row: ?usize,
    old_col: ?usize,
    new_col: ?usize,
    old_visible: bool,
    new_visible: bool,
};

const RowRefreshContext = struct {
    cursor: CursorRefreshContext,
};

const RowRefreshPlan = struct {
    full_rebuild: bool,
    cursor_rebuild: bool,
    rows_to_rebuild: std.StaticBitSet(256),
};

fn planRowRefresh(
    state: RowRefreshState,
    dirty_rows: []const bool,
    ctx: RowRefreshContext,
) RowRefreshPlan {
    var rows_to_rebuild = std.StaticBitSet(256).initEmpty();

    const full_rebuild = state == .full or dirty_rows.len > rows_to_rebuild.capacity();
    const cursor_rebuild = full_rebuild or
        cursorNeedsRebuild(ctx.cursor) or
        cursorTouchesDirtyRow(dirty_rows, ctx.cursor);

    if (!full_rebuild) {
        var row_idx: usize = 0;
        while (row_idx < dirty_rows.len) : (row_idx += 1) {
            if (dirty_rows[row_idx]) rows_to_rebuild.set(row_idx);
        }
    }

    return .{
        .full_rebuild = full_rebuild,
        .cursor_rebuild = cursor_rebuild,
        .rows_to_rebuild = rows_to_rebuild,
    };
}

fn cursorNeedsRebuild(cursor: CursorRefreshContext) bool {
    return cursor.old_row != cursor.new_row or
        cursor.old_col != cursor.new_col or
        cursor.old_visible != cursor.new_visible;
}

fn cursorTouchesDirtyRow(dirty_rows: []const bool, cursor: CursorRefreshContext) bool {
    if (cursor.old_row) |row| {
        if (row < dirty_rows.len and dirty_rows[row]) return true;
    }
    if (cursor.new_row) |row| {
        if (row < dirty_rows.len and dirty_rows[row]) return true;
    }
    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,
    buf: []u8,
) !?[]const u8 {
    const key = mapKeysymToInputKey(ev.keysym) orelse return null;
    const encoded = try term.encodeKey(
        key,
        .{
            .shift = ev.modifiers.shift,
            .ctrl = ev.modifiers.ctrl,
            .alt = ev.modifiers.alt,
            .super = ev.modifiers.super,
        },
        switch (ev.action) {
            .press => .press,
            .release => .release,
            .repeat => .repeat,
        },
        buf,
    );

    if (encoded.len == 0) return null;
    return encoded;
}

fn mapKeysymToInputKey(keysym: u32) ?vt.InputKey {
    return switch (keysym) {
        c.XKB_KEY_Up => .arrow_up,
        c.XKB_KEY_Down => .arrow_down,
        c.XKB_KEY_Left => .arrow_left,
        c.XKB_KEY_Right => .arrow_right,
        c.XKB_KEY_Home => .home,
        c.XKB_KEY_End => .end,
        c.XKB_KEY_Page_Up => .page_up,
        c.XKB_KEY_Page_Down => .page_down,
        c.XKB_KEY_Insert => .insert,
        c.XKB_KEY_Delete => .delete,
        c.XKB_KEY_F1 => .f1,
        c.XKB_KEY_F2 => .f2,
        c.XKB_KEY_F3 => .f3,
        c.XKB_KEY_F4 => .f4,
        c.XKB_KEY_F5 => .f5,
        c.XKB_KEY_F6 => .f6,
        c.XKB_KEY_F7 => .f7,
        c.XKB_KEY_F8 => .f8,
        c.XKB_KEY_F9 => .f9,
        c.XKB_KEY_F10 => .f10,
        c.XKB_KEY_F11 => .f11,
        c.XKB_KEY_F12 => .f12,
        else => null,
    };
}

test "event loop waits indefinitely when idle and wakes for imminent repeat" {
    try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false));
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true));
    try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false));
}

test "event loop redraws only when terminal or window state changed" {
    try std.testing.expect(shouldRenderFrame(true, false, false));
    try std.testing.expect(shouldRenderFrame(false, true, false));
    try std.testing.expect(shouldRenderFrame(false, false, true));
    try std.testing.expect(!shouldRenderFrame(false, false, false));
}

test "planRowRefresh requests full rebuild for full dirty state" {
    const plan = planRowRefresh(.full, &.{ false, true, false }, .{
        .cursor = .{
            .old_row = null,
            .new_row = null,
            .old_col = null,
            .new_col = null,
            .old_visible = false,
            .new_visible = false,
        },
    });

    try std.testing.expect(plan.full_rebuild);
    try std.testing.expect(plan.cursor_rebuild);
    try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}

test "planRowRefresh selects only dirty rows for partial state" {
    const plan = planRowRefresh(.partial, &.{ false, true, false, true }, .{
        .cursor = .{
            .old_row = null,
            .new_row = null,
            .old_col = null,
            .new_col = null,
            .old_visible = false,
            .new_visible = false,
        },
    });

    try std.testing.expect(!plan.full_rebuild);
    try std.testing.expect(!plan.cursor_rebuild);
    try std.testing.expectEqual(@as(usize, 2), plan.rows_to_rebuild.count());
    try std.testing.expect(plan.rows_to_rebuild.isSet(1));
    try std.testing.expect(plan.rows_to_rebuild.isSet(3));
    try std.testing.expect(!plan.rows_to_rebuild.isSet(0));
    try std.testing.expect(!plan.rows_to_rebuild.isSet(2));
}

test "planRowRefresh handles cursor-only updates without unrelated rows" {
    const plan = planRowRefresh(.partial, &.{ false, false, false }, .{
        .cursor = .{
            .old_row = 1,
            .new_row = 2,
            .old_col = 4,
            .new_col = 4,
            .old_visible = true,
            .new_visible = true,
        },
    });

    try std.testing.expect(!plan.full_rebuild);
    try std.testing.expect(plan.cursor_rebuild);
    try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}

test "planRowRefresh forces full rebuild when dirty rows exceed fixed capacity" {
    var dirty_rows: [257]bool = .{false} ** 257;
    dirty_rows[256] = true;

    const plan = planRowRefresh(.partial, dirty_rows[0..], .{
        .cursor = .{
            .old_row = null,
            .new_row = null,
            .old_col = null,
            .new_col = null,
            .old_visible = true,
            .new_visible = true,
        },
    });

    try std.testing.expect(plan.full_rebuild);
    try std.testing.expect(plan.cursor_rebuild);
    try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}

test "planRowRefresh rebuilds cursor when only column changes on same row" {
    const plan = planRowRefresh(.partial, &.{ false, false, false }, .{
        .cursor = .{
            .old_row = 2,
            .new_row = 2,
            .old_col = 1,
            .new_col = 5,
            .old_visible = true,
            .new_visible = true,
        },
    });

    try std.testing.expect(!plan.full_rebuild);
    try std.testing.expect(plan.cursor_rebuild);
    try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}

test "planRowRefresh rebuilds cursor when its row is dirty without cursor movement" {
    const plan = planRowRefresh(.partial, &.{ false, true, false }, .{
        .cursor = .{
            .old_row = 1,
            .new_row = 1,
            .old_col = 4,
            .new_col = 4,
            .old_visible = true,
            .new_visible = true,
        },
    });

    try std.testing.expect(!plan.full_rebuild);
    try std.testing.expect(plan.cursor_rebuild);
    try std.testing.expectEqual(@as(usize, 1), plan.rows_to_rebuild.count());
}

test "repackRowCaches assigns contiguous offsets" {
    var rows = [_]RowInstanceCache{
        .{
            .instances = try makeTestInstances(std.testing.allocator, 2),
            .gpu_offset_instances = 99,
            .gpu_len_instances = 0,
        },
        .{
            .instances = try makeTestInstances(std.testing.allocator, 3),
            .gpu_offset_instances = 99,
            .gpu_len_instances = 0,
        },
    };
    defer for (&rows) |*row| row.instances.deinit(std.testing.allocator);

    var packed_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    defer packed_instances.deinit(std.testing.allocator);

    var cursor_instances = try makeTestInstances(std.testing.allocator, 1);
    defer cursor_instances.deinit(std.testing.allocator);

    rows[0].instances.items[0].cell_pos[1] = 10.0;
    rows[0].instances.items[1].cell_pos[1] = 10.0;
    rows[1].instances.items[0].cell_pos[1] = 20.0;
    rows[1].instances.items[1].cell_pos[1] = 20.0;
    rows[1].instances.items[2].cell_pos[1] = 20.0;
    cursor_instances.items[0].cell_pos[0] = 99.0;
    cursor_instances.items[0].cell_pos[1] = 99.0;

    const packed_result = try repackRowCaches(
        std.testing.allocator,
        &packed_instances,
        &rows,
        cursor_instances.items,
    );

    try std.testing.expectEqual(@as(u32, 0), rows[0].gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 2), rows[0].gpu_len_instances);
    try std.testing.expectEqual(@as(u32, 2), rows[1].gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 3), rows[1].gpu_len_instances);
    try std.testing.expectEqual(@as(u32, 6), packed_result.total_instances);
    try std.testing.expectEqual(@as(u32, 5), packed_result.cursor_offset_instances);
    try std.testing.expectEqual(@as(u32, 1), packed_result.cursor_len_instances);
    try std.testing.expectEqual(@as(usize, 6), packed_instances.items.len);
    try std.testing.expectEqualDeep([2]f32{ 0.0, 10.0 }, packed_instances.items[0].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ 1.0, 10.0 }, packed_instances.items[1].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ 0.0, 20.0 }, packed_instances.items[2].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ 1.0, 20.0 }, packed_instances.items[3].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ 2.0, 20.0 }, packed_instances.items[4].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ 99.0, 99.0 }, packed_instances.items[5].cell_pos);
}

test "markLayoutDirtyOnLenChange returns true when row length changes" {
    try std.testing.expect(markLayoutDirtyOnLenChange(2, 3));
    try std.testing.expect(!markLayoutDirtyOnLenChange(3, 3));
}

test "repackRowCaches keeps cursor span explicit for empty and non-empty cursor instances" {
    var rows = [_]RowInstanceCache{
        .{
            .instances = try makeTestInstances(std.testing.allocator, 1),
        },
    };
    defer for (&rows) |*row| row.instances.deinit(std.testing.allocator);

    var packed_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    defer packed_instances.deinit(std.testing.allocator);

    const empty_cursor_result = try repackRowCaches(std.testing.allocator, &packed_instances, &rows, &.{});
    try std.testing.expectEqual(@as(u32, 1), empty_cursor_result.total_instances);
    try std.testing.expectEqual(@as(u32, 1), empty_cursor_result.cursor_offset_instances);
    try std.testing.expectEqual(@as(u32, 0), empty_cursor_result.cursor_len_instances);

    var cursor_instances = try makeTestInstances(std.testing.allocator, 2);
    defer cursor_instances.deinit(std.testing.allocator);

    cursor_instances.items[0].cell_pos[0] = 7.0;
    cursor_instances.items[0].cell_pos[1] = 8.0;
    cursor_instances.items[1].cell_pos[0] = 9.0;
    cursor_instances.items[1].cell_pos[1] = 10.0;

    const non_empty_cursor_result = try repackRowCaches(
        std.testing.allocator,
        &packed_instances,
        &rows,
        cursor_instances.items,
    );

    try std.testing.expectEqual(@as(u32, 3), non_empty_cursor_result.total_instances);
    try std.testing.expectEqual(@as(u32, 1), non_empty_cursor_result.cursor_offset_instances);
    try std.testing.expectEqual(@as(u32, 2), non_empty_cursor_result.cursor_len_instances);
    try std.testing.expectEqual(@as(usize, 3), packed_instances.items.len);
    try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, packed_instances.items[0].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ 7.0, 8.0 }, packed_instances.items[1].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ 9.0, 10.0 }, packed_instances.items[2].cell_pos);
}

test "rebuildCursorInstances invalidates packed cursor state when count is unchanged" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);

    cache.cursor_instances = try makeTestInstances(std.testing.allocator, 1);
    cache.cursor_instances.items[0].cell_pos = .{ 99.0, 99.0 };
    try cache.packed_instances.append(std.testing.allocator, .{
        .cell_pos = .{ 11.0, 11.0 },
        .glyph_size = .{ 1.0, 1.0 },
        .glyph_bearing = .{ 0.0, 0.0 },
        .uv_rect = .{ 0.0, 0.0, 0.0, 0.0 },
        .fg = .{ 0.0, 0.0, 0.0, 0.0 },
        .bg = .{ 0.0, 0.0, 0.0, 0.0 },
    });
    cache.total_instance_count = 4;
    cache.layout_dirty = false;

    var cursor_instances = try makeTestInstances(std.testing.allocator, 1);
    defer cursor_instances.deinit(std.testing.allocator);
    cursor_instances.items[0].cell_pos = .{ 4.0, 7.0 };

    const rebuilt = try cache.rebuildCursorInstances(std.testing.allocator, cursor_instances.items);

    try std.testing.expect(!rebuilt.len_changed);
    try std.testing.expect(rebuilt.packed_invalidated);
    try std.testing.expectEqual(@as(usize, 1), cache.cursor_instances.items.len);
    try std.testing.expectEqualDeep([2]f32{ 4.0, 7.0 }, cache.cursor_instances.items[0].cell_pos);
    try std.testing.expectEqual(@as(usize, 0), cache.packed_instances.items.len);
    try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
    try std.testing.expect(cache.layout_dirty);
}

test "rebuildCursorInstances invalidates packed cursor state when cursor instance count changes" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);

    cache.cursor_instances = try makeTestInstances(std.testing.allocator, 1);
    try cache.packed_instances.append(std.testing.allocator, .{
        .cell_pos = .{ 13.0, 13.0 },
        .glyph_size = .{ 1.0, 1.0 },
        .glyph_bearing = .{ 0.0, 0.0 },
        .uv_rect = .{ 0.0, 0.0, 0.0, 0.0 },
        .fg = .{ 0.0, 0.0, 0.0, 0.0 },
        .bg = .{ 0.0, 0.0, 0.0, 0.0 },
    });
    cache.total_instance_count = 4;
    cache.layout_dirty = false;

    const rebuilt = try cache.rebuildCursorInstances(std.testing.allocator, &.{});

    try std.testing.expect(rebuilt.len_changed);
    try std.testing.expect(rebuilt.packed_invalidated);
    try std.testing.expectEqual(@as(usize, 0), cache.cursor_instances.items.len);
    try std.testing.expectEqual(@as(usize, 0), cache.packed_instances.items.len);
    try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
    try std.testing.expect(cache.layout_dirty);
}

test "rebuildRowInstances emits expected instances for a colored glyph row" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 80,
        .rows = 24,
    });
    defer term.deinit();

    term.write("\x1b[31;44mA\x1b[0m");
    try term.snapshot();

    var lookup = try font.lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try font.Face.init(std.testing.allocator, lookup.path, lookup.index, config.font_size_px);
    defer face.deinit();

    var atlas = try font.Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    var cache = RowInstanceCache{};
    defer cache.instances.deinit(std.testing.allocator);

    const row_cells = term.render_state.row_data.get(0).cells;
    const default_bg = term.backgroundColor();
    const rebuilt = try rebuildRowInstances(
        std.testing.allocator,
        &cache,
        0,
        row_cells,
        term,
        &face,
        &atlas,
        face.cellWidth(),
        face.cellHeight(),
        face.baseline(),
        default_bg,
        atlas.cursorUV(),
        null,
    );

    try std.testing.expect(rebuilt.len_changed);
    try std.testing.expectEqual(@as(usize, 2), cache.instances.items.len);

    const colors = term.cellColors(row_cells.get(0));
    try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, cache.instances.items[0].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ @floatFromInt(face.cellWidth()), @floatFromInt(face.cellHeight()) }, cache.instances.items[0].glyph_size);
    try std.testing.expectEqualDeep(colors.bg, cache.instances.items[0].fg);
    try std.testing.expectEqualDeep(colors.bg, cache.instances.items[0].bg);
    try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, cache.instances.items[1].cell_pos);
    try std.testing.expectEqualDeep(colors.fg, cache.instances.items[1].fg);
    try std.testing.expectEqualDeep(colors.bg, cache.instances.items[1].bg);
}

test "rebuildRowInstances replaces stale cached contents without layout dirtiness when count is unchanged" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 80,
        .rows = 24,
    });
    defer term.deinit();

    term.write("\x1b[31;44mA\x1b[0m");
    try term.snapshot();

    var lookup = try font.lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try font.Face.init(std.testing.allocator, lookup.path, lookup.index, config.font_size_px);
    defer face.deinit();

    var atlas = try font.Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    var cache = RowInstanceCache{};
    defer cache.instances.deinit(std.testing.allocator);
    cache.gpu_offset_instances = 17;
    cache.gpu_len_instances = 29;
    try cache.instances.append(std.testing.allocator, .{
        .cell_pos = .{ 99.0, 99.0 },
        .glyph_size = .{ 1.0, 1.0 },
        .glyph_bearing = .{ 0.0, 0.0 },
        .uv_rect = .{ 0.0, 0.0, 0.0, 0.0 },
        .fg = .{ 0.0, 0.0, 0.0, 0.0 },
        .bg = .{ 0.0, 0.0, 0.0, 0.0 },
    });
    try cache.instances.append(std.testing.allocator, .{
        .cell_pos = .{ 98.0, 98.0 },
        .glyph_size = .{ 1.0, 1.0 },
        .glyph_bearing = .{ 0.0, 0.0 },
        .uv_rect = .{ 0.0, 0.0, 0.0, 0.0 },
        .fg = .{ 0.0, 0.0, 0.0, 0.0 },
        .bg = .{ 0.0, 0.0, 0.0, 0.0 },
    });

    const row_cells = term.render_state.row_data.get(0).cells;
    const rebuilt = try rebuildRowInstances(
        std.testing.allocator,
        &cache,
        0,
        row_cells,
        term,
        &face,
        &atlas,
        face.cellWidth(),
        face.cellHeight(),
        face.baseline(),
        term.backgroundColor(),
        atlas.cursorUV(),
        null,
    );

    try std.testing.expect(!rebuilt.len_changed);
    try std.testing.expectEqual(@as(usize, 2), cache.instances.items.len);
    try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, cache.instances.items[0].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ 0.0, 0.0 }, cache.instances.items[1].cell_pos);
    try std.testing.expectEqualDeep([2]f32{ @floatFromInt(face.cellWidth()), @floatFromInt(face.cellHeight()) }, cache.instances.items[0].glyph_size);
    try std.testing.expectEqualDeep(term.cellColors(row_cells.get(0)).bg, cache.instances.items[0].fg);
    try std.testing.expectEqualDeep(term.cellColors(row_cells.get(0)).bg, cache.instances.items[0].bg);
    try std.testing.expectEqualDeep(term.cellColors(row_cells.get(0)).fg, cache.instances.items[1].fg);
    try std.testing.expectEqualDeep(term.cellColors(row_cells.get(0)).bg, cache.instances.items[1].bg);
    try std.testing.expectEqual(@as(u32, 0), cache.gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 0), cache.gpu_len_instances);
}

test "RenderCache resizeRows preserves surviving row caches" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);

    try cache.resizeRows(std.testing.allocator, 2);
    cache.rows[0].instances = try makeTestInstances(std.testing.allocator, 1);
    cache.rows[0].gpu_offset_instances = 17;
    cache.rows[0].gpu_len_instances = 1;

    try cache.resizeRows(std.testing.allocator, 3);
    try std.testing.expectEqual(@as(usize, 3), cache.rows.len);
    try std.testing.expectEqual(@as(usize, 1), cache.rows[0].instances.items.len);
    try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_len_instances);
    try std.testing.expectEqual(@as(usize, 0), cache.rows[2].instances.items.len);

    try cache.resizeRows(std.testing.allocator, 1);
    try std.testing.expectEqual(@as(usize, 1), cache.rows.len);
    try std.testing.expectEqual(@as(usize, 1), cache.rows[0].instances.items.len);
    try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_len_instances);
}

test "RenderCache deinit resets fields after releasing row storage" {
    var cache = RenderCache.empty;

    try cache.resizeRows(std.testing.allocator, 2);
    cache.rows[0].instances = try makeTestInstances(std.testing.allocator, 2);
    cache.rows[1].instances = try makeTestInstances(std.testing.allocator, 1);

    var cursor_instances = try makeTestInstances(std.testing.allocator, 1);
    defer cursor_instances.deinit(std.testing.allocator);
    try cache.cursor_instances.append(std.testing.allocator, cursor_instances.items[0]);

    var packed_instances = try makeTestInstances(std.testing.allocator, 1);
    defer packed_instances.deinit(std.testing.allocator);
    try cache.packed_instances.append(std.testing.allocator, packed_instances.items[0]);

    cache.total_instance_count = 3;
    cache.layout_dirty = false;

    cache.deinit(std.testing.allocator);

    try std.testing.expectEqual(@as(usize, 0), cache.rows.len);
    try std.testing.expectEqual(@as(usize, 0), cache.cursor_instances.items.len);
    try std.testing.expectEqual(@as(usize, 0), cache.packed_instances.items.len);
    try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
    try std.testing.expect(cache.layout_dirty);
}

test "RenderCache resizeRows to zero clears derived state after live row data" {
    var cache = RenderCache.empty;

    try cache.resizeRows(std.testing.allocator, 2);
    cache.rows[0].instances = try makeTestInstances(std.testing.allocator, 1);
    cache.rows[1].instances = try makeTestInstances(std.testing.allocator, 2);

    var cursor_instances = try makeTestInstances(std.testing.allocator, 1);
    defer cursor_instances.deinit(std.testing.allocator);
    try cache.cursor_instances.append(std.testing.allocator, cursor_instances.items[0]);

    var packed_instances = try makeTestInstances(std.testing.allocator, 1);
    defer packed_instances.deinit(std.testing.allocator);
    try cache.packed_instances.append(std.testing.allocator, packed_instances.items[0]);

    cache.total_instance_count = 3;
    cache.layout_dirty = false;

    try cache.resizeRows(std.testing.allocator, 0);

    try std.testing.expectEqual(@as(usize, 0), cache.rows.len);
    try std.testing.expectEqual(@as(usize, 0), cache.cursor_instances.items.len);
    try std.testing.expectEqual(@as(usize, 0), cache.packed_instances.items.len);
    try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
    try std.testing.expect(cache.layout_dirty);

    cache.deinit(std.testing.allocator);
}

test "RenderCache resizeRows truncates populated tail rows and preserves prefix only" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);

    try cache.resizeRows(std.testing.allocator, 3);
    cache.rows[0].instances = try makeTestInstances(std.testing.allocator, 1);
    cache.rows[1].instances = try makeTestInstances(std.testing.allocator, 2);
    cache.rows[2].instances = try makeTestInstances(std.testing.allocator, 3);

    cache.rows[0].gpu_offset_instances = 10;
    cache.rows[1].gpu_offset_instances = 20;
    cache.rows[2].gpu_offset_instances = 30;

    try cache.resizeRows(std.testing.allocator, 1);

    try std.testing.expectEqual(@as(usize, 1), cache.rows.len);
    try std.testing.expectEqual(@as(usize, 1), cache.rows[0].instances.items.len);
    try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 0), cache.rows[0].gpu_len_instances);
    try std.testing.expectEqual(@as(u32, 0), cache.total_instance_count);
    try std.testing.expect(cache.layout_dirty);
}

test "applyRenderPlan requests full upload when layout changes" {
    const result = applyRenderPlan(.{
        .layout_dirty = true,
        .rows_rebuilt = 1,
        .cursor_rebuilt = false,
    });

    try std.testing.expect(result.full_upload);
    try std.testing.expect(!result.partial_upload);
}

test "clearConsumedDirtyFlags clears only consumed partial rows after successful refresh" {
    var dirty_rows = [_]bool{ true, false, true, false };
    const plan = RowRefreshPlan{
        .full_rebuild = false,
        .cursor_rebuild = false,
        .rows_to_rebuild = blk: {
            var rows = std.StaticBitSet(256).initEmpty();
            rows.set(0);
            rows.set(2);
            break :blk rows;
        },
    };

    var render_dirty: vt.RenderDirty = .partial;
    clearConsumedDirtyFlags(&render_dirty, dirty_rows[0..], plan);

    try std.testing.expectEqual(@as(@TypeOf(render_dirty), .false), render_dirty);
    try std.testing.expectEqualSlices(bool, &.{ false, false, false, false }, dirty_rows[0..]);
}

test "font module no longer exposes lookupMonospace compatibility wrapper" {
    try std.testing.expect(!@hasDecl(font, "lookupMonospace"));
}

const RowInstanceCache = struct {
    instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
    gpu_offset_instances: u32 = 0,
    gpu_len_instances: u32 = 0,

    fn deinit(self: *RowInstanceCache, alloc: std.mem.Allocator) void {
        self.instances.deinit(alloc);
        self.* = .{};
    }
};

const RenderCache = struct {
    rows: []RowInstanceCache = &.{},
    cursor_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
    packed_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
    total_instance_count: u32 = 0,
    layout_dirty: bool = true,

    const empty: RenderCache = .{};

    fn resizeRows(self: *RenderCache, alloc: std.mem.Allocator, row_count: usize) !void {
        if (self.rows.len == row_count) return;
        const old_rows = self.rows;
        if (row_count == 0) {
            if (old_rows.len > 0) {
                var row_idx: usize = 0;
                while (row_idx < old_rows.len) : (row_idx += 1) {
                    old_rows[row_idx].deinit(alloc);
                }
                alloc.free(old_rows);
            }
            self.rows = &.{};
            self.invalidateAfterResize();
            return;
        }

        var new_rows = try alloc.alloc(RowInstanceCache, row_count);
        for (new_rows) |*row| row.* = .{};

        const copy_len = @min(old_rows.len, row_count);
        var row_idx: usize = 0;
        while (row_idx < copy_len) : (row_idx += 1) {
            // Preserve only the surviving prefix by moving ownership row-by-row.
            new_rows[row_idx] = old_rows[row_idx];
            old_rows[row_idx] = .{};
        }
        while (row_idx < old_rows.len) : (row_idx += 1) {
            old_rows[row_idx].deinit(alloc);
        }

        if (old_rows.len > 0) {
            alloc.free(old_rows);
        }

        self.rows = new_rows;
        self.invalidateAfterResize();
    }

    fn deinit(self: *RenderCache, alloc: std.mem.Allocator) void {
        for (self.rows) |*row| row.deinit(alloc);
        if (self.rows.len > 0) {
            alloc.free(self.rows);
        }
        self.cursor_instances.deinit(alloc);
        self.packed_instances.deinit(alloc);
        self.* = .{};
    }

    fn invalidateAfterResize(self: *RenderCache) void {
        for (self.rows) |*row| {
            row.gpu_offset_instances = 0;
            row.gpu_len_instances = 0;
        }
        self.cursor_instances.clearRetainingCapacity();
        self.packed_instances.clearRetainingCapacity();
        self.total_instance_count = 0;
        self.layout_dirty = true;
    }

    fn rebuildCursorInstances(
        self: *RenderCache,
        alloc: std.mem.Allocator,
        cursor_instances: []const renderer.Instance,
    ) !CursorRebuildResult {
        const old_len = self.cursor_instances.items.len;
        self.cursor_instances.clearRetainingCapacity();
        try self.cursor_instances.appendSlice(alloc, cursor_instances);

        self.packed_instances.clearRetainingCapacity();
        self.total_instance_count = 0;
        self.layout_dirty = true;

        return .{
            .len_changed = markLayoutDirtyOnLenChange(old_len, self.cursor_instances.items.len),
            .packed_invalidated = true,
        };
    }
};

const RowPackResult = struct {
    total_instances: u32,
    cursor_offset_instances: u32,
    cursor_len_instances: u32,
};

const RowRebuildResult = struct {
    len_changed: bool,
};

const CursorRebuildResult = struct {
    len_changed: bool,
    packed_invalidated: bool,
};

const RenderUploadPlanInput = struct {
    layout_dirty: bool,
    rows_rebuilt: usize,
    cursor_rebuilt: bool,
};

const RenderUploadPlanResult = struct {
    full_upload: bool,
    partial_upload: bool,
};

fn applyRenderPlan(input: RenderUploadPlanInput) RenderUploadPlanResult {
    if (input.layout_dirty) {
        return .{
            .full_upload = true,
            .partial_upload = false,
        };
    }

    return .{
        .full_upload = false,
        .partial_upload = input.rows_rebuilt > 0 or input.cursor_rebuilt,
    };
}

fn repackRowCaches(
    alloc: std.mem.Allocator,
    packed_instances: *std.ArrayListUnmanaged(renderer.Instance),
    rows: []RowInstanceCache,
    cursor_instances: []const renderer.Instance,
) !RowPackResult {
    packed_instances.clearRetainingCapacity();

    var total_instances: u32 = 0;
    for (rows) |*row| {
        try packed_instances.appendSlice(alloc, row.instances.items);
        total_instances += @intCast(row.instances.items.len);
    }

    const cursor_offset_instances = total_instances;
    try packed_instances.appendSlice(alloc, cursor_instances);
    total_instances += @intCast(cursor_instances.len);

    var offset: u32 = 0;
    for (rows) |*row| {
        row.gpu_offset_instances = offset;
        row.gpu_len_instances = @intCast(row.instances.items.len);
        offset += @intCast(row.instances.items.len);
    }

    return .{
        .total_instances = total_instances,
        .cursor_offset_instances = cursor_offset_instances,
        .cursor_len_instances = @intCast(cursor_instances.len),
    };
}

fn rebuildRowInstances(
    alloc: std.mem.Allocator,
    cache: *RowInstanceCache,
    row_idx: u32,
    row_cells: anytype,
    term: *const vt.Terminal,
    face: *font.Face,
    atlas: *font.Atlas,
    cell_w: u32,
    cell_h: u32,
    baseline: u32,
    default_bg: [4]f32,
    bg_uv: font.GlyphUV,
    selection: ?SelectionSpan,
) !RowRebuildResult {
    const old_len = cache.instances.items.len;
    cache.instances.clearRetainingCapacity();
    cache.gpu_offset_instances = 0;
    cache.gpu_len_instances = 0;

    const raw_cells = row_cells.items(.raw);
    var col_idx: u32 = 0;
    while (col_idx < raw_cells.len) : (col_idx += 1) {
        const cp = raw_cells[col_idx].codepoint();
        const base_colors = term.cellColors(row_cells.get(col_idx));
        const is_selected = if (selection) |span| span.containsCell(col_idx, row_idx) else false;
        const colors = selectionColors(base_colors, is_selected);
        const glyph_uv = if (cp == 0 or cp == ' ')
            null
        else
            atlas.getOrInsert(face, @intCast(cp)) catch null;

        try appendCellInstances(
            alloc,
            &cache.instances,
            row_idx,
            col_idx,
            cell_w,
            cell_h,
            baseline,
            glyph_uv,
            bg_uv,
            colors,
            default_bg,
        );
    }

    return .{
        .len_changed = markLayoutDirtyOnLenChange(old_len, cache.instances.items.len),
    };
}

fn markLayoutDirtyOnLenChange(old_len: usize, new_len: usize) bool {
    return old_len != new_len;
}

fn clearConsumedDirtyFlags(
    render_dirty: *vt.RenderDirty,
    dirty_rows: []bool,
    plan: RowRefreshPlan,
) void {
    if (plan.full_rebuild) {
        @memset(dirty_rows, false);
        render_dirty.* = .false;
        return;
    }

    var row_idx: usize = 0;
    while (row_idx < dirty_rows.len) : (row_idx += 1) {
        if (plan.rows_to_rebuild.isSet(row_idx)) dirty_rows[row_idx] = false;
    }

    for (dirty_rows) |dirty| {
        if (dirty) {
            render_dirty.* = .partial;
            return;
        }
    }

    render_dirty.* = .false;
}

fn cursorOffsetInstances(rows: []const RowInstanceCache) u32 {
    var offset: u32 = 0;
    for (rows) |row| offset += row.gpu_len_instances;
    return offset;
}

fn makeTestInstances(
    alloc: std.mem.Allocator,
    count: usize,
) !std.ArrayListUnmanaged(renderer.Instance) {
    var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    try instances.ensureTotalCapacity(alloc, count);

    var i: usize = 0;
    while (i < count) : (i += 1) {
        try instances.append(alloc, .{
            .cell_pos = .{ @floatFromInt(i), 0.0 },
            .glyph_size = .{ 1.0, 1.0 },
            .glyph_bearing = .{ 0.0, 0.0 },
            .uv_rect = .{ 0.0, 0.0, 1.0, 1.0 },
            .fg = .{ 1.0, 1.0, 1.0, 1.0 },
            .bg = .{ 0.0, 0.0, 0.0, 1.0 },
        });
    }

    return instances;
}

fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();

    const window = try conn.createWindow(alloc, "waystty-text-compare");
    defer window.deinit();

    _ = conn.display.roundtrip();

    var font_lookup = try font.lookupConfiguredFont(alloc);
    defer font_lookup.deinit(alloc);

    var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
    defer face.deinit();

    var atlas = try font.Atlas.init(alloc, 2048, 2048);
    defer atlas.deinit();

    var geom: ScaledGeometry = .{
        .buffer_scale = 1,
        .px_size = config.font_size_px,
        .cell_w_px = face.cellWidth(),
        .cell_h_px = face.cellHeight(),
        .baseline_px = face.baseline(),
    };

    var scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
    defer scene.deinit(alloc);

    // Initial surface-coordinate window size. Prefer whatever the compositor
    // configured us at (already stored on window by xdgToplevelListener). If
    // the initial configure was (0, 0) — meaning "client chooses" — use a
    // size that fits the scene exactly at scale=1.
    if (window.width == 800 and window.height == 600) {
        window.width = scene.window_cols * geom.cell_w_px;
        window.height = scene.window_rows * geom.cell_h_px;
    }

    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width * @as(u32, @intCast(geom.buffer_scale)),
        window.height * @as(u32, @intCast(geom.buffer_scale)),
    );
    defer ctx.deinit();

    try ctx.uploadAtlas(atlas.pixels);
    atlas.dirty = false;
    try ctx.uploadInstances(scene.instances.items);

    const wl_fd = conn.display.getFd();
    var pollfds = [_]std.posix.pollfd{
        .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 },
    };
    var last_window_w = window.width;
    var last_window_h = window.height;
    var last_scale: i32 = geom.buffer_scale;

    while (!window.should_close) {
        _ = conn.display.flush();
        if (conn.display.prepareRead()) {
            pollfds[0].revents = 0;
            _ = std.posix.poll(&pollfds, 16) catch {};
            if (pollfds[0].revents & std.posix.POLL.IN != 0) {
                _ = conn.display.readEvents();
            } else {
                conn.display.cancelRead();
            }
        }
        _ = conn.display.dispatchPending();

        const current_scale = window.bufferScale();
        const scale_changed = current_scale != last_scale;
        const size_changed = window.width != last_window_w or window.height != last_window_h;

        if (scale_changed or size_changed) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);

            if (scale_changed) {
                geom = try rebuildFaceForScale(
                    &face,
                    &atlas,
                    font_lookup.path,
                    font_lookup.index,
                    config.font_size_px,
                    current_scale,
                );
                // Rebuild the scene against the fresh atlas.
                scene.deinit(alloc);
                scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);

                // Do NOT touch window.width/window.height here — those reflect the
                // compositor's configured surface size (from xdg_toplevel.configure).
                // Overwriting them forced sway to non-integer-scale our buffer to fit
                // its tile, which was the actual cause of the residual fuzz.

                window.surface.setBufferScale(geom.buffer_scale);
                try ctx.uploadAtlas(atlas.pixels);
                atlas.dirty = false;
                try ctx.uploadInstances(scene.instances.items);
                last_scale = current_scale;
            }

            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);

            last_window_w = window.width;
            last_window_h = window.height;
        }

        drawTextCoverageCompareFrame(
            &ctx,
            &scene,
            geom.cell_w_px,
            geom.cell_h_px,
            .{ 0.0, 0.0, 0.0, 1.0 },
        ) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
                const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
                try ctx.recreateSwapchain(buf_w, buf_h);
                last_window_w = window.width;
                last_window_h = window.height;
                continue;
            },
            else => return err,
        };

        _ = conn.display.flush();
        std.Thread.sleep(16 * std.time.ns_per_ms);
    }

    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
}

fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();
    std.debug.print("wayland connected\n", .{});

    const window = try conn.createWindow(alloc, "waystty-draw-smoke");
    defer window.deinit();
    std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });

    _ = conn.display.roundtrip();

    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width,
        window.height,
    );
    defer ctx.deinit();
    std.debug.print("vulkan context created\n", .{});

    // Load configured font and create atlas
    var font_lookup = try font.lookupConfiguredFont(alloc);
    defer font_lookup.deinit(alloc);

    const px_size: u32 = config.font_size_px;
    var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, px_size);
    defer face.deinit();

    var atlas = try font.Atlas.init(alloc, 1024, 1024);
    defer atlas.deinit();

    // Rasterize 'M' into the atlas
    const glyph_uv = try atlas.getOrInsert(&face, 'M');
    std.debug.print("glyph 'M': uv=({d:.3},{d:.3})->({d:.3},{d:.3}) size={d}x{d}\n", .{
        glyph_uv.u0, glyph_uv.v0, glyph_uv.u1, glyph_uv.v1,
        glyph_uv.width, glyph_uv.height,
    });

    // Upload atlas pixels to GPU
    try ctx.uploadAtlas(atlas.pixels);
    std.debug.print("atlas uploaded\n", .{});

    // Cell size from font metrics
    const cell_w: f32 = @floatFromInt(face.cellWidth());
    const cell_h: f32 = @floatFromInt(face.cellHeight());
    const baseline = face.baseline();
    std.debug.print("cell size: {d}x{d}\n", .{ cell_w, cell_h });

    // One Instance: 'M' at cell position (40, 12) — near center of 80x24 grid
    const instances = [_]renderer.Instance{
        .{
            .cell_pos = .{ 40.0, 12.0 },
            .glyph_size = .{ @floatFromInt(glyph_uv.width), @floatFromInt(glyph_uv.height) },
            .glyph_bearing = .{
                @floatFromInt(glyph_uv.bearing_x),
                glyphTopOffset(baseline, glyph_uv.bearing_y),
            },
            .uv_rect = .{ glyph_uv.u0, glyph_uv.v0, glyph_uv.u1, glyph_uv.v1 },
            .fg = .{ 1.0, 1.0, 1.0, 1.0 },
            .bg = .{ 0.0, 0.0, 0.0, 1.0 },
        },
    };

    try ctx.uploadInstances(&instances);
    std.debug.print("instances uploaded, rendering for ~15 seconds at 60fps...\n", .{});

    var i: u32 = 0;
    while (i < 900) : (i += 1) {
        // Non-blocking Wayland event read: prepare + read + dispatch.
        // The Vulkan WSI (FIFO) needs wl_buffer.release events from the compositor
        // to be read off the socket before it can release an acquire slot.
        _ = conn.display.flush();
        if (conn.display.prepareRead()) {
            _ = conn.display.readEvents();
        }
        _ = conn.display.dispatchPending();
        const baseline_coverage = renderer.coverageVariantParams(.baseline);
        ctx.drawCells(1, .{ cell_w, cell_h }, .{ 0.0, 0.0, 0.0, 1.0 }, baseline_coverage) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                try ctx.recreateSwapchain(window.width, window.height);
                continue;
            },
            else => return err,
        };
        _ = conn.display.flush();
        std.Thread.sleep(16 * std.time.ns_per_ms);
    }

    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
    std.debug.print("done\n", .{});
}

test "encodeKeyboardEvent encodes left arrow" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 80,
        .rows = 24,
    });
    defer term.deinit();

    var buf: [32]u8 = undefined;
    const encoded = (try encodeKeyboardEvent(term, .{
        .keysym = c.XKB_KEY_Left,
        .modifiers = .{},
        .action = .press,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }, &buf)).?;

    try std.testing.expectEqualStrings("\x1b[D", encoded);
}

test "gridSizeForWindow clamps to at least one cell" {
    const grid = gridSizeForWindow(10, 5, 16, 20);
    try std.testing.expectEqual(@as(u16, 1), grid.cols);
    try std.testing.expectEqual(@as(u16, 1), grid.rows);
}

test "isClipboardPasteEvent matches Ctrl-Shift-V press" {
    try std.testing.expect(isClipboardPasteEvent(.{
        .keysym = c.XKB_KEY_V,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .press,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
    try std.testing.expect(!isClipboardPasteEvent(.{
        .keysym = c.XKB_KEY_V,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .repeat,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
    try std.testing.expect(!isClipboardPasteEvent(.{
        .keysym = c.XKB_KEY_v,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .press,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
}

test "isClipboardCopyEvent matches Ctrl-Shift-C press" {
    try std.testing.expect(isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_C,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .press,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
    try std.testing.expect(!isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_C,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .repeat,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
    try std.testing.expect(!isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_c,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .press,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
}

test "copySelectionText returns false for empty visible selection" {
    var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 4, .rows = 1 });
    defer term.deinit();

    try std.testing.expect(!try copySelectionText(
        std.testing.allocator,
        null,
        term,
        null,
        0,
    ));
}

test "extractSelectedText trims trailing blanks on each visible row" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 8,
        .rows = 2,
    });
    defer term.deinit();

    term.write("ab\r\nc");
    try term.snapshot();

    const span = SelectionSpan{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 7, .row = 1 },
    };
    const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("ab\nc", text);
}

test "extractSelectedText preserves interior spaces while trimming trailing blanks" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 10,
        .rows = 1,
    });
    defer term.deinit();

    term.write("a b  c  ");
    try term.snapshot();

    const span = SelectionSpan{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 9, .row = 0 },
    };
    const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("a b  c  ", text);
}

test "extractSelectedText emits a wide glyph once when its spacer cell is selected too" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 4,
        .rows = 1,
    });
    defer term.deinit();

    term.write("表");
    try term.snapshot();

    const row_cells = term.render_state.row_data.items(.cells)[0];
    try std.testing.expectEqual(.wide, row_cells.get(0).raw.wide);
    try std.testing.expectEqual(.spacer_tail, row_cells.get(1).raw.wide);

    const span = SelectionSpan{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 1, .row = 0 },
    };
    const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("表", text);
}

test "extractSelectedText respects partial first and last rows" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 8,
        .rows = 2,
    });
    defer term.deinit();

    term.write("abc\r\ndef");
    try term.snapshot();

    const span = SelectionSpan{
        .start = .{ .col = 1, .row = 0 },
        .end = .{ .col = 1, .row = 1 },
    };
    const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("bc\nde", text);
}

test "extractSelectedText preserves grapheme clusters from render state" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 4,
        .rows = 1,
    });
    defer term.deinit();

    term.write("e\xcc\x81");
    try term.snapshot();

    const row_cells = term.render_state.row_data.items(.cells)[0];
    const cell = row_cells.get(0);
    try std.testing.expect(cell.raw.hasGrapheme());
    try std.testing.expectEqualSlices(u21, &.{0x0301}, cell.grapheme);

    const span = SelectionSpan{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 0, .row = 0 },
    };
    const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("e\xcc\x81", text);
}

test "extractSelectedText clamps an offscreen end row to visible rows" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 8,
        .rows = 2,
    });
    defer term.deinit();

    term.write("row0\r\nrow1");
    try term.snapshot();

    const span = SelectionSpan{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 7, .row = 9 },
    };
    const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("row0\nrow1", text);
}

test "drainSelectionPipeThenRoundtrip drains large paste before roundtrip" {
    const payload_len: usize = 8192;
    const payload = try std.testing.allocator.alloc(u8, payload_len);
    defer std.testing.allocator.free(payload);
    @memset(payload, 'p');

    const pipefds = try std.posix.pipe();
    defer std.posix.close(pipefds[0]);
    var write_fd_closed = false;
    defer if (!write_fd_closed) std.posix.close(pipefds[1]);

    var written: usize = 0;
    while (written < payload.len) {
        written += try std.posix.write(pipefds[1], payload[written..]);
    }
    std.posix.close(pipefds[1]);
    write_fd_closed = true;

    var roundtrip_ctx = struct {
        fd: std.posix.fd_t,
        called: bool = false,

        pub fn roundtrip(self: *@This()) !void {
            var probe: [1]u8 = undefined;
            const n = try std.posix.read(self.fd, &probe);
            try std.testing.expectEqual(@as(usize, 0), n);
            self.called = true;
        }
    }{ .fd = pipefds[0] };

    const text = try wayland_client.drainSelectionPipeThenRoundtrip(
        std.testing.allocator,
        pipefds[0],
        &roundtrip_ctx,
    );
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualSlices(u8, payload, text);
    try std.testing.expect(roundtrip_ctx.called);
}

test "appendCellInstances emits a background quad for colored space" {
    var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    defer instances.deinit(std.testing.allocator);

    const bg_uv: font.GlyphUV = .{
        .u0 = 0.0,
        .v0 = 0.0,
        .u1 = 0.1,
        .v1 = 0.1,
        .width = 1,
        .height = 1,
        .bearing_x = 0,
        .bearing_y = 0,
        .advance_x = 1,
    };

    try appendCellInstances(
        std.testing.allocator,
        &instances,
        2,
        3,
        8,
        16,
        12,
        null,
        bg_uv,
        .{
            .fg = .{ 1.0, 1.0, 1.0, 1.0 },
            .bg = .{ 0.2, 0.3, 0.4, 1.0 },
        },
        .{ 0.0, 0.0, 0.0, 1.0 },
    );

    try std.testing.expectEqual(@as(usize, 1), instances.items.len);
    try std.testing.expectEqualDeep([4]f32{ 0.2, 0.3, 0.4, 1.0 }, instances.items[0].fg);
    try std.testing.expectEqualDeep([4]f32{ 0.2, 0.3, 0.4, 1.0 }, instances.items[0].bg);
    try std.testing.expectEqualDeep([2]f32{ 8.0, 16.0 }, instances.items[0].glyph_size);
}

test "appendCellInstances emits background before glyph" {
    var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    defer instances.deinit(std.testing.allocator);

    const bg_uv: font.GlyphUV = .{
        .u0 = 0.0,
        .v0 = 0.0,
        .u1 = 0.1,
        .v1 = 0.1,
        .width = 1,
        .height = 1,
        .bearing_x = 0,
        .bearing_y = 0,
        .advance_x = 1,
    };
    const glyph_uv: font.GlyphUV = .{
        .u0 = 0.2,
        .v0 = 0.3,
        .u1 = 0.4,
        .v1 = 0.5,
        .width = 7,
        .height = 11,
        .bearing_x = 1,
        .bearing_y = 13,
        .advance_x = 8,
    };

    try appendCellInstances(
        std.testing.allocator,
        &instances,
        0,
        1,
        8,
        16,
        12,
        glyph_uv,
        bg_uv,
        .{
            .fg = .{ 0.9, 0.8, 0.7, 1.0 },
            .bg = .{ 0.1, 0.2, 0.3, 1.0 },
        },
        .{ 0.0, 0.0, 0.0, 1.0 },
    );

    try std.testing.expectEqual(@as(usize, 2), instances.items.len);
    try std.testing.expectEqualDeep([2]f32{ 8.0, 16.0 }, instances.items[0].glyph_size);
    try std.testing.expectEqualDeep([2]f32{ 7.0, 11.0 }, instances.items[1].glyph_size);
    try std.testing.expectEqualDeep([4]f32{ 0.1, 0.2, 0.3, 1.0 }, instances.items[0].fg);
    try std.testing.expectEqualDeep([4]f32{ 0.9, 0.8, 0.7, 1.0 }, instances.items[1].fg);
    try std.testing.expectEqualDeep([2]f32{ 1.0, -1.0 }, instances.items[1].glyph_bearing);
}

test "glyphTopOffset uses baseline rather than cell height" {
    try std.testing.expectEqual(@as(f32, -3.0), glyphTopOffset(9, 12));
}

test "comparisonPanelOrigins splits four panels left to right" {
    const origins = comparisonPanelOrigins(80, 24);
    try std.testing.expectEqual(@as(f32, 0), origins[0][0]);
    try std.testing.expect(origins[1][0] > origins[0][0]);
    try std.testing.expect(origins[2][0] > origins[1][0]);
    try std.testing.expect(origins[3][0] > origins[2][0]);
}

test "comparisonSpecimenLines remains fixed and non-empty" {
    const lines = comparisonSpecimenLines();
    try std.testing.expectEqual(@as(usize, 5), lines.len);
    try std.testing.expectEqualStrings("abcdefghijklmnopqrstuvwxyz", lines[0]);
    try std.testing.expectEqualStrings("ABCDEFGHIJKLMNOPQRSTUVWXYZ", lines[1]);
    try std.testing.expectEqualStrings("0123456789", lines[2]);
    try std.testing.expectEqualStrings("{}[]()/\\|,.;:_-=+", lines[3]);
    try std.testing.expectEqualStrings("~/code/rad/waystty $ zig build test", lines[4]);
    for (lines) |line| {
        try std.testing.expect(line.len > 0);
    }
}

test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
    var lookup = try font.lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try font.Face.init(std.testing.allocator, lookup.path, lookup.index, config.font_size_px);
    defer face.deinit();

    var atlas = try font.Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    var scene = try buildTextCoverageCompareScene(std.testing.allocator, &face, &atlas);
    defer scene.deinit(std.testing.allocator);

    const variants = comparisonVariants();
    const first_draw = scene.panel_draws[0];
    try std.testing.expect(first_draw.instance_count > 0);

    var idx: usize = 0;
    while (idx < scene.panel_draws.len) : (idx += 1) {
        try std.testing.expect(scene.panel_draws[idx].instance_count >= first_draw.instance_count - 8);
        try std.testing.expectEqualDeep(variants[idx].coverage, scene.panel_draws[idx].coverage);
    }

    const panel0_first = scene.instances.items[first_draw.instance_offset_instances];
    const panel1_first = scene.instances.items[scene.panel_draws[1].instance_offset_instances];
    try std.testing.expectEqual(panel0_first.cell_pos[1], panel1_first.cell_pos[1]);
    try std.testing.expectEqual(
        @as(f32, @floatFromInt(scene.panel_cols)),
        panel1_first.cell_pos[0] - panel0_first.cell_pos[0],
    );
}

test "FrameTiming.total sums all sections" {
    const ft: FrameTiming = .{
        .snapshot_us = 10,
        .row_rebuild_us = 20,
        .atlas_upload_us = 30,
        .instance_upload_us = 40,
        .gpu_submit_us = 50,
    };
    try std.testing.expectEqual(@as(u32, 150), ft.total());
}

test "FrameTimingRing records and wraps correctly" {
    var ring = FrameTimingRing{};
    try std.testing.expectEqual(@as(usize, 0), ring.count);

    ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 });
    try std.testing.expectEqual(@as(usize, 1), ring.count);
    try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us);

    // Fill to capacity
    for (1..FrameTimingRing.capacity) |i| {
        ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
    }
    try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);

    // One more wraps around — overwrites entries[0], head advances to 1
    ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
    try std.testing.expectEqual(FrameTimingRing.capacity, ring.count);
    // Newest entry is at (head + capacity - 1) % capacity = 0
    try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us);
    // head has advanced past the overwritten slot
    try std.testing.expectEqual(@as(usize, 1), ring.head);
}

test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" {
    var ring = FrameTimingRing{};
    // Push capacity + 3 entries so the ring wraps
    for (0..FrameTimingRing.capacity + 3) |i| {
        ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 });
    }
    var buf: [FrameTimingRing.capacity]FrameTiming = undefined;
    const ordered = ring.orderedSlice(&buf);
    try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len);
    // First entry should be the 4th pushed (index 3), last should be capacity+2
    try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us);
    try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us);
}

test "FrameTimingStats computes min/avg/p99/max correctly" {
    var ring = FrameTimingRing{};
    // Push 100 frames with snapshot_us = 1..100
    for (0..100) |i| {
        ring.push(.{
            .snapshot_us = @intCast(i + 1),
            .row_rebuild_us = 0,
            .atlas_upload_us = 0,
            .instance_upload_us = 0,
            .gpu_submit_us = 0,
        });
    }
    const stats = computeFrameStats(&ring);
    try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min);
    try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max);
    try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg);
    // p99 of 1..100 = value at index 98 (0-based) = 99
    try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99);
    try std.testing.expectEqual(@as(usize, 100), stats.frame_count);
}

test "FrameTimingStats handles empty ring" {
    var ring = FrameTimingRing{};
    const stats = computeFrameStats(&ring);
    try std.testing.expectEqual(@as(usize, 0), stats.frame_count);
    try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min);
}

fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();
    std.debug.print("wayland connected\n", .{});

    const window = try conn.createWindow(alloc, "waystty-render-smoke");
    defer window.deinit();
    std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });

    _ = conn.display.roundtrip();

    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width,
        window.height,
    );
    defer ctx.deinit();

    std.debug.print("rendering 60 frames...\n", .{});
    var i: u32 = 0;
    while (i < 60) : (i += 1) {
        _ = conn.display.dispatchPending();
        const t: f32 = @as(f32, @floatFromInt(i)) / 60.0;
        try ctx.drawClear(.{ t, 0.5, 1.0 - t, 1.0 });
    }
    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
    std.debug.print("done\n", .{});
}

fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();
    std.debug.print("wayland connected\n", .{});

    const window = try conn.createWindow(alloc, "waystty-vulkan-smoke");
    defer window.deinit();
    std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });

    // Roundtrip to ensure configure events have arrived before Vulkan touches the surface
    _ = conn.display.roundtrip();

    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width,
        window.height,
    );
    defer ctx.deinit();

    std.debug.print("vulkan ok\n", .{});
    std.debug.print("  format: {any}\n", .{ctx.swapchain_format});
    std.debug.print("  extent: {d}x{d}\n", .{ ctx.swapchain_extent.width, ctx.swapchain_extent.height });
    std.debug.print("  image count: {d}\n", .{ctx.swapchain_images.len});
}

fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void {
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();
    std.debug.print("connected\n", .{});

    const window = try conn.createWindow(alloc, "waystty");
    defer window.deinit();

    std.debug.print("window created (w={d} h={d})\n", .{ window.width, window.height });
}

fn runHeadless(alloc: std.mem.Allocator) !void {
    const shell: [:0]const u8 = "/bin/sh";

    var p = try pty.Pty.spawn(.{
        .cols = 80,
        .rows = 24,
        .shell = shell,
    });
    defer p.deinit();

    var term = try vt.Terminal.init(alloc, .{
        .cols = 80,
        .rows = 24,
    });
    defer term.deinit();

    // Give shell a moment to start up
    std.Thread.sleep(100 * std.time.ns_per_ms);

    // Write a command that prints something then exits
    _ = try p.write("echo hello; exit\n");

    // Drain output for up to 2 seconds, or until child exits
    var buf: [4096]u8 = undefined;
    const deadline = std.time.nanoTimestamp() + 2 * std.time.ns_per_s;
    while (std.time.nanoTimestamp() < deadline) {
        const n = p.read(&buf) catch |err| switch (err) {
            error.WouldBlock => {
                if (!p.isChildAlive()) break;
                std.Thread.sleep(10 * std.time.ns_per_ms);
                continue;
            },
            // EIO typically means the slave side of the PTY was closed (child exited)
            error.InputOutput => break,
            else => return err,
        };
        if (n == 0) break;
        term.write(buf[0..n]);
    }

    // Drain any remaining data after child exits
    drain: while (true) {
        const n = p.read(&buf) catch break :drain;
        if (n == 0) break;
        term.write(buf[0..n]);
    }

    try term.snapshot();

    // Dump the grid to stdout
    var out_buf: [65536]u8 = undefined;
    var fw = std.fs.File.stdout().writer(&out_buf);
    const w = &fw.interface;

    const rows = term.render_state.row_data.items(.cells);
    for (rows) |*row_cells| {
        // Each row is a MultiArrayList(RenderState.Cell)
        // RenderState.Cell has a .raw field of type page.Cell
        // page.Cell has a .codepoint() method returning u21
        const raw_cells = row_cells.items(.raw);
        for (raw_cells) |cell| {
            const cp = cell.codepoint();
            if (cp == 0) {
                // Empty cell — print a space
                try w.writeByte(' ');
            } else {
                var utf8_buf: [4]u8 = undefined;
                const utf8_len = std.unicode.utf8Encode(cp, &utf8_buf) catch {
                    try w.writeByte('?');
                    continue;
                };
                try w.writeAll(utf8_buf[0..utf8_len]);
            }
        }
        try w.writeByte('\n');
    }

    try w.flush();
}