From 28cb3eacfcf1217b4d014dd864005deb2d7f0585 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 16 Jun 2026 11:49:54 -0600 Subject: tool collapsed state, and fixing dirty component flags --- src/tui_engine.zig | 63 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 11 deletions(-) (limited to 'src/tui_engine.zig') diff --git a/src/tui_engine.zig b/src/tui_engine.zig index 6c5b372..f63e269 100644 --- a/src/tui_engine.zig +++ b/src/tui_engine.zig @@ -485,10 +485,12 @@ pub const Engine = struct { var cut: ?usize = null; for (self.slots.items, 0..) |*slot, idx| { - const first_changed = slot.comp.firstLineChanged(); + const dirty_signal = slot.comp.firstLineChanged(); // Render only when there's a reason to: dirty signal, or first - // paint (no cached lines yet), or a forced full redraw. - const must_render = first_changed != null or slot.lines.len == 0 or self.force_full; + // paint (no cached lines yet), or a forced full redraw. The signal + // is ONLY a dirty/render hint; the repaint cut below is computed + // from the actual old-vs-new line diff after rendering. + const must_render = dirty_signal != null or slot.lines.len == 0 or self.force_full; var new_lines: []const []const u8 = undefined; if (must_render) { @@ -503,6 +505,7 @@ pub const Engine = struct { if (visibleWidth(line) > self.width) return Error.LineOverflow; } + const first_changed = firstDiffLines(slot.lines, new_lines); frames[idx] = .{ .new_lines = new_lines, .first_changed = first_changed, @@ -510,18 +513,13 @@ pub const Engine = struct { .offset = offset, }; - // 2. cut = min across ALL components (not just the first dirty). + // 2. cut = min across ALL components with an ACTUAL line diff. + // A dirty component whose re-rendered bytes are identical leaves + // first_changed null and does not move the repaint point. if (first_changed) |fc| { const global = offset + fc; cut = if (cut) |c| @min(c, global) else global; } - // A length delta is itself a change point even when the component - // reports firstLineChanged == null (the diff backstop will catch - // the content, but the boundary must roll the cut back). - if (new_lines.len != slot.line_count) { - const boundary = offset + @min(new_lines.len, slot.line_count); - cut = if (cut) |c| @min(c, boundary) else boundary; - } offset += new_lines.len; } @@ -634,6 +632,21 @@ pub const Engine = struct { slot.lines = copies; } + /// Lowest local line index where `new_lines` differs from `old_lines`, or + /// null when they are byte-identical (including line count). This is the + /// authoritative repaint signal for a rendered component in the current + /// frame; pre-render `firstLineChanged()` is treated only as a cheap dirty + /// hint for whether the component needs to render. + fn firstDiffLines(old_lines: []const []const u8, new_lines: []const []const u8) ?usize { + const n = @min(old_lines.len, new_lines.len); + var i: usize = 0; + while (i < n) : (i += 1) { + if (!std.mem.eql(u8, old_lines[i], new_lines[i])) return i; + } + if (old_lines.len != new_lines.len) return n; + return null; + } + /// Line-diff backstop (the CORRECTNESS FLOOR). Flattens the previous /// baseline lines (`slot.lines`) and the new frame lines into global arrays /// and returns the FIRST global index where they actually differ, or null @@ -1324,6 +1337,34 @@ test "line-diff backstop corrects an inaccurate firstLineChanged" { try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); } +test "dirty hint at 0 still repaints from actual first changed line" { + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 100); + defer eng.deinit(); + + // Simulates a component that conservatively reports dirty from its top + // (e.g. markDirty dropped its internal cache), while the engine still has + // the previous slot baseline and can cheaply find the true line diff. + var body = FakeComponent{ + .scripts = &.{ &.{ "l0", "l1", "l2", "l3" }, &.{ "l0", "l1", "l2", "L3" } }, + .first_changed = &.{ 0, 0 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, "L3") != null); + try testing.expect(std.mem.indexOf(u8, out, "l0") == null); + try testing.expect(std.mem.indexOf(u8, out, "l1") == null); + try testing.expect(std.mem.indexOf(u8, out, "l2") == null); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); +} + test "consecutive differential frames move the cursor up correctly (streaming append)" { // Regression: the differential path must account for the cursor resting at // the END of the last content line (no trailing newline), not one row -- cgit v1.3