summaryrefslogtreecommitdiff
path: root/src/tui_engine.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_engine.zig')
-rw-r--r--src/tui_engine.zig231
1 files changed, 217 insertions, 14 deletions
diff --git a/src/tui_engine.zig b/src/tui_engine.zig
index fbe8474..0e1becd 100644
--- a/src/tui_engine.zig
+++ b/src/tui_engine.zig
@@ -253,9 +253,24 @@ pub const Engine = struct {
/// Highest `total_lines` we've ever rendered (used to clear orphaned
/// trailing lines on shrink).
max_lines_rendered: usize = 0,
- /// Forces a full redraw on the next frame (first paint, resize).
+ /// Forces a full redraw on the next frame (first paint, layout change,
+ /// resize). A full redraw reprints every line from the top of the working
+ /// area; it does NOT by itself clear the screen/scrollback.
force_full: bool = true,
+ /// When a forced full redraw should ALSO clear the screen + scrollback
+ /// (`\x1b[2J\x1b[H\x1b[3J`). True only for cases where the prior on-screen
+ /// layout is no longer valid and reprinting in place would corrupt it:
+ /// width/height resize (wrapping/viewport changes) and explicit
+ /// `forceFullRedraw` (e.g. after an external program scribbled the screen).
+ ///
+ /// Crucially this is FALSE on first paint and on ordinary layout changes
+ /// (add/remove component), so panto NEVER wipes the user's pre-launch
+ /// shell scrollback: the first frame is printed where the cursor already
+ /// sits and earlier output stays scrollable above (plan §1/§3.1; mirrors
+ /// pi-tui's first render, which calls its `fullRender(false)` — no clear).
+ force_clear: bool = false,
+
/// P3 hook: the global (line, col) where the focused component drew its
/// cursor marker, or null. P1 records it but leaves the hardware cursor at
/// end-of-content (the documented safe deferral); P3 wires hardware
@@ -323,14 +338,24 @@ pub const Engine = struct {
pub fn resize(self: *Engine, width: usize, height: usize) void {
if (width != self.width or height != self.height) {
self.force_full = true;
+ // A resize invalidates the on-screen layout (wrapping / viewport
+ // alignment), so this is one of the few redraws that legitimately
+ // clears the screen before reprinting.
+ self.force_clear = true;
}
self.width = width;
self.height = height;
}
- /// Force the next frame to be a full redraw (e.g. on unrecoverable doubt).
+ /// Force the next frame to be a full redraw that also CLEARS the screen +
+ /// scrollback. Use only when the on-screen content is known to be corrupt
+ /// (e.g. an external program like `$EDITOR` drew over the terminal); an
+ /// ordinary forced reprint that should preserve scrollback does not belong
+ /// here. For a plain layout-change reprint, the engine sets `force_full`
+ /// without `force_clear` itself.
pub fn forceFullRedraw(self: *Engine) void {
self.force_full = true;
+ self.force_clear = true;
}
// -- the render --------------------------------------------------------
@@ -415,19 +440,41 @@ pub const Engine = struct {
}
// 3. Decide full vs differential.
- // Full redraw is forced by: first paint / resize (`force_full`), or
- // a change that lands above `viewport_top` (off-limits to diff).
+ // Full redraw is forced by: first paint / layout change / resize
+ // (`force_full`), or a change that lands above `viewport_top`
+ // (off-limits to differential repaint).
var full = self.force_full;
- if (!full) {
- if (cut) |c| {
- if (c < self.viewport_top) full = true;
+ // Whether that full redraw also clears the screen + scrollback.
+ var clear = self.force_clear;
+
+ // Viewport-escape: a change at/above `viewport_top` touches lines that
+ // have already scrolled into native scrollback and cannot be patched
+ // in place. Reprinting from the top WITHOUT a clear would re-emit
+ // those scrolled-away lines below their original copy, DUPLICATING
+ // them in scrollback. So such a change must force a CLEARING full
+ // redraw (matches pi-tui's viewport-escape path = fullRender(true)).
+ //
+ // This is checked independently of `force_full`: a forced full redraw
+ // (e.g. an in-session layout change via addComponent/rebuild) is by
+ // default a NON-clearing reprint, but if the cut lands above the
+ // viewport it must escalate to a clearing one — otherwise the layout
+ // change duplicates scrollback. Only first paint (viewport_top == 0)
+ // and changes at/below the viewport stay clear-free.
+ if (cut) |c| {
+ if (c < self.viewport_top) {
+ full = true;
+ clear = true;
}
}
try self.beginFrame();
if (full) {
- try self.fullRedraw(frames, new_total);
+ // Clear the screen/scrollback only when required (resize,
+ // `forceFullRedraw`, or a change above the viewport). First paint
+ // and ordinary layout changes are full reprints WITHOUT a clear,
+ // so pre-launch scrollback survives (plan §1/§3.1).
+ try self.fullRedraw(frames, new_total, clear);
} else {
try self.differential(frames, cut, new_total);
}
@@ -452,6 +499,7 @@ pub const Engine = struct {
if (new_total > self.max_lines_rendered) self.max_lines_rendered = new_total;
self.recomputeViewportTop();
self.force_full = false;
+ self.force_clear = false;
}
/// Dupe `new_lines` into the slot's owned baseline, freeing the old copy.
@@ -514,16 +562,22 @@ pub const Engine = struct {
if (self.synchronized_output) try self.writer.writeAll(terminal.seq.sync_end);
}
- /// Full redraw: clear screen + scrollback, home cursor, print everything
- /// from the top. Resets the viewport.
+ /// Full redraw: optionally clear screen + scrollback, home cursor, then
+ /// print everything from the top. Resets the viewport.
+ ///
+ /// `clear` controls whether the screen + scrollback are wiped first
+ /// (`full_clear`). It is TRUE for resize / explicit `forceFullRedraw`, and
+ /// FALSE for first paint and layout-change reprints — so the very first
+ /// frame prints where the cursor already is and the user's prior shell
+ /// scrollback is preserved (plan §1/§3.1; mirrors pi-tui's first render).
///
/// Cursor-resting invariant (shared with `differential`): the last line is
/// written WITHOUT a trailing newline, so after the frame the hardware
/// cursor rests at the end of the last content line (global row
/// `total_lines - 1`), not one row below it. `differential` relies on this
/// to compute how far to move up.
- fn fullRedraw(self: *Engine, frames: anytype, new_total: usize) Error!void {
- try self.writer.writeAll(terminal.seq.full_clear);
+ fn fullRedraw(self: *Engine, frames: anytype, new_total: usize, clear: bool) Error!void {
+ if (clear) try self.writer.writeAll(terminal.seq.full_clear);
self.cursor_hint = null;
var global_line: usize = 0;
var first = true;
@@ -683,7 +737,11 @@ test "visibleWidth strips CSI and counts codepoints" {
try testing.expectEqual(@as(usize, 3), visibleWidth("aé✓"));
}
-test "first paint is a full redraw clearing scrollback" {
+test "first paint is a full redraw that does NOT clear scrollback" {
+ // Plan §1/§3.1: the first frame must print where the cursor already sits
+ // and preserve the user's pre-launch shell scrollback. It is a full
+ // reprint, but it must NOT emit the screen+scrollback clear (mirrors
+ // pi-tui's first render = fullRender(false)).
var buf = std.Io.Writer.Allocating.init(testing.allocator);
defer buf.deinit();
var eng = makeEngine(&buf, 80, 24);
@@ -697,12 +755,157 @@ test "first paint is a full redraw clearing scrollback" {
try eng.render();
const out = buf.written();
- try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null);
+ // No scrollback wipe on first paint.
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+ // But the content is printed.
try testing.expect(std.mem.indexOf(u8, out, "line one") != null);
try testing.expect(std.mem.indexOf(u8, out, "line two") != null);
try testing.expectEqual(@as(usize, 1), fc.render_calls);
}
+test "resize forces a full redraw that DOES clear scrollback" {
+ // A width/height change invalidates the on-screen layout, so this is one
+ // of the few redraws that legitimately clears before reprinting.
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 24);
+ defer eng.deinit();
+
+ var fc = FakeComponent{
+ .scripts = &.{ &.{ "line one", "line two" }, &.{ "line one", "line two" } },
+ .first_changed = &.{ 0, null },
+ };
+ try eng.addComponent(fc.comp());
+ try eng.render(); // first paint: no clear
+ try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) == null);
+
+ fc.advance();
+ buf.clearRetainingCapacity();
+ eng.resize(100, 24); // width change -> clear on next frame
+ try eng.render();
+ try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) != null);
+}
+
+test "in-session layout change (addComponent) does NOT clear scrollback" {
+ // The app calls rebuildEngineList (drain + re-add all slots) on EVERY new
+ // transcript entry, which sets force_full each time. That forced full
+ // redraw must be a plain reprint WITHOUT a screen/scrollback clear, or the
+ // user's history would flash-wipe on every streamed message. Plan §1/§3.1.
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100); // tall: nothing scrolls off
+ defer eng.deinit();
+
+ var body = FakeComponent{ .scripts = &.{&.{ "b0", "b1" }}, .first_changed = &.{0} };
+ try eng.addComponent(body.comp());
+ try eng.render(); // first paint (no clear)
+ try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) == null);
+
+ // Add a second component (a layout change => force_full, NOT force_clear).
+ var footer = FakeComponent{ .scripts = &.{&.{"f0"}}, .first_changed = &.{0} };
+ buf.clearRetainingCapacity();
+ try eng.addComponent(footer.comp());
+ try eng.render();
+
+ const out = buf.written();
+ // Layout-change reprint: full content, but NO scrollback wipe.
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+ try testing.expect(std.mem.indexOf(u8, out, "b0") != null);
+ try testing.expect(std.mem.indexOf(u8, out, "f0") != null);
+}
+
+test "layout change with scrolled content: change above viewport_top clears (no scrollback duplication)" {
+ // The scrollback-duplication hazard: when earlier lines have already
+ // scrolled into native scrollback (viewport_top > 0), a forced full
+ // reprint from the top WITHOUT a clear would re-emit those scrolled-away
+ // lines below their original copy, duplicating them in scrollback.
+ //
+ // The engine must NOT do that. When a change (here a fresh first-render of
+ // re-added slots after a drain+rebuild) lands at/above viewport_top, the
+ // render path must clear before reprinting. We simulate the drain+rebuild
+ // by removing and re-adding the component, which resets its baseline so it
+ // first-renders at line 0 (i.e. the "change" is at line 0, above
+ // viewport_top).
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ // Short viewport so content scrolls: 5 lines, height 2 => viewport_top 3.
+ var eng = makeEngine(&buf, 80, 2);
+ defer eng.deinit();
+
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "l0", "l1", "l2", "l3", "l4" }, &.{ "l0", "l1", "l2", "l3", "l4" } },
+ .first_changed = &.{ 0, 0 },
+ };
+ try eng.addComponent(body.comp());
+ try eng.render(); // first paint
+ try testing.expectEqual(@as(usize, 3), eng.viewport_top);
+
+ // Drain + re-add (the rebuildEngineList shape). This resets the slot
+ // baseline so the re-added component first-renders from line 0.
+ _ = eng.removeComponent(body.comp());
+ body.advance();
+ try eng.addComponent(body.comp());
+ buf.clearRetainingCapacity();
+ try eng.render();
+
+ const out = buf.written();
+ // The change is at line 0 (above viewport_top == 3), so the engine MUST
+ // clear before reprinting to avoid duplicating the scrolled-away lines.
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null);
+ // And exactly one copy of each line is emitted (no duplication). Count l0.
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "l0"));
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "l4"));
+}
+
+test "forceFullRedraw clears scrollback (e.g. $EDITOR return path)" {
+ // After an external program ($EDITOR) scribbles the screen, the app calls
+ // engine.forceFullRedraw() to repaint from a known-clean slate. That path
+ // MUST clear (the on-screen content is corrupt), unlike ordinary layout
+ // changes.
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100);
+ defer eng.deinit();
+
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "a", "b" }, &.{ "a", "b" } },
+ .first_changed = &.{ 0, null },
+ };
+ try eng.addComponent(body.comp());
+ try eng.render(); // first paint: no clear
+ try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) == null);
+
+ body.advance();
+ buf.clearRetainingCapacity();
+ eng.forceFullRedraw();
+ try eng.render();
+ try testing.expect(std.mem.indexOf(u8, buf.written(), terminal.seq.full_clear) != null);
+}
+
+test "first paint preserves the cursor-resting invariant (no trailing newline)" {
+ // The no-clear first paint must still leave the cursor at the END of the
+ // last content line (last line written WITHOUT a trailing newline), since
+ // the differential cursor math on the next frame depends on it.
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100);
+ defer eng.deinit();
+
+ var body = FakeComponent{ .scripts = &.{&.{ "l0", "l1", "l2" }}, .first_changed = &.{0} };
+ try eng.addComponent(body.comp());
+ try eng.render();
+
+ const out = buf.written();
+ // No clear, and the frame must NOT end with a trailing newline after the
+ // last line (it ends with the last line's content, or the sync-end marker
+ // when synchronized; this engine has sync off).
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+ try testing.expect(!std.mem.endsWith(u8, out, "\r\n"));
+ try testing.expect(std.mem.endsWith(u8, out, "l2"));
+ // Lines are joined by \r\n, so exactly (count-1) separators for 3 lines.
+ try testing.expectEqual(@as(usize, 2), std.mem.count(u8, out, "\r\n"));
+}
+
test "cut is the min across ALL components (ticking footer below clean body)" {
var buf = std.Io.Writer.Allocating.init(testing.allocator);
defer buf.deinit();