diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/tui_engine.zig | 71 |
1 files changed, 64 insertions, 7 deletions
diff --git a/src/tui_engine.zig b/src/tui_engine.zig index f63e269..4924347 100644 --- a/src/tui_engine.zig +++ b/src/tui_engine.zig @@ -398,10 +398,14 @@ pub const Engine = struct { old[old.len - 1 - suf].comp.ptr == comps[comps.len - 1 - suf].ptr) suf += 1; // OLD slots in [pre, old.len - suf) are replaced/removed; NEW comps in - // [pre, comps.len - suf) are inserted. A pure insertion replaces zero - // old slots; anything else is a structural change. + // [pre, comps.len - suf) are inserted. const old_changed = old.len - suf - pre; // old slots dropped - const structural_change = old_changed != 0; + const new_inserted = comps.len - suf - pre; // new slots added + // Only a NET REMOVAL forces a full redraw. A pure insertion or a 1:1 + // in-place replacement (override swap) stays differential: the + // replaced/inserted slot first-renders from an empty baseline, so the + // cut lands at THAT slot's offset (clear_to_end clears any orphaned tail). + const structural_change = new_inserted < old_changed; // Detach the preserved suffix slots (with their baselines) before we // mutate the middle, then re-attach them after the inserted slots. @@ -1499,7 +1503,15 @@ test "height change forces a full redraw" { try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); } -test "change above viewport_top forces a full redraw" { +test "change above viewport_top forces a full redraw (keeps scrollback honest)" { + // A change to a line that has scrolled into native scrollback (above + // viewport_top) cannot be patched in place — the cursor can't reach it. + // The ONLY way to make scrollback reflect the change is a clearing full + // redraw (\x1b[3J wipes history) + reprint from the top. We accept that + // cost to keep scrollback honest; the differential path can't address + // those lines. (Tool INPUT streaming does not hit this: the tool is small + // and in-view while args stream — it has no output yet — so the changing + // header stays at/below viewport_top.) var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); // Short viewport so most content scrolls into native scrollback. @@ -1595,7 +1607,13 @@ test "syncComponents: removal in the middle forces a full redraw (structural cha try testing.expect(std.mem.indexOf(u8, out, "b") == null); } -test "syncComponents: replacing a slot (override swap) resets only that baseline" { +test "syncComponents: replacing a slot (override swap) stays differential, no reprint-from-top" { + // A 1:1 in-place swap (e.g. a tool-renderer override installed on a + // `tool_delta`) must NOT force a full reprint. The replaced slot + // first-renders from an empty baseline, so the differential cut lands at + // THAT slot's offset and only the slot (and anything below it) repaints. + // Forcing a full redraw here reprinted the whole transcript from line 0 on + // every delta — the streaming-tool flicker. var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); var eng = makeEngine(&buf, 80, 100); @@ -1607,15 +1625,54 @@ test "syncComponents: replacing a slot (override swap) resets only that baseline try eng.syncComponents(&.{ a.comp(), b.comp() }); try eng.render(); - // Swap b -> b2 at the same position: structural change (pointer differs). + // Swap b -> b2 at the same position (pointer differs, count unchanged). buf.clearRetainingCapacity(); try eng.syncComponents(&.{ a.comp(), b2.comp() }); - try testing.expect(eng.force_full); + try testing.expect(!eng.force_full); // NOT a full redraw try eng.render(); const out = buf.written(); try testing.expect(std.mem.indexOf(u8, out, "b2") != null); } +test "syncComponents: override swap with scrolled content does not reprint from the top (tool-stream flicker)" { + // The real streaming-tool flicker. A tool-renderer override is re-installed + // on every `tool_delta`; the Lua bridge mints a fresh component per + // `set_component`, so the App swaps the slot every delta. With earlier + // content scrolled into native scrollback (viewport_top > 0), forcing a + // full redraw reprinted the ENTIRE conversation from line 0 each delta — + // the visible flash. The swap must stay differential and never re-emit the + // scrolled-away prefix. + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + // Short viewport so the header scrolls off: total 9 lines, height 8 => + // viewport_top 1. The swapped tool slot stays in view (offset 6 >= 1). + var eng = makeEngine(&buf, 80, 8); + defer eng.deinit(); + + // [tall header h0..h5][tool][input][footer]; header h0 scrolls off the top. + var header = FakeComponent{ .scripts = &.{&.{ "h0", "h1", "h2", "h3", "h4", "h5" }}, .first_changed = &.{0} }; + var tool = FakeComponent{ .scripts = &.{&.{"tool_v0"}}, .first_changed = &.{0} }; + var tool2 = FakeComponent{ .scripts = &.{&.{"tool_v1"}}, .first_changed = &.{0} }; + var input = FakeComponent{ .scripts = &.{&.{"input"}}, .first_changed = &.{0} }; + var footer = FakeComponent{ .scripts = &.{&.{"f0"}}, .first_changed = &.{0} }; + + try eng.syncComponents(&.{ header.comp(), tool.comp(), input.comp(), footer.comp() }); + try eng.render(); + try testing.expect(eng.viewport_top > 0); + + // Swap the tool component (the per-delta override re-install). + buf.clearRetainingCapacity(); + try eng.syncComponents(&.{ header.comp(), tool2.comp(), input.comp(), footer.comp() }); + try eng.render(); + + const out = buf.written(); + // No screen clear and — the flicker signature — no reprint from line 0: + // the scrolled-away header top is never re-emitted. + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + try testing.expect(std.mem.indexOf(u8, out, "h0") == null); + try testing.expect(std.mem.indexOf(u8, out, "tool_v1") != null); // the swap shows +} + test "width overflow is a hard error" { var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); |
