vt: row dirty tracking

This commit is contained in:
Mitchell Hashimoto
2026-03-19 10:17:37 -07:00
parent f610d7e00f
commit 2147b9d65c
4 changed files with 210 additions and 11 deletions

View File

@@ -39,6 +39,25 @@ extern "C" {
* 2. Update it from a terminal instance whenever you need.
* 3. Read from the render state to get the data needed to draw your frame.
*
* ## Dirty Tracking
*
* Dirty tracking is a key feature of the render state that allows renderers
* to efficiently determine what parts of the screen have changed and only
* redraw changed regions.
*
* The render state API keeps track of dirty state at two independent layers:
* a global dirty state that indicates whether the entire frame is clean,
* partially dirty, or fully dirty, and a per-row dirty state that allows
* tracking which rows in a partially dirty frame have changed.
*
* The user of the render state API is expected to unset both of these.
* The `update` call does not unset dirty state, it only updates it.
*
* An extremely important detail: setting one dirty state doesn't unset
* the other. For example, setting the global dirty state to false does not
* reset the row-level dirty flags. So, the caller of the render state API must
* be careful to manage both layers of dirty state correctly.
*
* ## Example
*
* @snippet c-vt-render/src/main.c render-state-update
@@ -128,6 +147,18 @@ typedef struct {
GhosttyResult ghostty_render_state_new(const GhosttyAllocator* allocator,
GhosttyRenderState* state);
/**
* Free a render state instance.
*
* Releases all resources associated with the render state. After this call,
* the render state handle becomes invalid.
*
* @param state The render state handle to free (may be NULL)
*
* @ingroup render
*/
void ghostty_render_state_free(GhosttyRenderState state);
/**
* Update a render state instance from a terminal.
*
@@ -227,6 +258,15 @@ GhosttyResult ghostty_render_state_row_iterator_new(
GhosttyRenderState state,
GhosttyRenderStateRowIterator* out_iterator);
/**
* Free a render-state row iterator.
*
* @param iterator The iterator handle to free (may be NULL)
*
* @ingroup render
*/
void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIterator iterator);
/**
* Move a render-state row iterator to the next row.
*
@@ -242,25 +282,37 @@ GhosttyResult ghostty_render_state_row_iterator_new(
bool ghostty_render_state_row_iterator_next(GhosttyRenderStateRowIterator iterator);
/**
* Free a render-state row iterator.
* Get the dirty state of the current row in a render-state row iterator.
*
* @param iterator The iterator handle to free (may be NULL)
* This reads the dirty flag at the iterator's current row position.
* Call ghostty_render_state_row_iterator_next() at least once before
* calling this function.
*
* @param iterator The iterator handle to query (may be NULL)
* @return true if the current row is dirty, false if the row is clean,
* `iterator` is NULL, or the iterator is not positioned on a row
*
* @ingroup render
*/
void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIterator iterator);
bool ghostty_render_state_row_dirty_get(GhosttyRenderStateRowIterator iterator);
/**
* Free a render state instance.
* Set the dirty state of the current row in a render-state row iterator.
*
* Releases all resources associated with the render state. After this call,
* the render state handle becomes invalid.
* This writes the dirty flag at the iterator's current row position.
* Call ghostty_render_state_row_iterator_next() at least once before
* calling this function.
*
* @param state The render state handle to free (may be NULL)
* @param iterator The iterator handle to update (may be NULL)
* @param dirty The dirty state to set for the current row
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if
* `iterator` is NULL or the iterator is not positioned on a row
*
* @ingroup render
*/
void ghostty_render_state_free(GhosttyRenderState state);
GhosttyResult ghostty_render_state_row_dirty_set(
GhosttyRenderStateRowIterator iterator,
bool dirty);
/** @} */

View File

