summaryrefslogtreecommitdiff
path: root/src/tui_engine.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-09 00:00:56 -0600
committert <t@tjp.lol>2026-06-09 08:52:47 -0600
commit97b10466d83d488ad2cb9e084159a445af74c845 (patch)
tree6bea7c23d00ff1178a43e631fc700f210cb7d85b /src/tui_engine.zig
parent104001d25c9c8cb5ec45ced1678f7c7b70888808 (diff)
event lifecycle
Diffstat (limited to 'src/tui_engine.zig')
-rw-r--r--src/tui_engine.zig578
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();