From 104001d25c9c8cb5ec45ced1678f7c7b70888808 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 8 Jun 2026 14:48:33 -0600 Subject: keybinding fixes --- src/tui_engine.zig | 231 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 217 insertions(+), 14 deletions(-) (limited to 'src/tui_engine.zig') 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(); -- cgit v1.3