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.