@@ -195,6 +195,8 @@ comptime {
@export(&c.render_state_dirty_set, .{ .name = "ghostty_render_state_dirty_set" });
@export(&c.render_state_row_iterator_new, .{ .name = "ghostty_render_state_row_iterator_new" });
@export(&c.render_state_row_iterator_next, .{ .name = "ghostty_render_state_row_iterator_next" });
@export(&c.render_state_row_dirty_get, .{ .name = "ghostty_render_state_row_dirty_get" });
@export(&c.render_state_row_dirty_set, .{ .name = "ghostty_render_state_row_dirty_set" });
@export(&c.render_state_row_iterator_free, .{ .name = "ghostty_render_state_row_iterator_free" });
@export(&c.render_state_free, .{ .name = "ghostty_render_state_free" });
@export(&c.terminal_new, .{ .name = "ghostty_terminal_new" });

View File

@@ -45,6 +45,8 @@ pub const render_state_dirty_get = render.dirty_get;
pub const render_state_dirty_set = render.dirty_set;
pub const render_state_row_iterator_new = render.row_iterator_new;
pub const render_state_row_iterator_next = render.row_iterator_next;
pub const render_state_row_dirty_get = render.row_dirty_get;
pub const render_state_row_dirty_set = render.row_dirty_set;
pub const render_state_row_iterator_free = render.row_iterator_free;
pub const sgr_new = sgr.new;

View File

@@ -7,6 +7,7 @@ const CAllocator = lib_alloc.Allocator;
const colorpkg = @import("../color.zig");
const page = @import("../page.zig");
const size = @import("../size.zig");
const Style = @import("../style.zig").Style;
const terminal_c = @import("terminal.zig");
const renderpkg = @import("../render.zig");
const Result = @import("result.zig").Result;
@@ -24,8 +25,8 @@ const RowIteratorWrapper = struct {
/// These are the raw pointers into the render state data.
raws: []const page.Row,
cells: []const std.MultiArrayList(renderpkg.RenderState.Cell),
dirty: []const bool,
cells: []const std.MultiArrayList(renderpkg.RenderState.Cell).Slice,
dirty: []bool,
};
/// C: GhosttyRenderState
@@ -217,7 +218,7 @@ fn row_iterator_new_(
.alloc = alloc,
.y = null,
.raws = row_data.items(.raw),
.cells = row_data.items(.cells),
.cells = row_data.items(.cells).slice(),
.dirty = row_data.items(.dirty),
};
@@ -238,6 +239,22 @@ pub fn row_iterator_next(iterator_: RowIterator) callconv(.c) bool {
return true;
}
pub fn row_dirty_get(iterator_: RowIterator) callconv(.c) bool {
const it = iterator_ orelse return false;
const y = it.y orelse return false;
return it.dirty[y];
}
pub fn row_dirty_set(
iterator_: RowIterator,
dirty: bool,
) callconv(.c) Result {
const it = iterator_ orelse return .invalid_value;
const y = it.y orelse return .invalid_value;
it.dirty[y] = dirty;
return .success;
}
test "render: new/free" {
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
@@ -432,6 +449,132 @@ test "render: row iterator next null" {
try testing.expect(!row_iterator_next(null));
}
test "render: row iterator dirty get null" {
try testing.expect(!row_dirty_get(null));
}
test "render: row iterator dirty set invalid value" {
try testing.expectEqual(Result.invalid_value, row_dirty_set(null, false));
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&terminal,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 10_000,
},
));
defer terminal_c.free(terminal);
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&state,
));
defer free(state);
try testing.expectEqual(Result.success, update(state, terminal));
var iterator: RowIterator = null;
try testing.expectEqual(Result.success, row_iterator_new(
&lib_alloc.test_allocator,
state,
&iterator,
));
defer row_iterator_free(iterator);
try testing.expectEqual(Result.invalid_value, row_dirty_set(iterator, false));
}
test "render: row iterator dirty get before iteration" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&terminal,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 10_000,
},
));
defer terminal_c.free(terminal);
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&state,
));
defer free(state);
try testing.expectEqual(Result.success, update(state, terminal));
var iterator: RowIterator = null;
try testing.expectEqual(Result.success, row_iterator_new(
&lib_alloc.test_allocator,
state,
&iterator,
));
defer row_iterator_free(iterator);
try testing.expect(!row_dirty_get(iterator));
}
test "render: row iterator dirty get" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib_alloc.test_allocator,
&terminal,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 10_000,
},
));
defer terminal_c.free(terminal);
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
&lib_alloc.test_allocator,
&state,
));
defer free(state);
try testing.expectEqual(Result.success, update(state, terminal));
// Dirty the first row so the iterator has at least one dirty row to observe.
terminal_c.vt_write(terminal, "hello", 5);
try testing.expectEqual(Result.success, update(state, terminal));
// Create an iterator and verify it is dirty.
var it: RowIterator = null;
try testing.expectEqual(Result.success, row_iterator_new(
&lib_alloc.test_allocator,
state,
&it,
));
defer row_iterator_free(it);
try testing.expect(row_iterator_next(it));
try testing.expect(row_dirty_get(it));
// Clear dirty on this row.
try testing.expectEqual(Result.success, row_dirty_set(it, false));
// It should not be dirty anymore.
var it2: RowIterator = null;
try testing.expectEqual(Result.success, row_iterator_new(
&lib_alloc.test_allocator,
state,
&it2,
));
defer row_iterator_free(it2);
try testing.expect(row_iterator_next(it2));
try testing.expect(!row_dirty_get(it2));
}
test "render: row iterator next" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(