a73x

docs/superpowers/plans/2026-04-08-dirty-row-rendering-implementation.md

Ref:   Size: 18.4 KiB

# Dirty-Row Rendering Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make terminal redraw cost proportional to changed rows by caching per-row instances and supporting partial instance-buffer uploads.

**Architecture:** Keep the existing single contiguous instance buffer and single draw call. Add CPU-side row and cursor caches in `src/main.zig`, use `ghostty-vt` dirty flags to decide what to rebuild, and extend `src/renderer.zig` with partial-range instance uploads plus full-upload fallback when buffer growth invalidates prior GPU contents.

**Tech Stack:** Zig 0.15, ghostty-vt render state dirty flags, Vulkan host-visible buffers, existing `renderer.Instance` pipeline.

---

## File Structure

- Modify: `src/main.zig`
  - Add row-cache data structures, rebuild planning helpers, cursor-cache tracking, and the new redraw/update flow.
- Modify: `src/renderer.zig`
  - Add partial instance-range upload support and tests for the buffer-growth fallback behavior.
- Test: `src/main.zig`
  - Add tests for rebuild planning, packing offsets, cursor-only invalidation, and dirty-flag lifecycle.
- Test: `src/renderer.zig`
  - Add tests for range calculations and fallback decisions without requiring a live Vulkan device.

### Task 1: Add failing tests for dirty-row planning helpers

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "planRowRefresh requests full rebuild for full dirty state" {
    const plan = planRowRefresh(.full, &.{ false, true, false }, .{
        .cursor_changed = false,
        .old_cursor_row = null,
        .new_cursor_row = null,
    });

    try std.testing.expect(plan.full_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_changed = false,
        .old_cursor_row = null,
        .new_cursor_row = null,
    });

    try std.testing.expect(!plan.full_rebuild);
    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));
}

test "planRowRefresh handles cursor-only updates without unrelated rows" {
    const plan = planRowRefresh(.partial, &.{ false, false, false }, .{
        .cursor_changed = true,
        .old_cursor_row = 1,
        .new_cursor_row = 2,
    });

    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());
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL with missing identifiers such as `planRowRefresh`.

- [ ] **Step 3: Write minimal helper types and planning implementation**

```zig
const RowRefreshContext = struct {
    cursor_changed: bool,
    old_cursor_row: ?usize,
    new_cursor_row: ?usize,
};

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

fn planRowRefresh(
    dirty: vt.RenderDirty,
    row_dirty: []const bool,
    ctx: RowRefreshContext,
) RowRefreshPlan {
    var rows = std.StaticBitSet(256).initEmpty();
    if (dirty == .full) {
        return .{
            .full_rebuild = true,
            .cursor_rebuild = true,
            .rows_to_rebuild = rows,
        };
    }

    for (row_dirty, 0..) |is_dirty, i| {
        if (is_dirty) rows.set(i);
    }

    return .{
        .full_rebuild = false,
        .cursor_rebuild = ctx.cursor_changed,
        .rows_to_rebuild = rows,
    };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new planning tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add dirty-row refresh planning helpers"
```

### Task 2: Add failing tests for row packing and layout invalidation

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
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: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    defer packed.deinit(std.testing.allocator);

    const total = try repackRowCaches(std.testing.allocator, &packed, &rows, &.{});

    try std.testing.expectEqual(@as(u32, 0), rows[0].gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 2), rows[1].gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 5), total);
}

