mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
libghostty: add dedicated dirty row iteration + clear functions
Add render state C APIs for iterating only rows that require a redraw and for marking a completed frame clean in one call. A one-row update in a 24-row viewport reduces dirty-row discovery from 50 calls to two, while cleanup becomes one call instead of O(N) of rows. This lower call count is massive for environments where FFI is expensive (Go, wasm). The dirty next API outputs the viewport y because it jumps, unlike the normal sequential next where its trivial for a caller to keep track.
This commit is contained in:
@@ -165,15 +165,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.
|
||||
@@ -239,22 +233,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]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -421,6 +424,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.
|
||||
*
|
||||
@@ -534,7 +554,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)
|
||||
@@ -545,12 +566,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)
|
||||
@@ -599,7 +644,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)
|
||||
|
||||
@@ -283,12 +283,14 @@ 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_colors_get, .{ .name = "ghostty_render_state_colors_get" });
|
||||
@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" });
|
||||
|
||||
@@ -87,12 +87,14 @@ 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_colors_get = render.colors_get;
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
@@ -220,6 +224,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,
|
||||
@@ -300,6 +312,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,
|
||||
};
|
||||
},
|
||||
@@ -442,6 +455,7 @@ pub fn row_iterator_new(
|
||||
.cells = undefined,
|
||||
.selection = undefined,
|
||||
.dirty = undefined,
|
||||
.state_dirty = undefined,
|
||||
.palette = undefined,
|
||||
};
|
||||
result.* = ptr;
|
||||
@@ -463,6 +477,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,
|
||||
@@ -1058,6 +1102,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(
|
||||
@@ -1708,6 +1788,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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user