diff options
| author | t <t@tjp.lol> | 2026-06-09 00:00:56 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-09 08:52:47 -0600 |
| commit | 97b10466d83d488ad2cb9e084159a445af74c845 (patch) | |
| tree | 6bea7c23d00ff1178a43e631fc700f210cb7d85b /src/tui_engine.zig | |
| parent | 104001d25c9c8cb5ec45ced1678f7c7b70888808 (diff) | |
event lifecycle
Diffstat (limited to 'src/tui_engine.zig')
| -rw-r--r-- | src/tui_engine.zig | 578 |
1 files changed, 565 insertions, 13 deletions
diff --git a/src/tui_engine.zig b/src/tui_engine.zig index 0e1becd..c6ddf49 100644 --- a/src/tui_engine.zig +++ b/src/tui_engine.zig @@ -271,12 +271,31 @@ pub const Engine = struct { /// 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 - /// positioning. + /// The global (line, col) where the focused component drew its cursor + /// marker this frame, or null when no focused component emitted a marker. + /// Set by `writeLineNoNewline` as content is written; consumed by + /// `positionHardwareCursor` after content for IME anchoring (§3.5). cursor_hint: ?struct { line: usize, col: usize } = null, + /// The global row the HARDWARE cursor was actually left on after the last + /// frame finished. This is the differential render path's source of truth + /// for its next up-move, REPLACING the old assumption that the cursor + /// always rests at `total_lines - 1` (end of content). + /// + /// Why this exists (the rest-row reconciliation, §3.5): writing a frame's + /// content leaves the cursor at the end of the last content line + /// (`total_lines - 1`). But P3 then MOVES the cursor for IME anchoring to + /// the virtual-cursor position (`cursor_hint`), which is usually NOT the + /// last content row. The next differential frame must compute its up-move + /// from wherever the cursor truly is, not from a stale + /// "rest-at-end-of-content" assumption — otherwise every frame following a + /// cursor reposition would be off by `(last_content_row - hint_row)` rows. + /// So each frame records here the row it actually left the cursor on, and + /// `differential` reads it. + /// + /// Initialized to 0; meaningful only after the first render. + hw_cursor_row: usize = 0, + pub fn init( alloc: std.mem.Allocator, writer: *std.Io.Writer, @@ -479,6 +498,20 @@ pub const Engine = struct { try self.differential(frames, cut, new_total); } + // Position the hardware cursor for IME anchoring (§3.5), still INSIDE + // the synchronized-output block so it composites atomically with the + // frame. This also records `hw_cursor_row`, the rest row the next + // differential frame measures its move from. + // + // The no-op differential fast path (`cut == null`) returns early + // WITHOUT writing or moving the cursor, so the cursor stays where the + // previous frame left it and `hw_cursor_row` already reflects that — + // we must not recompute it from `new_total` in that case, or we would + // clobber a valid IME position with end-of-content. Detect the no-op + // frame (differential path with no cut) and skip repositioning. + const was_noop = !full and cut == null; + if (!was_noop) try self.positionHardwareCursor(new_total); + try self.endFrame(); // 4. Commit: store each component's new lines as the next baseline and @@ -605,20 +638,42 @@ pub const Engine = struct { return; }; - // Cursor-resting invariant: after the previous frame the hardware - // cursor rests at the END of the last content line (global row - // `prev_total - 1`), because the last line is written without a - // trailing newline. To reach the cut line we move up by - // `(prev_total - 1) - cut_line`, then carriage-return to column 0. - const prev_total = self.total_lines; - const last_row = if (prev_total == 0) 0 else prev_total - 1; - const up = last_row - @min(cut_line, last_row); - if (up > 0) try self.cursorUp(up); + // Reach the cut line from wherever the cursor was ACTUALLY left after + // the previous frame. Historically that was assumed to be the end of + // the last content line (`total_lines - 1`); since P3 may reposition + // the cursor for IME anchoring after writing content, the true row is + // tracked in `hw_cursor_row` (see its doc-comment for the rest-row + // reconciliation). We move up by `hw_cursor_row - cut_line`, then + // carriage-return to column 0. + // + // If the cut is BELOW the cursor's current row (possible only when the + // prior frame parked the cursor above the change, e.g. the virtual + // cursor sat on an earlier line than a now-dirty footer), we must move + // DOWN instead. `cursorDown` covers that case; the common path is a + // pure up-move. + const start_row = self.hw_cursor_row; + if (cut_line < start_row) { + try self.cursorUp(start_row - cut_line); + } else if (cut_line > start_row) { + try self.cursorDown(cut_line - start_row); + } try self.writer.writeAll(terminal.seq.carriage_return); // Clear from the cut downward; this also handles orphaned trailing // lines when the content shrank (plan §3.3 step 4). try self.writer.writeAll(terminal.seq.clear_to_end); + // Cursor-hint persistence across a PARTIAL repaint. `differential` only + // re-scans lines from `cut_line` down, so a focused component whose + // marker line sits ABOVE the cut is not re-emitted this frame — its + // marker is still on screen, unchanged, from a prior frame. If we blindly + // reset the hint to null, the cursor would wrongly jump to end-of-content + // whenever an unrelated region below the cursor changes (e.g. a footer + // ticking under a pinned input box). So we remember the prior hint, + // reset, let the scan overwrite it if the marker is in the repainted + // region, and otherwise RESTORE it when the marker line is above the cut + // (hence still displayed). A full redraw re-scans every line, so it + // resets the hint unconditionally (see `fullRedraw`). + const prev_hint = self.cursor_hint; self.cursor_hint = null; var global_line: usize = 0; var first = true; @@ -632,6 +687,14 @@ pub const Engine = struct { global_line += 1; } } + // The scan did not find a marker (cursor_hint still null), but the + // previous frame's marker line is above the repainted region and thus + // still on screen: keep using it so the hardware cursor stays anchored. + if (self.cursor_hint == null) { + if (prev_hint) |h| { + if (h.line < cut_line) self.cursor_hint = h; + } + } _ = new_total; } @@ -655,11 +718,90 @@ pub const Engine = struct { } fn cursorUp(self: *Engine, n: usize) Error!void { + if (n == 0) return; var buf: [16]u8 = undefined; const s = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return; try self.writer.writeAll(s); } + fn cursorDown(self: *Engine, n: usize) Error!void { + if (n == 0) return; + var buf: [16]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "\x1b[{d}B", .{n}) catch return; + try self.writer.writeAll(s); + } + + fn cursorForward(self: *Engine, n: usize) Error!void { + if (n == 0) return; + var buf: [16]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "\x1b[{d}C", .{n}) catch return; + try self.writer.writeAll(s); + } + + /// Position the HARDWARE cursor for IME anchoring (plan §3.5), and record + /// the row it was left on for the next differential frame. + /// + /// Called once per frame AFTER content is written and BEFORE `endFrame` + /// (so the move composites atomically inside the synchronized-output + /// block). On entry the cursor rests at the end of the last content line + /// (`last_content_row`, at that line's visible width), per the + /// cursor-resting invariant. + /// + /// When a focused component emitted `CURSOR_MARKER` this frame, + /// `cursor_hint` holds the marker's global (line, col). We move the cursor + /// there with RELATIVE moves only — never absolute CUP rows, because the + /// no-alt-screen scrolling model makes absolute row numbers unstable: + /// 1. vertical: up/down from `last_content_row` to `cursor_hint.line`. + /// A vertical move preserves the column, so afterward we are on the + /// target row but at the old column. + /// 2. carriage-return to column 0, then cursor-forward by + /// `cursor_hint.col`. Cursor-forward (`\x1b[<n>C`) moves without + /// writing glyphs, so it never overwrites the already-painted line + /// (the virtual reverse-video cursor block the InputBox drew stays + /// intact — the two coexist: the styled block is what the user sees, + /// the hardware cursor is moved to the same cell for the IME). + /// + /// The cursor is left HIDDEN (the session hides it globally; see + /// `runLoop`). Most terminals anchor an IME candidate popup to the cursor + /// POSITION regardless of visibility, and the InputBox already draws the + /// visible block, so showing a second hardware caret over it would be + /// redundant and distracting. We therefore position-but-hide. + /// + /// IME CAVEAT [verify]: anchoring an IME popup to a HIDDEN, repositioned + /// hardware cursor is implemented here but UNVERIFIED against a real IME + /// (CJK / accent / emoji composition). If a terminal turns out to ignore a + /// hidden cursor for IME placement, the fix is to show the cursor here; + /// that is a one-line policy change and does not affect the positioning + /// math. The mechanism (marker -> strip -> relative move) is what §3.5 + /// specifies; the popup-follows-hidden-cursor assumption is the unverified + /// part. + /// + /// `last_content_row` is `total_lines - 1` (0 when empty). After this + /// runs, `hw_cursor_row` records the row the cursor truly rests on, which + /// `differential` reads next frame for its up/down move. + fn positionHardwareCursor(self: *Engine, new_total: usize) Error!void { + const last_content_row: usize = if (new_total == 0) 0 else new_total - 1; + + if (self.cursor_hint) |hint| { + // Clamp defensively: the marker is within content, so the hint row + // should never exceed the last content row, but guard anyway. + const target_row = @min(hint.line, last_content_row); + if (target_row < last_content_row) { + try self.cursorUp(last_content_row - target_row); + } else if (target_row > last_content_row) { + try self.cursorDown(target_row - last_content_row); + } + try self.writer.writeAll(terminal.seq.carriage_return); + try self.cursorForward(hint.col); + self.hw_cursor_row = target_row; + } else { + // No focused marker: leave the cursor at end-of-content (where the + // content write already parked it). Record that row so the next + // differential frame computes its move correctly. + self.hw_cursor_row = last_content_row; + } + } + /// Recompute `viewport_top`: lines beyond the terminal height have scrolled /// into native scrollback and are off-limits to future differential /// updates. The bottom `height` lines remain addressable. @@ -1250,6 +1392,416 @@ test "cursor marker is stripped from output and recorded as a hint" { try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.col); } +test "cursor_hint repositions the hardware cursor to (line,col) with relative moves" { + // A single-line frame with the marker at column 2. After writing content + // the cursor rests at end-of-content (row 0, col 4). The hint is on the + // SAME row (0), so there is no vertical move: just CR to column 0 then + // cursor-forward by 2. The emitted tail must be `\r\x1b[2C`. + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{&.{"ab" ++ CURSOR_MARKER ++ "cd"}}, + .first_changed = &.{0}, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + const out = buf.written(); + // Same row => no vertical move escape. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") == null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1B") == null); + // CR + cursor-forward(2) places the cursor at the marker column. + try testing.expect(std.mem.indexOf(u8, out, "\r\x1b[2C") != null); + // The engine recorded the row it left the cursor on. + try testing.expectEqual(@as(usize, 0), eng.hw_cursor_row); +} + +test "cursor_hint on a non-last line moves up then forward" { + // Three lines, marker on the MIDDLE line (global row 1) at column 1. After + // content the cursor rests at row 2 (end of "l2"). To reach the marker: + // up 1 row (`\x1b[1A`), CR, forward 1 (`\x1b[1C`). + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{&.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }}, + .first_changed = &.{0}, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + const out = buf.written(); + try testing.expect(eng.cursor_hint != null); + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.col); + // Up 1, then CR + forward 1. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") != null); + try testing.expect(std.mem.indexOf(u8, out, "\r\x1b[1C") != null); + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); +} + +test "no-marker frame parks cursor at end-of-content and does not misposition" { + // Without a marker, hw_cursor_row must equal the last content row and no + // IME cursor-forward escape is emitted on a fresh, column-0 baseline. + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 24); + defer eng.deinit(); + + var body = FakeComponent{ + .scripts = &.{&.{ "l0", "l1", "l2" }}, + .first_changed = &.{0}, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + try testing.expect(eng.cursor_hint == null); + // Cursor parked at the last content row (row 2 of a 3-line frame). + try testing.expectEqual(@as(usize, 2), eng.hw_cursor_row); +} + +test "rest-row reconciliation: a marker frame followed by a differential frame patches the right lines" { + // THE acceptance-critical invariant. Frame 1 emits a marker on the MIDDLE + // line, so the engine parks the hardware cursor on row 1 (not the + // end-of-content row 2). Frame 2 is a differential frame that changes the + // TAIL (row 2). The differential up/down math must start from the ACTUAL + // cursor row (1, via hw_cursor_row), not the stale + // "rest-at-end-of-content" assumption (2) — otherwise it would mis-target + // the patched region. + // + // With reconciliation: cut == 2, start_row == 1 => move DOWN 1 (`\x1b[1B`), + // CR, reprint the tail. The unchanged upper lines (l0, the marker line) + // must NOT be reprinted. + 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 = &.{ + &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }, + &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "L2" }, // only the tail changes + }, + .first_changed = &.{ 0, 2 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); // frame 1: marker parks cursor on row 1 + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); + + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // frame 2: differential tail change + + const out = buf.written(); + // Stayed differential (no full clear). + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) == null); + // The cursor started on row 1 and the cut is row 2 => a DOWN move, proving + // the reconciliation used hw_cursor_row (1), not total_lines-1 (2). + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1B") != null); + // The changed tail is reprinted. + try testing.expect(std.mem.indexOf(u8, out, "L2") != null); + // The untouched first line is NOT reprinted (it is above the cut). If the + // reconciliation were wrong, the engine would mis-position and the diff + // region would be corrupted. + try testing.expect(std.mem.indexOf(u8, out, "l0") == null); + // And the marker frame re-parks the cursor on row 1 again. + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); +} + +test "cursor reconciliation: MULTIPLE differential frames after a marker frame" { + // Stress the rest-row reconciliation across a SEQUENCE of differential + // frames (not just one). The cursor marker sits on the middle line and + // never moves; only the tail line changes each frame. Every frame the + // engine must (1) start its diff move from hw_cursor_row, (2) re-park the + // cursor on the marker row, so hw_cursor_row stays pinned to row 1 across + // all frames and the upper lines are never reprinted. + 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", "a" ++ CURSOR_MARKER ++ "b", "t0" }, + &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "t1" }, + &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "t2" }, + &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "t3" }, + }, + .first_changed = &.{ 0, 2, 2, 2 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); // first paint, marker parks cursor on row 1 + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); + + // Three successive differential tail changes. + inline for (.{ "t1", "t2", "t3" }) |tail| { + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + const out = buf.written(); + // Each frame: cursor was on row 1, cut is row 2 => DOWN 1 to reach it. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1B") != null); + // The new tail is painted; the marker line and l0 are not. + try testing.expect(std.mem.indexOf(u8, out, tail) != null); + try testing.expect(std.mem.indexOf(u8, out, "l0") == null); + // Re-parked on the marker row. + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); + // Hint preserved (marker line above the cut => restored). + try testing.expect(eng.cursor_hint != null); + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); + } +} + +test "cursor reconciliation: marker MOVES between frames (user editing)" { + // The user types/moves the caret: the marker shifts column and row across + // frames. Because the marker is part of the diffed line bytes, a move + // changes that line, rolls the cut to it, and the scan re-derives the + // fresh hint. hw_cursor_row and the emitted forward-distance must track the + // new position each frame. + 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 = &.{ + // Frame 1: marker after "ab" on row 1 (col 2). + &.{ "hdr", "ab" ++ CURSOR_MARKER, "ftr" }, + // Frame 2: caret moved left to col 1 (marker between a and b). + &.{ "hdr", "a" ++ CURSOR_MARKER ++ "b", "ftr" }, + // Frame 3: caret moved up to the header row (row 0, col 3). + &.{ "hdr" ++ CURSOR_MARKER, "ab", "ftr" }, + }, + .first_changed = &.{ 0, 1, 0 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); // marker col 2 on row 1 + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); + try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.col); + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); + + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // marker now col 1, still row 1 + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.col); + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); + // CR + forward 1 places the caret. + try testing.expect(std.mem.indexOf(u8, buf.written(), "\r\x1b[1C") != null); + + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // marker jumps UP to row 0, col 3 + try testing.expectEqual(@as(usize, 0), eng.cursor_hint.?.line); + try testing.expectEqual(@as(usize, 3), eng.cursor_hint.?.col); + try testing.expectEqual(@as(usize, 0), eng.hw_cursor_row); +} + +test "cursor reconciliation: marker DISAPPEARS (focus lost) clears the hint" { + // When a focused component loses focus it stops emitting the marker. That + // is a byte change on the marker line, so the diff rolls the cut to that + // line, the scan finds no marker, and the hint must drop to null (the + // cursor falls back to end-of-content). This guards the stale-hint bug: + // the above-the-cut restoration must NOT resurrect a marker that no longer + // exists, because a vanished marker can only appear via a line change that + // forces re-scan. + 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", "a" ++ CURSOR_MARKER ++ "b", "l2" }, + &.{ "l0", "ab", "l2" }, // marker gone on row 1 + }, + .first_changed = &.{ 0, 1 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); + try testing.expect(eng.cursor_hint != null); + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); + + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + // The hint is cleared; the cursor parks at end-of-content (row 2). + try testing.expect(eng.cursor_hint == null); + try testing.expectEqual(@as(usize, 2), eng.hw_cursor_row); +} + +test "cursor reconciliation: marker BELOW the cut is re-scanned and re-derived" { + // The complement of the above-cut restoration: an UPPER line changes (cut + // rolls high) while the marker sits on a LOWER line. That lower line is + // within the repainted region (>= cut), so the scan re-derives the hint + // from fresh bytes rather than the above-cut restoration branch. The caret + // must still land on its (unchanged) row/col. + 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 = &.{ + &.{ "h0", "mid", "x" ++ CURSOR_MARKER ++ "y" }, // marker on row 2 + &.{ "H0", "mid", "x" ++ CURSOR_MARKER ++ "y" }, // only row 0 changes + }, + .first_changed = &.{ 0, 0 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); + try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.line); + try testing.expectEqual(@as(usize, 2), eng.hw_cursor_row); // parked on the marker row + + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // cut == 0 (row 0 changed); marker row 2 is BELOW the cut + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, "H0") != null); // changed upper line repainted + // Marker re-derived from the fresh scan of row 2. + try testing.expect(eng.cursor_hint != null); + try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.line); + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.col); // after "x" + try testing.expectEqual(@as(usize, 2), eng.hw_cursor_row); +} + +test "cursor reconciliation: marker exactly AT the cut is re-derived, not stale" { + // The marker line is precisely the cut line. It IS re-scanned (cut is + // inclusive), so the hint comes from the fresh scan, never the above-cut + // restoration branch (which only fires for h.line < cut_line). + 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", "x" ++ CURSOR_MARKER ++ "y", "l2" }, + // Row 1 changes (marker shifts to col 2) and is the cut. + &.{ "l0", "xy" ++ CURSOR_MARKER, "l2" }, + }, + .first_changed = &.{ 0, 1 }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); + try testing.expect(eng.cursor_hint != null); + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); + try testing.expectEqual(@as(usize, 2), eng.cursor_hint.?.col); // after "xy" + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); +} + +test "cursor reconciliation: full redraw (clear) between marker frames resets cleanly" { + // A resize forces a full clear+redraw between marker frames. fullRedraw + // re-scans every line, so the hint is re-derived from scratch and the + // hardware cursor is re-anchored without any stale carry-over. + 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", "a" ++ CURSOR_MARKER ++ "b", "l2" }, + &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }, + }, + .first_changed = &.{ 0, null }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); + + body.advance(); + buf.clearRetainingCapacity(); + eng.resize(70, 100); // width change => full clear + redraw + try eng.render(); + + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, terminal.seq.full_clear) != null); + // Hint re-derived from the full re-scan, cursor re-anchored on row 1. + try testing.expect(eng.cursor_hint != null); + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.line); + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); +} + +test "cursor reconciliation: no-op frame preserves the prior IME position" { + // A differential frame with no change (cut == null) returns early WITHOUT + // moving the cursor. hw_cursor_row and cursor_hint must survive untouched + // so the IME stays anchored where the previous marker frame left 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", "a" ++ CURSOR_MARKER ++ "b", "l2" }, + &.{ "l0", "a" ++ CURSOR_MARKER ++ "b", "l2" }, // identical + }, + .first_changed = &.{ 0, null }, + }; + try eng.addComponent(body.comp()); + + try eng.render(); + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); + const hint_before = eng.cursor_hint; + + body.advance(); + buf.clearRetainingCapacity(); + try eng.render(); // no-op frame + + // No cursor-move escapes emitted (the frame is a sync-wrapped no-op). + const out = buf.written(); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1A") == null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1B") == null); + // State preserved. + try testing.expectEqual(@as(usize, 1), eng.hw_cursor_row); + try testing.expect(eng.cursor_hint != null); + try testing.expectEqual(hint_before.?.line, eng.cursor_hint.?.line); + try testing.expectEqual(hint_before.?.col, eng.cursor_hint.?.col); +} + +test "cursor reconciliation: marker with content scrolled into scrollback (viewport_top)" { + // With a short viewport, upper content scrolls into native scrollback + // (viewport_top > 0). A marker on a still-visible line must still anchor + // correctly: the hint row is a GLOBAL row, and positionHardwareCursor moves + // relative to last_content_row, so the math is independent of viewport_top. + var buf = std.Io.Writer.Allocating.init(testing.allocator); + defer buf.deinit(); + var eng = makeEngine(&buf, 80, 3); // height 3 + defer eng.deinit(); + + // 5 lines, marker on row 3 (visible: rows 2..4 are the bottom 3). + var body = FakeComponent{ + .scripts = &.{&.{ "l0", "l1", "l2", "m" ++ CURSOR_MARKER, "l4" }}, + .first_changed = &.{0}, + }; + try eng.addComponent(body.comp()); + try eng.render(); + + try testing.expect(eng.viewport_top > 0); + try testing.expectEqual(@as(usize, 3), eng.cursor_hint.?.line); + try testing.expectEqual(@as(usize, 1), eng.cursor_hint.?.col); // after "m" + // last_content_row is 4; marker on row 3 => up 1 to reach it. + try testing.expect(std.mem.indexOf(u8, buf.written(), "\x1b[1A") != null); + try testing.expectEqual(@as(usize, 3), eng.hw_cursor_row); +} + test "every frame is wrapped in synchronized output when enabled" { var buf = std.Io.Writer.Allocating.init(testing.allocator); defer buf.deinit(); |
