diff options
| -rw-r--r-- | src/tui_engine.zig | 62 |
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...")); +} |
