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.zig62
1 files changed, 61 insertions, 1 deletions
diff --git a/src/tui_engine.zig b/src/tui_engine.zig
index 05b0b89..59bce06 100644
--- a/src/tui_engine.zig
+++ b/src/tui_engine.zig
@@ -610,7 +610,25 @@ pub const Engine = struct {
/// `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, clear: bool) Error!void {
- if (clear) try self.writer.writeAll(terminal.seq.full_clear);
+ if (clear) {
+ try self.writer.writeAll(terminal.seq.full_clear);
+ } else {
+ // Non-clearing full reprint (layout change / first paint after
+ // scrolled content). The cursor may be anywhere in the previously
+ // rendered region; move it to `viewport_top` so the reprint
+ // overwrites the correct rows rather than appending below them.
+ // First paint has viewport_top == 0 and hw_cursor_row == 0, so
+ // this is a no-op for the initial frame.
+ const start_row = self.hw_cursor_row;
+ const target = self.viewport_top;
+ if (target < start_row) {
+ try self.cursorUp(start_row - target);
+ } else if (target > start_row) {
+ try self.cursorDown(target - start_row);
+ }
+ try self.writer.writeAll(terminal.seq.carriage_return);
+ try self.writer.writeAll(terminal.seq.clear_to_end);
+ }
self.cursor_hint = null;
var global_line: usize = 0;
var first = true;
@@ -1909,3 +1927,45 @@ test "scheduler: nextDeadline reports remaining window" {
try testing.expectEqual(@as(?i128, 8 * std.time.ns_per_ms), s.nextDeadline(0));
try testing.expectEqual(@as(?i128, 0), s.nextDeadline(8 * std.time.ns_per_ms));
}
+
+test "layout change reprint homes cursor to viewport_top (no duplication when cursor was repositioned)" {
+ // Regression: when the hardware cursor was parked at an input row (above
+ // end-of-content) and a layout change triggered a full reprint, fullRedraw
+ // wrote all lines starting from the CURRENT cursor row rather than
+ // viewport_top, duplicating previously rendered content in the terminal.
+ // The fix: non-clearing fullRedraw moves the cursor to viewport_top first.
+ var buf = std.Io.Writer.Allocating.init(testing.allocator);
+ defer buf.deinit();
+ var eng = makeEngine(&buf, 80, 100); // tall: viewport_top stays 0
+ defer eng.deinit();
+
+ // First frame: 3-line component (welcome + user + input).
+ var body = FakeComponent{
+ .scripts = &.{ &.{ "welcome", "user", "input" }, &.{ "welcome", "user", "input" } },
+ .first_changed = &.{ 0, 0 },
+ };
+ try eng.addComponent(body.comp());
+ try eng.render();
+ // Simulate the app parking the cursor at the input row (row 1, mid-content)
+ // as it would after positioning the hardware cursor for IME anchoring.
+ eng.hw_cursor_row = 1;
+
+ // Layout change: add a new component (thinking block spawn).
+ body.advance();
+ var thinking = FakeComponent{ .scripts = &.{&.{"thinking..."}}, .first_changed = &.{0} };
+ buf.clearRetainingCapacity();
+ _ = eng.removeComponent(body.comp()); // drain
+ try eng.addComponent(body.comp()); // re-add (rebuildEngineList shape)
+ try eng.addComponent(thinking.comp());
+ try eng.render();
+
+ const out = buf.written();
+ // Must NOT wipe scrollback.
+ try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null);
+ // Must reposition the cursor to viewport_top (row 0) from hw_cursor_row (1)
+ // before reprinting: that's one cursorUp(1) = "\x1b[1A" followed by \r.
+ try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") != null);
+ // Content must be present exactly once.
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "welcome"));
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "thinking..."));
+}