diff --git a/packages/core/src/tests/renderer.native-scene-split.test.ts b/packages/core/src/tests/renderer.native-scene-split.test.ts index 48109ec3..b33adbe6 100644 --- a/packages/core/src/tests/renderer.native-scene-split.test.ts +++ b/packages/core/src/tests/renderer.native-scene-split.test.ts @@ -5,6 +5,7 @@ import { CliRenderEvents } from "../renderer.js" import { Renderable, RenderableEvents } from "../Renderable.js" import { BoxRenderable } from "../renderables/Box.js" import { CodeRenderable } from "../renderables/Code.js" +import { EmbeddedTerminalRenderable } from "../renderables/EmbeddedTerminal.js" import { TextRenderable } from "../renderables/Text.js" import { SyntaxStyle } from "../syntax-style.js" import { @@ -256,21 +257,46 @@ test.each(["frames", "suspend", "destroy"] as const)( ) // A wide grapheme that does not fit the row starts the next one; native must count the rows the terminal uses. +// A row that fills the width keeps its last cell (#1580). The footer starts below the empty row after the newline. +// Old text on the start row (`~`) gives way to the right of a row, and only there. `{ red }` is a 9-cell writer row on a +// red background that continues the last row, and "\n" ends its line; runs on a red background show in brackets. test.each([ - ["ASCII", "abcdefghijklmnopqrs", 3], - ["wide characters", "一二三四五六七八九", 3], - ["a wide character at the last column", "abcdefgh一", 2], -] as const)( - "a captured %s line that wraps advances the split footer by its terminal rows", - async (_name, line, rows) => { - const terminal = await setupTerminal({ columns: 9 }) - const renderOffset = () => (terminal.renderer as unknown as { renderOffset: number }).renderOffset - const before = renderOffset() - terminal.stdout.write(line + "\n") + ["a captured ASCII line", ["abcdefghijklmnopqrs\n"], ["abcdefghi", "jklmnopqr", "s"]], + ["a captured line of wide characters", ["一二三四五六七八九\n"], ["一二三四", "五六七八", "九"]], + ["a captured wide character at the last column", ["abcdefgh一\n"], ["abcdefgh", "一"]], + ["a captured full-width ASCII line", ["012345678\n"], ["012345678"]], + ["a captured full-width line of wide characters", ["a一二三四\n"], ["a一二三四"]], + ["a captured short line", ["abc\n"], ["abc"]], + ["a captured line continued mid-row", ["ab", "cdefghijk\n"], ["abcdefghi", "jk"]], + [ + "rows continued at the pinned bottom", + ["1\n2\n3\n4\n5\n6\n7\n8\n", "abcde", { red: "XXXXXX\n" }, "abcd", { red: "YY" }, "efg", "z\n"], + ["7", "8", "abcde[XXXX]", "[XX]", "abcd[YY]efg", "z"], + ], +] as const)("%s shows its terminal rows above the split footer", async (_name, writes, rows) => { + const terminal = await setupTerminal({ columns: 9 }) + terminal.renderer.root.add(new TextRenderable(terminal.renderer, { content: "F" })) + for (const write of writes) { + if (typeof write === "string") terminal.stdout.write(write) + else + terminal.renderer.writeToScrollback(({ renderContext }) => ({ + root: new TextRenderable(renderContext, { content: write.red.trimEnd(), width: 9, height: 1, bg: "#ff0000" }), + startOnNewLine: false, + trailingNewline: write.red.endsWith("\n"), + })) await terminal.frame() - expect(renderOffset() - before).toBe(rows) - }, -) + } + const view = await createTestRenderer({ width: 9, height: 10 }) + renderers.push(view.renderer) + const vt = new EmbeddedTerminalRenderable(view.renderer, { cols: 9, rows: 10 }) + view.renderer.root.add(vt) + vt.write("~".repeat(9) + "\r" + terminal.stdout.text()) + await view.renderOnce() + const screen = view + .captureSpans() + .lines.map(({ spans }) => spans.map((s) => (s.bg.r ? `[${s.text}]` : s.text)).join("")) + expect(screen.join("\n").replace(/ +$/gm, "").trimEnd()).toBe([...rows, "", "F"].join("\n")) +}) // Each commit is "text:rowColumns", plus "\n" when it ends its line. test.each([ diff --git a/packages/native/src/renderer.zig b/packages/native/src/renderer.zig index 0b219875..e40b3fac 100644 --- a/packages/native/src/renderer.zig +++ b/packages/native/src/renderer.zig @@ -1866,6 +1866,9 @@ pub const CliRenderer = struct { const hyperlinksEnabled = self.terminal.getCapabilities().hyperlinks; const render_columns = @min(row_columns, snapshot.width); + // Erase before each row, not after it: a row that fills the width leaves the cursor on + // the last column with a wrap pending, and an erase there removes that cell. + writer.writeAll(ansi.ANSI.reset ++ ansi.ANSI.eraseToEndOfLine) catch {}; for (0..snapshot.height) |uy| { const y = @as(u32, @intCast(uy)); const row_end = snapshotRowEnd(snapshot, y, render_columns); @@ -1959,8 +1962,6 @@ pub const CliRenderer = struct { } writer.writeAll(ansi.ANSI.reset) catch {}; - // Guarantee short rows do not leave stale content from prior frame data. - writer.writeAll(ansi.ANSI.eraseToEndOfLine) catch {}; if (image_state.protocol == .sixel or image_state.protocol == .kitty) { try self.writeSnapshotNativeImagesForRow(writer, snapshot, y, image_state); } @@ -1970,14 +1971,9 @@ pub const CliRenderer = struct { const is_last_row = @as(u32, @intCast(uy + 1)) >= snapshot.height; if (!is_last_row or trailing_newline) { - writer.writeAll("\r\n") catch {}; - } - - if (is_last_row and trailing_newline) { - // After a trailing newline, clear the destination row that becomes - // current so old footer text cannot flash through. - writer.writeAll(ansi.ANSI.reset) catch {}; - writer.writeAll(ansi.ANSI.eraseToEndOfLine) catch {}; + // The erase also clears the row that a trailing newline makes current, so old + // footer text cannot flash through. + writer.writeAll("\r\n" ++ ansi.ANSI.eraseToEndOfLine) catch {}; } } } @@ -2077,6 +2073,10 @@ pub const CliRenderer = struct { // Emit CRLF before payload to preserve logical row boundaries // across commit chunks. writer.writeAll("\r\n") catch {}; + } else if (previousOutputColumn + snapshotRowEnd(snapshot, 0, normalized_row_columns) > self.width) { + // A first row that wraps would scroll its next line in with the background of the + // cell that wraps (BCE). Scroll that line in first, with default attributes. + writer.writeAll(ansi.ANSI.reset ++ "\x1bD\x1b[A") catch {}; } // Serialize payload rows at current output cursor. diff --git a/packages/native/src/tests/renderer_test.zig b/packages/native/src/tests/renderer_test.zig index 34cfffbf..92516f75 100644 --- a/packages/native/src/tests/renderer_test.zig +++ b/packages/native/src/tests/renderer_test.zig @@ -3591,8 +3591,7 @@ test "renderer - commitSplitFooterSnapshot does not emit NUL padding for short r const output = test_cli_renderer.lastOutput(); try std.testing.expect(std.mem.findScalar(u8, output, 0) == null); - try std.testing.expect(std.mem.find(u8, output, ansi.ANSI.eraseToEndOfLine) != null); - try std.testing.expect(std.mem.find(u8, output, "\r\n\x1b[0m\x1b[K") != null); + try std.testing.expect(std.mem.find(u8, output, "\x1b[0m\r\n\x1b[K") != null); } test "renderer - batched split commits share single sync frame" {