From bd67a1d31f4a191a457554c1ebeedcafc1d2d2b8 Mon Sep 17 00:00:00 2001 From: gcgbarbosa Date: Tue, 25 Aug 2026 22:34:53 +0200 Subject: [PATCH 1/4] fix(frame): Clear the rows the previous frame used and the current one does not. --- src/terminal/frame.zig | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/terminal/frame.zig b/src/terminal/frame.zig index 6798fe9..b399753 100644 --- a/src/terminal/frame.zig +++ b/src/terminal/frame.zig @@ -84,7 +84,7 @@ pub const Renderer = struct { const line_count = if (use_diff) try self.writeDiff(writer, view, size) else - try self.writeFull(writer, view); + try self.writeFull(writer, view, size); try writer.writeAll(ansi.sync_end); self.remember(view, line_count, hash); @@ -111,26 +111,21 @@ pub const Renderer = struct { self.dirty = false; } - /// Rewrite the whole frame from the top-left corner. - fn writeFull(self: *Renderer, writer: *Writer, view: []const u8) !usize { - try writer.writeAll(ansi.cursor_home); - + fn writeFull(self: *Renderer, writer: *Writer, view: []const u8, size: Size) !usize { var lines = std.mem.splitScalar(u8, view, '\n'); - var first = true; var line_count: usize = 0; while (lines.next()) |line| { - if (!first) try writer.writeAll("\r\n"); - first = false; - try writer.writeAll(line); - try writer.writeAll(ansi.line_clear_right); + try ansi.cursorTo0(writer, @intCast(line_count), 0); line_count += 1; + try writer.writeAll(line); + if (measure.width(line) < size.width) { + try writer.writeAll(ansi.line_clear_right); + } } - - // Clear the rows the previous frame used and this one does not. if (self.last_line_count > line_count) { - var remaining = self.last_line_count - line_count; - while (remaining > 0) : (remaining -= 1) { - try writer.writeAll("\r\n"); + var row = line_count; + while (row < self.last_line_count and row < size.height) : (row += 1) { + try ansi.cursorTo0(writer, @intCast(row), 0); try writer.writeAll(ansi.line_clear); } } @@ -158,7 +153,12 @@ pub const Renderer = struct { try ansi.cursorTo0(writer, row, 0); try writer.writeAll(line); - try writer.writeAll(ansi.line_clear_right); + // Mirror writeFull: a full-width line has nothing after it to + // erase, and the erase would destroy its last character in + // terminals with wrap-pending semantics. + if (measure.width(line) < size.width) { + try writer.writeAll(ansi.line_clear_right); + } } // Clear the rows the previous frame used and this one does not. From 55fe92d59293fffb66974f572bd5a7c469d9fd2b Mon Sep 17 00:00:00 2001 From: gcgbarbosa Date: Tue, 25 Aug 2026 23:16:11 +0200 Subject: [PATCH 2/4] test(frame): test the correct bytes when redrawing writer.writeAll(ansi.cursor_home) prints the byte \x1b[H (go to row 1, column 1 but ommit params) I changed it to: ansi.cursorTo0(writer, @intCast(line_count), 0) which prints the byte \x1b[1;1H (go to row 1, column 1, exclicit params) (it actually prints \x1b[{row+1};1H, but you get the idea) --- tests/render_tests.zig | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/render_tests.zig b/tests/render_tests.zig index bd066d2..32b8602 100644 --- a/tests/render_tests.zig +++ b/tests/render_tests.zig @@ -174,7 +174,7 @@ test "first frame is painted in full" { const out = try h.render("alpha\nbeta\ngamma", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); try h.expectScreen("alpha\nbeta\ngamma"); } @@ -315,7 +315,7 @@ test "a line wider than the terminal falls back to a full repaint" { // Wrapping shifts every row below it, so absolute addressing is off the // table: the whole frame is rewritten from home. - try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); try testing.expect(std.mem.indexOf(u8, out, "short") != null); } @@ -327,11 +327,11 @@ test "a frame taller than the terminal falls back to a full repaint" { _ = try h.render("a\nb\nc", short); const out = try h.render("a\nb\nc\nd", short); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); // The frame scrolled, so the one after it cannot be diffed either. const next = try h.render("a\nb\nc\ne", short); - try testing.expect(std.mem.indexOf(u8, next, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, next, "\x1b[1;1H") != null); } test "a style left open disables diffing for the following frame" { @@ -343,7 +343,7 @@ test "a style left open disables diffing for the following frame" { _ = try h.render("\x1b[41mred background\nstill red", size_80x24); const out = try h.render("\x1b[41mred background\nSTILL RED", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); try testing.expect(std.mem.indexOf(u8, out, "red background") != null); } @@ -354,7 +354,7 @@ test "styles closed per line still diff" { _ = try h.render("\x1b[31mred\x1b[0m\n\x1b[1mbold\x1b[0m plain", size_80x24); const out = try h.render("\x1b[31mred\x1b[0m\n\x1b[1mbold\x1b[0m PLAIN", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") == null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") == null); try testing.expect(std.mem.indexOf(u8, out, "PLAIN") != null); } @@ -367,7 +367,7 @@ test "truecolor sequences do not read as a reset" { _ = try h.render("\x1b[38;2;255;0;0mred\nsecond", size_80x24); const out = try h.render("\x1b[38;2;255;0;0mred\nSECOND", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); } test "invalidate forces a repaint of an unchanged view" { @@ -381,7 +381,7 @@ test "invalidate forces a repaint of an unchanged view" { try testing.expect(h.renderer.needsRepaint()); const out = try h.render("stable\nframe", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); try testing.expect(!h.renderer.needsRepaint()); } @@ -392,7 +392,7 @@ test "full mode always rewrites every line" { _ = try h.render("alpha\nbeta\ngamma", size_80x24); const out = try h.render("alpha\nBETA\ngamma", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); try testing.expect(std.mem.indexOf(u8, out, "alpha") != null); try testing.expect(std.mem.indexOf(u8, out, "gamma") != null); } @@ -420,7 +420,7 @@ test "wide characters are measured by display width, not bytes" { _ = try h.render("日本語だ\nplain", narrow); const out = try h.render("日本語だ\nPLAIN", narrow); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") == null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") == null); } test "empty view is handled" { From f121b8b4b7deed51be333c71b7caa79cb78e0da4 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 28 Aug 2026 13:40:15 +0200 Subject: [PATCH 3/4] fix(frame): bound row addressing, and erase when the width is unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressing rows absolutely removes the scroll that #143 reported, and skipping `EL` on a line that fills the row removes the erased border that #144 reported. Both need bounds the original change did not apply. A `CUP` row is a `u16`, and the terminal clamps it to the screen: * A view of 65536 lines or more overflowed the row counter and panicked. `writeFull` is the path that absorbs oversized frames, so the crash sat on exactly the input it exists to handle, and `.full` mode never bounds the line count at all. * Rows past the bottom clamped onto the last row, so each overflow line overwrote the one before it: six lines on a three-row screen left `a`, `b`, `f` rather than the top three. Count those lines, so `last_line_count` still describes the frame, but do not draw them. `writeDiff` bounds its trailing clear the same way. It cannot reach a `last_line_count` past the screen today — an oversized frame marks the renderer dirty, which routes the next frame through `writeFull` — but two paths clearing to different limits is a trap, not an invariant. The erase predicate needs two corrections of its own, both in `fillsRow`: * `width < size.width` is never true when the width is zero, which suppressed every erase and left stale text on screen. `TIOCGWINSZ` reports 0x0 for a terminal whose window size was never set, and the 80x24 fallback in `getSize` only covers the non-TTY case. An unknown width says nothing about which lines fill a row, so erase. * A line that wraps returns to the edge at every multiple of the row width, not only when it fills one row exactly. Ten columns of text on a five-column screen parks the cursor on the edge a second time, where `EL` eats the last character just the same. Test the width modulo the row rather than against it. --- src/terminal/frame.zig | 100 ++++++++++++++++++++++++++++++++++------- 1 file changed, 83 insertions(+), 17 deletions(-) diff --git a/src/terminal/frame.zig b/src/terminal/frame.zig index b399753..eb49017 100644 --- a/src/terminal/frame.zig +++ b/src/terminal/frame.zig @@ -111,23 +111,35 @@ pub const Renderer = struct { self.dirty = false; } + /// Rewrite the whole frame, addressing every row absolutely. + /// + /// Absolute addressing rather than `\r\n` between lines: a newline on the + /// bottom row scrolls the screen, which silently shifts every row of the + /// frame up by one and leaves the diff path writing to the wrong rows from + /// then on. fn writeFull(self: *Renderer, writer: *Writer, view: []const u8, size: Size) !usize { + const max_row = addressableRows(size); + var lines = std.mem.splitScalar(u8, view, '\n'); var line_count: usize = 0; while (lines.next()) |line| { + defer line_count += 1; + // A row past the bottom cannot be addressed — the terminal clamps + // the cursor to the last row, so every line beyond it would land + // on top of the one before. Count them so `last_line_count` still + // describes the frame, but do not draw them. + if (line_count >= max_row) continue; + try ansi.cursorTo0(writer, @intCast(line_count), 0); - line_count += 1; try writer.writeAll(line); - if (measure.width(line) < size.width) { - try writer.writeAll(ansi.line_clear_right); - } + if (!fillsRow(line, size)) try writer.writeAll(ansi.line_clear_right); } - if (self.last_line_count > line_count) { - var row = line_count; - while (row < self.last_line_count and row < size.height) : (row += 1) { - try ansi.cursorTo0(writer, @intCast(row), 0); - try writer.writeAll(ansi.line_clear); - } + + // Clear the rows the previous frame used and this one does not. + var row = line_count; + while (row < self.last_line_count and row < max_row) : (row += 1) { + try ansi.cursorTo0(writer, @intCast(row), 0); + try writer.writeAll(ansi.line_clear); } return line_count; @@ -153,17 +165,12 @@ pub const Renderer = struct { try ansi.cursorTo0(writer, row, 0); try writer.writeAll(line); - // Mirror writeFull: a full-width line has nothing after it to - // erase, and the erase would destroy its last character in - // terminals with wrap-pending semantics. - if (measure.width(line) < size.width) { - try writer.writeAll(ansi.line_clear_right); - } + if (!fillsRow(line, size)) try writer.writeAll(ansi.line_clear_right); } // Clear the rows the previous frame used and this one does not. var row = line_count; - while (row < self.last_line_count) : (row += 1) { + while (row < self.last_line_count and row < addressableRows(size)) : (row += 1) { try ansi.cursorTo0(writer, @intCast(row), 0); try writer.writeAll(ansi.line_clear); } @@ -182,6 +189,36 @@ pub const Renderer = struct { } }; +/// How many rows can be addressed with `CUP`, which carries a `u16` row. +/// +/// A height of zero means the size is not known — `TIOCGWINSZ` reports it that +/// way on a terminal whose window size has never been set. Fall back to the +/// widest row the sequence can express so an oversized view cannot overflow +/// the row counter. +fn addressableRows(size: Size) usize { + return if (size.height > 0) size.height else std.math.maxInt(u16); +} + +/// Whether writing `line` leaves the cursor parked at the far edge of a row. +/// +/// A terminal that has just filled the last column keeps the cursor on it with +/// the wrap deferred until the next character arrives. `EL` erases from the +/// column the cursor sits on, so issuing it in that state destroys the +/// character that was just written — the right-hand border of a frame drawn to +/// the full width of the screen. Nothing follows such a line on its row, so +/// the erase is skipped rather than fixed up. +/// +/// A line long enough to wrap lands on the edge whenever its width is an exact +/// multiple of the row width, not only when it fills a single row. +fn fillsRow(line: []const u8, size: Size) bool { + // Size unknown: erase, because leaving stale text on screen is the worse + // of the two failures. + if (size.width == 0) return false; + + const w = measure.width(line); + return w > 0 and w % size.width == 0; +} + /// What a frame looks like on screen, as far as the diff path cares. const Shape = struct { /// Every line fits on one row and the frame fits on screen, so row `n` of @@ -384,6 +421,35 @@ test "leavesStyleOpen: hyperlinks" { try std.testing.expect(!leavesStyleOpen("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\")); } +test "fillsRow: a line that ends on the row edge" { + const size = Size{ .width = 5, .height = 4 }; + + try std.testing.expect(!fillsRow("", size)); + try std.testing.expect(!fillsRow("abc", size)); + try std.testing.expect(fillsRow("abcde", size)); + + // A line long enough to wrap comes back to the edge at every multiple of + // the row width, not only on the first row it fills. + try std.testing.expect(!fillsRow("abcdefgh", size)); + try std.testing.expect(fillsRow("abcdefghij", size)); + + // Escape sequences occupy no columns; double-width characters occupy two. + try std.testing.expect(fillsRow("\x1b[31mabcde\x1b[0m", size)); + try std.testing.expect(!fillsRow("日本", size)); + try std.testing.expect(fillsRow("日本語だよ", size)); + + // An unknown width cannot rule the erase out. + try std.testing.expect(!fillsRow("abcde", .{ .width = 0, .height = 0 })); +} + +test "addressableRows: an unknown height falls back to the sequence limit" { + try std.testing.expectEqual(@as(usize, 24), addressableRows(.{ .width = 80, .height = 24 })); + try std.testing.expectEqual( + @as(usize, std.math.maxInt(u16)), + addressableRows(.{ .width = 80, .height = 0 }), + ); +} + test "scan: frame geometry" { const size = Size{ .width = 10, .height = 3 }; From 711db24de15dbea858b72b0ca20adb657d642873 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 28 Aug 2026 13:40:15 +0200 Subject: [PATCH 4/4] test(frame): model autowrap, and assert repaints by content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps let the renderer's own regressions through. `VirtualScreen` counted bytes, not columns, and knew nothing of the screen it was modelling. It could not represent the bug behind #144 at all: a character written to the last column parks the cursor there with its wrap deferred, and `EL` erases from the column the cursor occupies — taking that character with it. A frame drawn to the full width of the screen modelled perfectly and still lost its right-hand border on a real terminal. Model columns, autowrap, wrap-pending and `CUP` clamping, so the harness can show what a terminal would. `\x1b[H` used to be proof of a full repaint, because only `writeFull` emitted it. Absolute addressing made it `\x1b[1;1H`, which `writeDiff` also emits whenever row 0 is the row that changed. Every rewritten assertion still discriminated by luck — each left row 0 untouched — but they no longer tested what their names claimed. Assert instead that a row which did *not* change was rewritten, which is the actual difference between the two paths. Covers what neither path had: a frame that fills the width, a wrapped line that ends on the edge, truncation past the bottom row, a view too tall for a `u16` row, and an unknown terminal width. --- tests/render_tests.zig | 296 +++++++++++++++++++++++++++++++++++------ 1 file changed, 253 insertions(+), 43 deletions(-) diff --git a/tests/render_tests.zig b/tests/render_tests.zig index 32b8602..af2eb02 100644 --- a/tests/render_tests.zig +++ b/tests/render_tests.zig @@ -15,16 +15,53 @@ const size_80x24 = Size{ .width = 80, .height = 24 }; /// Minimal terminal model: enough of the control sequences the renderer emits /// to reconstruct what would be on screen. +/// +/// The model counts display columns rather than bytes, and it reproduces the +/// two behaviours the renderer has to draw around: +/// +/// * autowrap — a character written to the last column parks the cursor on +/// that column with the wrap deferred, instead of moving it off the row; +/// * `EL` erases from the column the cursor occupies, which in that parked +/// state is the column holding the character just written. +/// +/// Both matter: without them a frame drawn to the full width of the screen +/// models perfectly and still loses its right-hand column on a real terminal. const VirtualScreen = struct { - rows: [64][256]u8, - used: usize, + const max_rows = 32; + const max_cols = 128; + + /// One display column. A double-width character owns two of them: the + /// first carries its bytes, the second is a continuation. + const Cell = struct { + bytes: [4]u8 = .{ ' ', 0, 0, 0 }, + len: u8 = 1, + continuation: bool = false, + + fn isBlank(self: Cell) bool { + return !self.continuation and self.len == 1 and self.bytes[0] == ' '; + } + }; + + cells: [max_rows][max_cols]Cell, + width: usize = max_cols, + height: usize = max_rows, + used: usize = 0, cursor_row: usize = 0, cursor_col: usize = 0, + /// Set once the last column is filled and the wrap is deferred. + wrap_pending: bool = false, fn init() VirtualScreen { - var self = VirtualScreen{ .rows = undefined, .used = 0 }; - for (&self.rows) |*row| @memset(row, ' '); - return self; + const blank_row = [_]Cell{.{}} ** max_cols; + return .{ .cells = [_][max_cols]Cell{blank_row} ** max_rows }; + } + + /// A reported size of zero means the terminal never told anyone how big it + /// is; model a screen wide enough that nothing wraps, which is the same + /// assumption the renderer falls back on. + fn resize(self: *VirtualScreen, size: Size) void { + self.width = if (size.width == 0) max_cols else @min(size.width, max_cols); + self.height = if (size.height == 0) max_rows else @min(size.height, max_rows); } fn apply(self: *VirtualScreen, output: []const u8) !void { @@ -39,21 +76,60 @@ const VirtualScreen = struct { if (c == '\r') { self.cursor_col = 0; + self.wrap_pending = false; i += 1; continue; } if (c == '\n') { self.cursor_row += 1; + self.wrap_pending = false; i += 1; continue; } - try testing.expect(self.cursor_row < self.rows.len); - try testing.expect(self.cursor_col < self.rows[0].len); - self.rows[self.cursor_row][self.cursor_col] = c; - self.cursor_col += 1; - self.used = @max(self.used, self.cursor_row + 1); - i += 1; + // One character, however many bytes and columns it occupies. + const byte_len = std.unicode.utf8ByteSequenceLength(c) catch 1; + const take = @min(byte_len, output.len - i); + const bytes = output[i..][0..take]; + const cols = if (std.unicode.utf8Decode(bytes)) |cp| + zz.unicode.charWidth(cp) + else |_| + 1; + try self.put(bytes, cols); + i += take; + } + } + + /// Write one character at the cursor and advance past it. + fn put(self: *VirtualScreen, bytes: []const u8, cols: usize) !void { + if (cols == 0) return; // A combining mark claims no column of its own. + + if (self.wrap_pending) { + self.cursor_row += 1; + self.cursor_col = 0; + self.wrap_pending = false; + } + // A double-width character that would straddle the edge wraps whole. + if (self.cursor_col + cols > self.width) { + self.cursor_row += 1; + self.cursor_col = 0; + } + + try testing.expect(self.cursor_row < max_rows); + + const row = &self.cells[self.cursor_row]; + row[self.cursor_col] = .{ .bytes = .{ 0, 0, 0, 0 }, .len = @intCast(bytes.len) }; + @memcpy(row[self.cursor_col].bytes[0..bytes.len], bytes); + for (1..cols) |k| row[self.cursor_col + k] = .{ .len = 0, .continuation = true }; + + self.used = @max(self.used, self.cursor_row + 1); + + self.cursor_col += cols; + if (self.cursor_col >= self.width) { + // The cursor stays on the last column; the wrap waits for the next + // character, and may never happen. + self.cursor_col = self.width - 1; + self.wrap_pending = true; } } @@ -71,21 +147,26 @@ const VirtualScreen = struct { switch (final) { 'H' => { - if (params.len == 0) { - self.cursor_row = 0; - self.cursor_col = 0; - } else { + var row: usize = 1; + var col: usize = 1; + if (params.len > 0) { var it = std.mem.splitScalar(u8, params, ';'); - const row = try std.fmt.parseInt(usize, it.next().?, 10); - const col = try std.fmt.parseInt(usize, it.next() orelse "1", 10); - self.cursor_row = row - 1; - self.cursor_col = col - 1; + row = try std.fmt.parseInt(usize, it.next().?, 10); + col = try std.fmt.parseInt(usize, it.next() orelse "1", 10); } + // `CUP` clamps to the screen. It never scrolls, which is the + // whole reason the renderer addresses rows this way. + self.cursor_row = @min(row -| 1, self.height - 1); + self.cursor_col = @min(col -| 1, self.width - 1); + self.wrap_pending = false; self.used = @max(self.used, self.cursor_row + 1); }, 'K' => { + // Deliberately leaves `wrap_pending` set: erasing from a + // parked cursor is precisely the case being modelled, and it + // takes the character under the cursor with it. const from = if (std.mem.eql(u8, params, "2")) 0 else self.cursor_col; - @memset(self.rows[self.cursor_row][from..], ' '); + for (self.cells[self.cursor_row][from..self.width]) |*cell| cell.* = .{}; self.used = @max(self.used, self.cursor_row + 1); }, // Style and synchronized-output sequences do not move the cursor @@ -96,18 +177,30 @@ const VirtualScreen = struct { return consumed; } + /// Columns up to and including the last non-blank one. + fn rowWidth(self: *const VirtualScreen, row: usize) usize { + var end: usize = 0; + for (self.cells[row][0..self.width], 0..) |cell, col| { + if (!cell.isBlank()) end = col + 1; + } + return end; + } + /// Visible content, trailing blanks trimmed, as a newline-joined string. fn text(self: *const VirtualScreen, out: *std.array_list.Managed(u8)) !void { out.clearRetainingCapacity(); var last: usize = 0; - for (0..self.used) |row| { - if (std.mem.trimEnd(u8, &self.rows[row], " ").len > 0) last = row + 1; + for (0..@min(self.used, self.height)) |row| { + if (self.rowWidth(row) > 0) last = row + 1; } for (0..last) |row| { if (row > 0) try out.append('\n'); - try out.appendSlice(std.mem.trimEnd(u8, &self.rows[row], " ")); + for (self.cells[row][0..self.rowWidth(row)]) |cell| { + if (cell.continuation) continue; + try out.appendSlice(cell.bytes[0..cell.len]); + } } } }; @@ -133,6 +226,7 @@ const Harness = struct { /// Renders one frame; returns the bytes it produced. fn render(self: *Harness, view: []const u8, size: Size) ![]const u8 { + self.screen.resize(size); self.out.clearRetainingCapacity(); _ = try self.renderer.render(&self.out.writer, view, size); const written = self.out.written(); @@ -148,6 +242,27 @@ const Harness = struct { } }; +/// A full repaint rewrites every row of the frame; the diff path only touches +/// rows whose content changed. Whether a row that did *not* change was written +/// is what tells the two apart. +/// +/// The leading cursor address does not: `writeDiff` emits `CSI 1;1H` too, +/// whenever row 0 happens to be the row that changed. Asserting on it would +/// pass for a diff that touched the top row. +fn expectRepainted(out: []const u8, unchanged_row: []const u8) !void { + if (std.mem.indexOf(u8, out, unchanged_row) == null) { + std.debug.print("expected a full repaint to rewrite '{s}'\n", .{unchanged_row}); + return error.TestExpectedFullRepaint; + } +} + +fn expectDiffed(out: []const u8, unchanged_row: []const u8) !void { + if (std.mem.indexOf(u8, out, unchanged_row) != null) { + std.debug.print("expected a diff to leave '{s}' alone\n", .{unchanged_row}); + return error.TestExpectedDiff; + } +} + fn countOccurrences(haystack: []const u8, needle: []const u8) usize { var count: usize = 0; var i: usize = 0; @@ -174,7 +289,7 @@ test "first frame is painted in full" { const out = try h.render("alpha\nbeta\ngamma", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); + for ([_][]const u8{ "alpha", "beta", "gamma" }) |row| try expectRepainted(out, row); try h.expectScreen("alpha\nbeta\ngamma"); } @@ -313,10 +428,9 @@ test "a line wider than the terminal falls back to a full repaint" { const out = try h.render("short\nthis line is far too wide to fit", narrow); - // Wrapping shifts every row below it, so absolute addressing is off the - // table: the whole frame is rewritten from home. - try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); - try testing.expect(std.mem.indexOf(u8, out, "short") != null); + // Wrapping shifts every row below it, so the frame can no longer be + // addressed by row: row 0 is rewritten even though it did not change. + try expectRepainted(out, "short"); } test "a frame taller than the terminal falls back to a full repaint" { @@ -324,14 +438,14 @@ test "a frame taller than the terminal falls back to a full repaint" { defer h.deinit(); const short = Size{ .width = 80, .height = 3 }; - _ = try h.render("a\nb\nc", short); + _ = try h.render("top\nmid\nbot", short); - const out = try h.render("a\nb\nc\nd", short); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); + const out = try h.render("top\nmid\nbot\nextra", short); + try expectRepainted(out, "top"); - // The frame scrolled, so the one after it cannot be diffed either. - const next = try h.render("a\nb\nc\ne", short); - try testing.expect(std.mem.indexOf(u8, next, "\x1b[1;1H") != null); + // The frame does not fit, so the one after it cannot be diffed either. + const next = try h.render("top\nmid\nbot\nother", short); + try expectRepainted(next, "top"); } test "a style left open disables diffing for the following frame" { @@ -343,8 +457,7 @@ test "a style left open disables diffing for the following frame" { _ = try h.render("\x1b[41mred background\nstill red", size_80x24); const out = try h.render("\x1b[41mred background\nSTILL RED", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); - try testing.expect(std.mem.indexOf(u8, out, "red background") != null); + try expectRepainted(out, "red background"); } test "styles closed per line still diff" { @@ -354,7 +467,7 @@ test "styles closed per line still diff" { _ = try h.render("\x1b[31mred\x1b[0m\n\x1b[1mbold\x1b[0m plain", size_80x24); const out = try h.render("\x1b[31mred\x1b[0m\n\x1b[1mbold\x1b[0m PLAIN", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") == null); + try expectDiffed(out, "\x1b[31mred"); try testing.expect(std.mem.indexOf(u8, out, "PLAIN") != null); } @@ -367,7 +480,7 @@ test "truecolor sequences do not read as a reset" { _ = try h.render("\x1b[38;2;255;0;0mred\nsecond", size_80x24); const out = try h.render("\x1b[38;2;255;0;0mred\nSECOND", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); + try expectRepainted(out, "\x1b[38;2;255;0;0mred"); } test "invalidate forces a repaint of an unchanged view" { @@ -381,7 +494,7 @@ test "invalidate forces a repaint of an unchanged view" { try testing.expect(h.renderer.needsRepaint()); const out = try h.render("stable\nframe", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); + try expectRepainted(out, "stable"); try testing.expect(!h.renderer.needsRepaint()); } @@ -392,9 +505,8 @@ test "full mode always rewrites every line" { _ = try h.render("alpha\nbeta\ngamma", size_80x24); const out = try h.render("alpha\nBETA\ngamma", size_80x24); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") != null); - try testing.expect(std.mem.indexOf(u8, out, "alpha") != null); - try testing.expect(std.mem.indexOf(u8, out, "gamma") != null); + try expectRepainted(out, "alpha"); + try expectRepainted(out, "gamma"); } test "every frame is wrapped in synchronized output" { @@ -420,7 +532,105 @@ test "wide characters are measured by display width, not bytes" { _ = try h.render("日本語だ\nplain", narrow); const out = try h.render("日本語だ\nPLAIN", narrow); - try testing.expect(std.mem.indexOf(u8, out, "\x1b[1;1H") == null); + try expectDiffed(out, "日本語だ"); +} + +test "a frame that fills the width keeps its last column" { + var h = Harness.init(.diff); + defer h.deinit(); + + // The right border sits on the last column, so the cursor is parked there + // with its wrap deferred. `EL` erases from the parked column and would + // take the border with it. + const exact = Size{ .width = 5, .height = 4 }; + _ = try h.render("┌───┐\n│ a │\n└───┘", exact); + try h.expectScreen("┌───┐\n│ a │\n└───┘"); + + // And again down the diff path, which repaints the middle row alone. + _ = try h.render("┌───┐\n│ b │\n└───┘", exact); + try h.expectScreen("┌───┐\n│ b │\n└───┘"); +} + +test "a full-width line is not followed by an erase" { + var h = Harness.init(.diff); + defer h.deinit(); + + const out = try h.render("abcde\nxy", Size{ .width = 5, .height = 4 }); + + try testing.expect(std.mem.indexOf(u8, out, "abcde\x1b[K") == null); + // A line with room to spare still gets one. + try testing.expect(std.mem.indexOf(u8, out, "xy\x1b[K") != null); +} + +test "a wrapped line that ends on a row edge keeps its last column" { + var h = Harness.init(.full); + defer h.deinit(); + + // Ten columns of text on a five-column screen wraps onto a second row and + // fills that one exactly, parking the cursor on the edge a second time. + // Landing there is a question of width modulo the row, not of fitting. + _ = try h.render("abcdefghij", Size{ .width = 5, .height = 4 }); + try h.expectScreen("abcde\nfghij"); +} + +test "a frame taller than the terminal is truncated, not smeared" { + var h = Harness.init(.full); + defer h.deinit(); + + const short = Size{ .width = 10, .height = 3 }; + const out = try h.render("a\nb\nc\nd\ne\nf", short); + + // Addressing row 4 would clamp onto row 3, so each overflow line would + // overwrite the one before it and the bottom row would end up showing the + // last line of the frame rather than the third. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[4;1H") == null); + try h.expectScreen("a\nb\nc"); +} + +test "a view with more rows than a cursor address can hold does not overflow" { + var h = Harness.init(.full); + defer h.deinit(); + + var view = std.array_list.Managed(u8).init(testing.allocator); + defer view.deinit(); + for (0..70_000) |_| try view.appendSlice("x\n"); + + // The row of a `CUP` sequence is a u16 and this view has more lines than + // one can count, so every row past the bottom has to be dropped before it + // reaches the sequence. + const out = try h.render(view.items, size_80x24); + + try testing.expect(std.mem.indexOf(u8, out, "\x1b[24;1H") != null); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[25;1H") == null); +} + +test "an unknown terminal width still erases stale text" { + var h = Harness.init(.diff); + defer h.deinit(); + + // `TIOCGWINSZ` reports 0x0 for a terminal whose window size was never set. + // Nothing can be concluded about which lines fill a row, so every line is + // erased rather than none of them. + const unknown = Size{ .width = 0, .height = 0 }; + _ = try h.render("stale-long-content\nsecond", unknown); + _ = try h.render("x\ny", unknown); + + try h.expectScreen("x\ny"); +} + +test "no frame is ever written with a newline" { + var h = Harness.init(.diff); + defer h.deinit(); + + // A newline on the bottom row scrolls the screen, which shifts every row + // of the frame up by one and leaves the diff path addressing the wrong + // rows from then on. + const short = Size{ .width = 10, .height = 3 }; + for ([_][]const u8{ "a\nb\nc", "a\nB\nc", "a", "a\nb\nc\nd", "" }) |view| { + const out = try h.render(view, short); + try testing.expect(std.mem.indexOfScalar(u8, out, '\n') == null); + try testing.expect(std.mem.indexOfScalar(u8, out, '\r') == null); + } } test "empty view is handled" {