summaryrefslogtreecommitdiff
path: root/src/tui_component.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_component.zig')
-rw-r--r--src/tui_component.zig52
1 files changed, 41 insertions, 11 deletions
diff --git a/src/tui_component.zig b/src/tui_component.zig
index a2b0ea4..b213e3f 100644
--- a/src/tui_component.zig
+++ b/src/tui_component.zig
@@ -119,7 +119,16 @@ pub const Focusable = struct {
/// 2. records the lowest differing index as the "changed-from" value,
/// 3. replaces the cache with a copy of the new lines,
/// 4. marks the cache clean.
-/// - After a clean render with no diff, `firstLineChanged()` returns null.
+/// - Once the cache is CLEAN, `firstLineChanged()` returns null regardless
+/// of the diff `store` recorded. `firstLineChanged()` is the LIVE
+/// pre-render dirty signal (the engine reads it once per slot BEFORE
+/// `render` to decide whether to re-render and where to cut); a clean
+/// component is already up to date on screen and has nothing pending, so
+/// it must report null. The recorded diff index is retained separately in
+/// `changed_from` as render bookkeeping and is NOT a live signal — exposing
+/// it while clean would peg the differential cut to a stale line on every
+/// later frame (a first-paint store records `changed_from == 0`, which
+/// would otherwise force the cut to line 0 forever).
///
/// Because `firstLineChanged` is computed purely from cache state, it cannot
/// drift from a hand-managed field. Components embed this struct and route
@@ -132,8 +141,11 @@ pub const RenderCache = struct {
/// Owned copies of the last rendered lines, or null when never rendered or
/// invalidated.
lines: ?[][]u8 = null,
- /// Lowest line index that changed at the last `store`, or 0 when dirty
- /// with no prior cache. Null only when clean with no change.
+ /// Lowest line index that changed at the last `store` (or the pending
+ /// dirty line while dirty; 0 when dirty with no prior cache). This is
+ /// render bookkeeping, NOT the live signal: when the cache is clean,
+ /// `firstLineChanged()` returns null even though this still holds the last
+ /// diff index. Null only when a clean store found no change.
changed_from: ?usize = null,
dirty: bool = true,
@@ -202,8 +214,17 @@ pub const RenderCache = struct {
/// Derived dirty signal. Returns the lowest changed line index, or null
/// when the cache is clean and nothing changed at the last store.
pub fn firstLineChanged(self: *const RenderCache) ?usize {
+ // The engine reads this as the PRE-render dirty signal (once per slot,
+ // before `render`). A clean cache has no pending change to repaint, so
+ // it must report null — even though the last `store` recorded a
+ // `changed_from` diff index. Returning that retained index while clean
+ // makes an unchanged component spuriously cut the differential repaint
+ // at its stale diff line on EVERY later frame (notably a first-paint
+ // store leaves `changed_from == 0`, which would peg the cut to line 0
+ // forever). The retained `changed_from` is internal render bookkeeping,
+ // not a live signal; only the dirty state drives a repaint.
if (self.dirty) return self.changed_from orelse 0;
- return self.changed_from;
+ return null;
}
/// Record a successful render. Diffs `new_lines` against the cache,
@@ -264,12 +285,15 @@ test "RenderCache: store cleans, no-change render returns null" {
const a = [_][]const u8{ "one", "two" };
try c.store(&a);
- // First store after empty cache => changed from 0.
- try std.testing.expectEqual(@as(?usize, 0), c.firstLineChanged());
+ // store cleans the cache, so the live signal is null (clean => null).
+ // The recorded diff (first store after empty => 0) lives in changed_from.
+ try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
+ try std.testing.expectEqual(@as(?usize, 0), c.changed_from);
- // Re-store identical => no change.
+ // Re-store identical => no change, still clean.
try c.store(&a);
try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
+ try std.testing.expectEqual(@as(?usize, null), c.changed_from);
}
test "RenderCache: store reports lowest differing line" {
@@ -280,7 +304,10 @@ test "RenderCache: store reports lowest differing line" {
try c.store(&a);
const b = [_][]const u8{ "one", "TWO", "three" };
try c.store(&b);
- try std.testing.expectEqual(@as(?usize, 1), c.firstLineChanged());
+ // The diff store computed is recorded in changed_from; the live signal is
+ // null because the cache is now clean (already rendered on screen).
+ try std.testing.expectEqual(@as(?usize, 1), c.changed_from);
+ try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
}
test "RenderCache: line-count change reports the boundary" {
@@ -291,7 +318,8 @@ test "RenderCache: line-count change reports the boundary" {
try c.store(&a);
const b = [_][]const u8{ "one", "two", "three" };
try c.store(&b);
- try std.testing.expectEqual(@as(?usize, 2), c.firstLineChanged());
+ try std.testing.expectEqual(@as(?usize, 2), c.changed_from);
+ try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
}
test "RenderCache: markDirtyAppend keeps baseline and reports a tail hint, diff recovers exact change" {
@@ -307,10 +335,12 @@ test "RenderCache: markDirtyAppend keeps baseline and reports a tail hint, diff
try std.testing.expect(c.lines != null);
// A render that only adds a tail line: diff recovers the exact boundary (4),
- // NOT 0 — the streaming-tail property.
+ // NOT 0 — the streaming-tail property. The cache is clean after store, so
+ // the recorded diff lives in changed_from and the live signal is null.
const b = [_][]const u8{ "l0", "l1", "l2", "l3", "l4" };
try c.store(&b);
- try std.testing.expectEqual(@as(?usize, 4), c.firstLineChanged());
+ try std.testing.expectEqual(@as(?usize, 4), c.changed_from);
+ try std.testing.expectEqual(@as(?usize, null), c.firstLineChanged());
}
test "RenderCache: markDirtyAppend with no baseline reports 0" {