From 2147b9d65c6f5b04ef784c4c624709ff5c401dba Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 10:17:37 -0700 Subject: [PATCH] vt: row dirty tracking --- include/ghostty/vt/render.h | 68 ++++++++++++++-- src/lib_vt.zig | 2 + src/terminal/c/main.zig | 2 + src/terminal/c/render.zig | 149 +++++++++++++++++++++++++++++++++++- 4 files changed, 210 insertions(+), 11 deletions(-) diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index 62bfb9b66..da9849332 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -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); /** @} */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 825fa2f11..9d983a6c6 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 52ed87522..7e1b653d9 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index 0b1169326..186c168c3 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -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(