diff --git a/example/c-vt-render/src/main.c b/example/c-vt-render/src/main.c index ede168033..705a4a0e6 100644 --- a/example/c-vt-render/src/main.c +++ b/example/c-vt-render/src/main.c @@ -152,15 +152,9 @@ int main(void) { result = ghostty_render_state_row_cells_new(NULL, &cells); assert(result == GHOSTTY_SUCCESS); - int row_index = 0; - while (ghostty_render_state_row_iterator_next(row_iter)) { - // Check per-row dirty state; a real renderer would skip clean rows. - bool row_dirty = false; - ghostty_render_state_row_get( - row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY, &row_dirty); - - printf("Row %2d [%s]: ", row_index, - row_dirty ? "dirty" : "clean"); + uint16_t row_y = 0; + while (ghostty_render_state_row_iterator_next_dirty(row_iter, &row_y)) { + printf("Row %2u [dirty]: ", row_y); // Query the row-local selection range. Rows without a selection return // GHOSTTY_NO_VALUE; selected rows return inclusive start/end columns. @@ -226,22 +220,13 @@ int main(void) { } printf("\n"); - - // Clear per-row dirty flag after "rendering" it. - bool clean = false; - ghostty_render_state_row_set( - row_iter, GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY, &clean); - - row_index++; } //! [render-row-iterate] //! [render-dirty-reset] - // After finishing the frame, reset the global dirty state so the next - // update can report changes accurately. - GhosttyRenderStateDirty clean_state = GHOSTTY_RENDER_STATE_DIRTY_FALSE; - result = ghostty_render_state_set( - render_state, GHOSTTY_RENDER_STATE_OPTION_DIRTY, &clean_state); + // After successfully rendering the complete frame, clear both the global + // and per-row dirty state in one call. + result = ghostty_render_state_clean(render_state); assert(result == GHOSTTY_SUCCESS); //! [render-dirty-reset] diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index 5329a1d9a..728966f48 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -63,7 +63,10 @@ extern "C" { * 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. + * The `update` call does not unset dirty state, it only updates it. After + * successfully rendering a complete frame, use ghostty_render_state_clean() + * to unset both layers in one call. The granular setters remain available + * for callers that only consume part of a frame. * * An extremely important detail: setting one dirty state doesn't unset * the other. For example, setting the global dirty state to false does not @@ -471,6 +474,23 @@ GHOSTTY_API GhosttyResult ghostty_render_state_begin_update(GhosttyRenderState s */ GHOSTTY_API GhosttyResult ghostty_render_state_end_update(GhosttyRenderState state); +/** + * Mark all dirty render-state data as consumed. + * + * This sets the global dirty state to GHOSTTY_RENDER_STATE_DIRTY_FALSE and + * clears every per-row dirty flag. It is idempotent and does not modify cell + * contents or dirty state owned by the terminal. Call this only after a + * complete frame has been rendered successfully; partial consumers should + * use ghostty_render_state_set() and ghostty_render_state_row_set() instead. + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is + * NULL + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_clean(GhosttyRenderState state); + /** * Get a value from a render state. * @@ -567,7 +587,8 @@ GHOSTTY_API void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIte /** * Move a render-state row iterator to the next row. * - * Returns true if the iterator moved successfully and row data is + * Rows are visited contiguously in ascending viewport order, starting at + * y = 0. Returns true if the iterator moved successfully and row data is * available to read at the new position. * * @param iterator The iterator handle to advance (may be NULL) @@ -578,12 +599,36 @@ GHOSTTY_API void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIte */ GHOSTTY_API bool ghostty_render_state_row_iterator_next(GhosttyRenderStateRowIterator iterator); +/** + * Move a render-state row iterator to the next row requiring a redraw. + * + * If the global dirty state is GHOSTTY_RENDER_STATE_DIRTY_FALSE, this returns + * false. If it is GHOSTTY_RENDER_STATE_DIRTY_PARTIAL, clean rows are skipped. + * If it is GHOSTTY_RENDER_STATE_DIRTY_FULL, every remaining row is returned + * regardless of its per-row dirty flag. Rows are returned in ascending + * viewport order. This function does not clear any dirty state. + * + * @param iterator The iterator handle to advance (NULL returns false) + * @param[out] out_y Receives the viewport y coordinate when true is returned + * (NULL returns false); it is not modified when false is + * returned + * @return true if advanced to a row requiring a redraw, false if an argument + * is NULL or the iterator has reached the end of the effective dirty + * rows + * + * @ingroup render + */ +GHOSTTY_API bool ghostty_render_state_row_iterator_next_dirty( + GhosttyRenderStateRowIterator iterator, + uint16_t* out_y); + /** * Get a value from the current row in a render-state row iterator. * * The `out` pointer must point to a value of the type corresponding to the * requested data kind (see GhosttyRenderStateRowData). - * Call ghostty_render_state_row_iterator_next() at least once before + * Call ghostty_render_state_row_iterator_next() or + * ghostty_render_state_row_iterator_next_dirty() at least once before * calling this function. * * @param iterator The iterator handle to query (NULL returns GHOSTTY_INVALID_VALUE) @@ -632,7 +677,8 @@ GHOSTTY_API GhosttyResult ghostty_render_state_row_get_multi( * * The `value` pointer must point to a value of the type corresponding to the * requested option kind (see GhosttyRenderStateRowOption). - * Call ghostty_render_state_row_iterator_next() at least once before + * Call ghostty_render_state_row_iterator_next() or + * ghostty_render_state_row_iterator_next_dirty() at least once before * calling this function. * * @param iterator The iterator handle to update (NULL returns GHOSTTY_INVALID_VALUE) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 8faf86471..b65d12883 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -283,11 +283,13 @@ comptime { @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); @export(&c.render_state_begin_update, .{ .name = "ghostty_render_state_begin_update" }); @export(&c.render_state_end_update, .{ .name = "ghostty_render_state_end_update" }); + @export(&c.render_state_clean, .{ .name = "ghostty_render_state_clean" }); @export(&c.render_state_get, .{ .name = "ghostty_render_state_get" }); @export(&c.render_state_get_multi, .{ .name = "ghostty_render_state_get_multi" }); @export(&c.render_state_set, .{ .name = "ghostty_render_state_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_iterator_next_dirty, .{ .name = "ghostty_render_state_row_iterator_next_dirty" }); @export(&c.render_state_row_get, .{ .name = "ghostty_render_state_row_get" }); @export(&c.render_state_row_get_multi, .{ .name = "ghostty_render_state_row_get_multi" }); @export(&c.render_state_row_set, .{ .name = "ghostty_render_state_row_set" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index d45bbfe26..9a3374504 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -87,11 +87,13 @@ pub const render_state_free = render.free; pub const render_state_update = render.update; pub const render_state_begin_update = render.begin_update; pub const render_state_end_update = render.end_update; +pub const render_state_clean = render.clean; pub const render_state_get = render.get; pub const render_state_get_multi = render.get_multi; pub const render_state_set = render.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_iterator_next_dirty = render.row_iterator_next_dirty; pub const render_state_row_get = render.row_get; pub const render_state_row_get_multi = render.row_get_multi; pub const render_state_row_set = render.row_set; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index 377767532..c108456f2 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -42,6 +42,10 @@ const RowIteratorWrapper = struct { selection: []const ?[2]size.CellCountInt, dirty: []bool, + /// The global dirty state from the render state that populated this + /// iterator. This has the same borrowed lifetime as the row slices. + state_dirty: *const Dirty, + /// The color palette from the render state, needed to resolve /// palette-indexed background colors on cells. palette: *const colorpkg.Palette, @@ -237,6 +241,14 @@ pub fn end_update( return .success; } +pub fn clean( + state_: RenderState, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + state.state.clean(); + return .success; +} + pub fn get( state_: RenderState, data: Data, @@ -318,6 +330,7 @@ fn getTyped( .cells = row_data.items(.cells), .selection = row_data.items(.selection), .dirty = row_data.items(.dirty), + .state_dirty = &state.state.dirty, .palette = &state.state.colors.palette, }; }, @@ -537,6 +550,7 @@ pub fn row_iterator_new( .cells = undefined, .selection = undefined, .dirty = undefined, + .state_dirty = undefined, .palette = undefined, }; result.* = ptr; @@ -558,6 +572,36 @@ pub fn row_iterator_next(iterator_: RowIterator) callconv(lib.calling_conv) bool return true; } +pub fn row_iterator_next_dirty( + iterator_: RowIterator, + out_y: ?*size.CellCountInt, +) callconv(lib.calling_conv) bool { + const it = iterator_ orelse return false; + const y_out = out_y orelse return false; + + switch (it.state_dirty.*) { + .false => return false, + .full => { + // The none sentinel wraps to zero. + const next_y = it.y +% 1; + if (next_y >= it.raws.len) return false; + it.y = next_y; + y_out.* = @intCast(next_y); + return true; + }, + .partial => { + var next_y = it.y +% 1; + while (next_y < it.raws.len) : (next_y += 1) { + if (!it.dirty[next_y]) continue; + it.y = next_y; + y_out.* = @intCast(next_y); + return true; + } + return false; + }, + } +} + pub fn row_cells_new( alloc_: ?*const CAllocator, result: *RowCells, @@ -1170,6 +1214,42 @@ test "render: get/set dirty" { try testing.expectEqual(Dirty.full, dirty); } +test "render: clean" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 3, + )); + 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.invalid_value, clean(null)); + try testing.expectEqual(Result.success, update(state, terminal)); + try testing.expectEqual(Dirty.full, state.?.state.dirty); + + try testing.expectEqual(Result.success, clean(state)); + try testing.expectEqual(Dirty.false, state.?.state.dirty); + for (state.?.state.row_data.items(.dirty)) |dirty| { + try testing.expect(!dirty); + } + + // Cleaning is idempotent, and an unchanged update remains clean. + try testing.expectEqual(Result.success, clean(state)); + try testing.expectEqual(Result.success, update(state, terminal)); + try testing.expectEqual(Dirty.false, state.?.state.dirty); + for (state.?.state.row_data.items(.dirty)) |dirty| { + try testing.expect(!dirty); + } +} + test "render: set null value" { var state: RenderState = null; try testing.expectEqual(Result.success, new( @@ -1820,6 +1900,79 @@ test "render: row iterator next" { try testing.expectEqual(@as(usize, rows - 1), iterator.?.y); } +test "render: row iterator next dirty" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + 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, + &iterator, + )); + defer row_iterator_free(iterator); + + // Invalid arguments neither write the output nor advance the iterator. + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + var out_y: size.CellCountInt = 0xCAFE; + try testing.expect(!row_iterator_next_dirty(null, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 0xCAFE), out_y); + try testing.expect(!row_iterator_next_dirty(iterator, null)); + try testing.expectEqual(position_none, iterator.?.y); + + // A full redraw returns every row, even if its row flag was cleared. + @memset(state.?.state.row_data.items(.dirty), false); + var expected_y: size.CellCountInt = 0; + while (row_iterator_next_dirty(iterator, &out_y)) : (expected_y += 1) { + try testing.expectEqual(expected_y, out_y); + } + try testing.expectEqual(@as(size.CellCountInt, 4), expected_y); + try testing.expectEqual(@as(size.CellCountInt, 3), out_y); + + // A partial redraw skips clean rows without consuming dirty flags. + const dirty = state.?.state.row_data.items(.dirty); + state.?.state.dirty = .partial; + @memset(dirty, false); + dirty[1] = true; + dirty[3] = true; + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + try testing.expect(row_iterator_next_dirty(iterator, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 1), out_y); + try testing.expect(row_iterator_next_dirty(iterator, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 3), out_y); + try testing.expect(!row_iterator_next_dirty(iterator, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 3), out_y); + try testing.expect(dirty[1]); + try testing.expect(dirty[3]); + + // The global clean state is authoritative, even if stale row flags exist. + state.?.state.dirty = .false; + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + out_y = 0xCAFE; + try testing.expect(!row_iterator_next_dirty(iterator, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 0xCAFE), out_y); + + // Existing iterators observe cleaning through their borrowed state. + state.?.state.dirty = .partial; + dirty[1] = true; + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + try testing.expectEqual(Result.success, clean(state)); + try testing.expect(!row_iterator_next_dirty(iterator, &out_y)); +} + test "render: update" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 0c9bca318..6bcddb2c2 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -810,6 +810,16 @@ pub const RenderState = struct { self.pending_styles.clearRetainingCapacity(); } + /// Mark all render-state data as consumed by the renderer. + /// + /// This clears both the global dirty state and every per-row dirty flag. + /// Callers that only consume part of a frame should clear the two layers + /// individually instead. + pub fn clean(self: *RenderState) void { + self.dirty = .false; + @memset(self.row_data.items(.dirty), false); + } + /// Fill a slice of styles with one value. /// /// This is equivalent to `@memset(dst, value)` but manually vectorized: