a73x

247fce8e

Use dirty-row render cache in terminal loop

a73x   2026-04-09 05:52


diff --git a/src/main.zig b/src/main.zig
index 08690d5..88b9cbe 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -145,12 +145,10 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
    defer p.deinit();
    term.setWritePtyCallback(&p, &writePtyFromTerminal);

    // === instance buffer ===
    var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    defer instances.deinit(alloc);
    try instances.ensureTotalCapacity(alloc, @as(usize, cols) * rows);
    var row_cache = RowInstanceCache{};
    defer row_cache.deinit(alloc);
    // === render cache ===
    var render_cache = RenderCache.empty;
    defer render_cache.deinit(alloc);
    try render_cache.resizeRows(alloc, rows);

    // === main loop ===
    const wl_fd = conn.display.getFd();
@@ -246,19 +244,42 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
        if (!shouldRenderFrame(render_pending, false, false)) continue;

        // === render ===
        const previous_cursor = term.render_state.cursor;
        try term.snapshot();

        instances.clearRetainingCapacity();
        const default_bg = term.backgroundColor();
        const bg_uv = atlas.cursorUV();

        const term_rows = term.render_state.row_data.items(.cells);
        var row_idx: u32 = 0;
        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) .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;
        while (row_idx < term_rows.len) : (row_idx += 1) {
            _ = try rebuildRowInstances(
            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,
                &row_cache,
                row_idx,
                &render_cache.rows[row_idx],
                @intCast(row_idx),
                term_rows[row_idx],
                term,
                &face,
@@ -269,13 +290,22 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
                default_bg,
                bg_uv,
            );
            try instances.appendSlice(alloc, row_cache.instances.items);
            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;
        }

        if (term.render_state.cursor.visible) {
        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();
                try instances.append(alloc, .{
                cursor_instances_buf[0] = .{
                    .cell_pos = .{
                        @floatFromInt(cursor.x),
                        @floatFromInt(cursor.y),
@@ -293,22 +323,86 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
                    },
                    .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;
        }

        // Re-upload atlas if new glyphs were added
        if (atlas.dirty) {
            try ctx.uploadAtlas(atlas.pixels);
            atlas.dirty = false;
            render_cache.layout_dirty = true;
        }

        if (instances.items.len > 0) {
            try ctx.uploadInstances(instances.items);
        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);
                }
            }
        }

        ctx.drawCells(
            @intCast(instances.items.len),
            render_cache.total_instance_count,
            .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
            default_bg,
        ) catch |err| switch (err) {
@@ -320,6 +414,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
            },
            else => return err,
        };
        clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
        render_pending = false;
    }

@@ -391,7 +486,9 @@ fn planRowRefresh(
    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);
    const cursor_rebuild = full_rebuild or
        cursorNeedsRebuild(ctx.cursor) or
        cursorTouchesDirtyRow(dirty_rows, ctx.cursor);

    if (!full_rebuild) {
        var row_idx: usize = 0;
@@ -413,6 +510,16 @@ fn cursorNeedsRebuild(cursor: CursorRefreshContext) bool {
        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),
@@ -614,6 +721,23 @@ test "planRowRefresh rebuilds cursor when only column changes on same row" {
    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{
        .{
@@ -992,6 +1116,37 @@ test "RenderCache resizeRows truncates populated tail rows and preserves prefix 
    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..]);
}

const RowInstanceCache = struct {
    instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
    gpu_offset_instances: u32 = 0,
@@ -1106,6 +1261,31 @@ const CursorRebuildResult = struct {
    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),
@@ -1191,6 +1371,38 @@ 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,
diff --git a/src/vt.zig b/src/vt.zig
index 3d4912c..eaedd7a 100644
--- a/src/vt.zig
+++ b/src/vt.zig
@@ -60,6 +60,7 @@ const DeviceAttributesReturn = @typeInfo(
pub const InputAction = ghostty_vt.input.KeyAction;
pub const InputKey = ghostty_vt.input.Key;
pub const InputMods = ghostty_vt.input.KeyMods;
pub const RenderDirty = ghostty_vt.RenderState.Dirty;
pub const Size = ghostty_vt.size_report.Size;
pub const CellColors = struct {
    fg: [4]f32,