test "updateLayoutDirty becomes true when row instance count changes" {
    try std.testing.expect(markLayoutDirtyOnLenChange(2, 3));
    try std.testing.expect(!markLayoutDirtyOnLenChange(3, 3));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL with missing identifiers such as `repackRowCaches`.

- [ ] **Step 3: Write minimal packing helpers**

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

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

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

    _ = cursor_instances;
    return offset;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new packing tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add dirty-row packing helpers"
```

### Task 3: Add failing tests for renderer partial-upload fallback decisions

**Files:**
- Modify: `src/renderer.zig`
- Test: `src/renderer.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "range upload falls back to full upload when capacity must grow" {
    const decision = planInstanceUpload(.{
        .current_capacity = 8,
        .offset_instances = 6,
        .write_len = 4,
    });

    try std.testing.expect(decision.needs_growth);
    try std.testing.expect(decision.force_full_upload);
}

test "range upload stays partial when capacity is sufficient" {
    const decision = planInstanceUpload(.{
        .current_capacity = 16,
        .offset_instances = 4,
        .write_len = 3,
    });

    try std.testing.expect(!decision.needs_growth);
    try std.testing.expect(!decision.force_full_upload);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/renderer.zig`
Expected: FAIL with `planInstanceUpload` undefined.

- [ ] **Step 3: Write minimal planning implementation in renderer**

```zig
const InstanceUploadRequest = struct {
    current_capacity: u32,
    offset_instances: u32,
    write_len: u32,
};

const InstanceUploadDecision = struct {
    needed_capacity: u32,
    needs_growth: bool,
    force_full_upload: bool,
};

fn planInstanceUpload(req: InstanceUploadRequest) InstanceUploadDecision {
    const needed = req.offset_instances + req.write_len;
    const needs_growth = needed > req.current_capacity;
    return .{
        .needed_capacity = needed,
        .needs_growth = needs_growth,
        .force_full_upload = needs_growth,
    };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/renderer.zig`
Expected: PASS for the new renderer planning tests.

- [ ] **Step 5: Commit**

```bash
git add src/renderer.zig
git commit -m "Add instance upload planning helpers"
```

### Task 4: Implement row and cursor cache data structures in main

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "RenderCache resizeRows preserves existing row allocations" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);

    try cache.resizeRows(std.testing.allocator, 3);
    try std.testing.expectEqual(@as(usize, 3), cache.rows.len);

    try cache.resizeRows(std.testing.allocator, 2);
    try std.testing.expectEqual(@as(usize, 2), cache.rows.len);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL with `RenderCache` undefined.

- [ ] **Step 3: Implement cache structs and lifecycle**

```zig
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);
    }
};

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 { ... }
    fn deinit(self: *RenderCache, alloc: std.mem.Allocator) void { ... }
};
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for `RenderCache` lifecycle tests and prior helper tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add render cache data structures"
```

### Task 5: Extract row rebuild logic from the current full-frame path

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing test**

```zig
test "rebuildRowInstances emits expected instances for a colored glyph row" {
    // Reuse existing appendCellInstances expectations, but route through
    // rebuildRowInstances into a RowInstanceCache.
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL because `rebuildRowInstances` does not exist.

- [ ] **Step 3: Implement minimal row rebuild helper**

```zig
fn rebuildRowInstances(
    alloc: std.mem.Allocator,
    cache: *RowInstanceCache,
    row_idx: u32,
    row_cells: anytype,
    face: *font.Face,
    atlas: *font.Atlas,
    baseline: u32,
    default_bg: [4]f32,
) !bool {
    const old_len = cache.instances.items.len;
    cache.instances.clearRetainingCapacity();
    // Move the current per-row cell loop from runTerminal here.
    return markLayoutDirtyOnLenChange(old_len, cache.instances.items.len);
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new row rebuild test and existing append-order tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Extract row instance rebuild logic"
```

### Task 6: Add cursor cache handling and tests

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "rebuildCursorInstances produces one cursor quad when visible" {
    // Build a minimal cursor state and assert one instance is emitted.
}

test "cursor cache marks layout dirty when visibility changes instance count" {
    try std.testing.expect(markLayoutDirtyOnLenChange(1, 0));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL because `rebuildCursorInstances` is missing.

- [ ] **Step 3: Implement minimal cursor rebuild helper**

```zig
fn rebuildCursorInstances(
    alloc: std.mem.Allocator,
    cursor_instances: *std.ArrayListUnmanaged(renderer.Instance),
    cursor: vt.Terminal.RenderCursor,
    cell_w: u32,
    cell_h: u32,
    cursor_uv: font.GlyphUV,
) !bool { ... }
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new cursor-cache tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add cursor cache rebuild logic"
```

### Task 7: Implement renderer partial-range uploads

**Files:**
- Modify: `src/renderer.zig`
- Test: `src/renderer.zig`

- [ ] **Step 1: Write the failing test**

```zig
test "uploadInstanceRangeWrite computes byte offset from instance offset" {
    const write = planInstanceRangeWrite(3, 2);
    try std.testing.expectEqual(@as(vk.DeviceSize, 3 * @sizeOf(Instance)), write.byte_offset);
    try std.testing.expectEqual(@as(vk.DeviceSize, 2 * @sizeOf(Instance)), write.byte_len);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/renderer.zig`
Expected: FAIL because `planInstanceRangeWrite` is undefined.

- [ ] **Step 3: Implement partial-range upload support**

```zig
const InstanceRangeWrite = struct {
    byte_offset: vk.DeviceSize,
    byte_len: vk.DeviceSize,
};

fn planInstanceRangeWrite(offset_instances: u32, len_instances: u32) InstanceRangeWrite {
    return .{
        .byte_offset = @as(vk.DeviceSize, offset_instances) * @sizeOf(Instance),
        .byte_len = @as(vk.DeviceSize, len_instances) * @sizeOf(Instance),
    };
}

pub fn uploadInstanceRange(
    self: *Context,
    offset_instances: u32,
    instances: []const Instance,
) !bool {
    const decision = planInstanceUpload(.{
        .current_capacity = self.instance_capacity,
        .offset_instances = offset_instances,
        .write_len = @intCast(instances.len),
    });
    if (decision.force_full_upload) return true;

    const range = planInstanceRangeWrite(offset_instances, @intCast(instances.len));
    const mapped = try self.vkd.mapMemory(self.device, self.instance_memory, range.byte_offset, range.byte_len, .{});
    @memcpy(@as([*]Instance, @ptrCast(@alignCast(mapped)))[0..instances.len], instances);
    self.vkd.unmapMemory(self.device, self.instance_memory);
    return false;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/renderer.zig`
Expected: PASS for the new range-write and upload-planning tests.

- [ ] **Step 5: Commit**

```bash
git add src/renderer.zig
git commit -m "Add partial instance buffer uploads"
```

### Task 8: Integrate dirty-row cache flow into `runTerminal`

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing test**

```zig
test "applyRenderPlan requests full upload when layout changes" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);

    const result = applyRenderPlan(.{
        .layout_dirty = true,
        .rows_rebuilt = 1,
        .cursor_rebuilt = false,
    });

    try std.testing.expect(result.full_upload);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL because `applyRenderPlan` is undefined.

- [ ] **Step 3: Implement the main-loop integration**

```zig
// In runTerminal:
// 1. initialize RenderCache after grid creation
// 2. after term.snapshot(), compute RowRefreshPlan
// 3. rebuild only required rows/cursor
// 4. if layout dirty, repack + full upload
// 5. otherwise call uploadInstanceRange for changed rows/cursor
// 6. if uploadInstanceRange requests fallback, repack + full upload
// 7. clear dirty flags only after cache refresh succeeds
// 8. call drawCells with cache.total_instance_count
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new integration helper tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig src/renderer.zig
git commit -m "Use dirty-row render cache in terminal loop"
```

### Task 9: Verify dirty-flag lifecycle and resize behavior

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "clearConsumedDirtyFlags clears flags only after successful refresh" {
    var rows = [_]bool{ true, false, true };
    clearConsumedDirtyFlags(.partial, &rows, true);
    try std.testing.expect(!rows[0]);
    try std.testing.expect(!rows[2]);
}

test "resize invalidates cache layout for full repack" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);
    cache.layout_dirty = false;
    invalidateCacheForResize(&cache);
    try std.testing.expect(cache.layout_dirty);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL because helpers are undefined.

- [ ] **Step 3: Implement the helpers and wire resize invalidation**

```zig
fn clearConsumedDirtyFlags(dirty: vt.RenderDirty, row_dirty: []bool, success: bool) void { ... }
fn invalidateCacheForResize(cache: *RenderCache) void {
    cache.layout_dirty = true;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the dirty-flag lifecycle and resize invalidation tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Handle dirty flag clearing and resize invalidation"
```

### Task 10: Full verification

**Files:**
- Modify: none
- Test: `src/main.zig`, `src/renderer.zig`

- [ ] **Step 1: Run the full test suite**

Run: `zig build test`
Expected: PASS

- [ ] **Step 2: Run a manual responsiveness smoke test**

Run: `zig build run`
Expected:
- Terminal opens normally.
- Typing a single character does not trigger visible sluggishness.
- Cursor movement still renders correctly.
- Resize still redraws correctly.

- [ ] **Step 3: Run a scrolling/full-clear smoke test**

Run inside the terminal:

```sh
yes "row" | head -n 200
clear
printf 'done\n'
```

Expected:
- Scrolling remains correct.
- `clear` fully redraws the screen.
- Prompt remains usable afterward.

- [ ] **Step 4: Commit**

```bash
git add src/main.zig src/renderer.zig
git commit -m "Verify dirty-row rendering implementation"
```

## Self-Review

- Spec coverage:
  - Row caches: Tasks 4, 5, 8
  - Partial redraw planning: Tasks 1, 8
  - Full repack and offset packing: Tasks 2, 8
  - Partial uploads and fallback on growth: Tasks 3, 7, 8
  - Cursor-only handling: Task 6
  - Dirty-flag lifecycle: Task 9
  - Verification and smoke tests: Task 10
- Placeholder scan:
  - The exact integration steps are listed explicitly in Task 8.
  - No `TODO`/`TBD` markers remain.
- Type consistency:
  - `RowInstanceCache`, `RenderCache`, `RowRefreshPlan`, `planInstanceUpload`, and `uploadInstanceRange` are defined before later tasks rely on them.