mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
native: keep the last cell of a split scrollback row that fills the width (#1581)
In split-footer mode, native wrote `ESC[K` after each scrollback row to clear what was left to its right. After a row that ends on the last column, the cursor stays on that column with a wrap pending, and `ESC[K` erases from the cursor, so terminals such as Ghostty removed the row's last character. Captured stdout splits long lines into rows of the full width, so every line longer than the terminal lost one character at each wrap point, and a line of exactly the width lost its last one. Writer snapshots and surfaces that fill the width lost it too. Native now erases before each row instead: once where the commit starts, then after each CRLF. That erase also clears the row that a trailing newline makes current, so the separate erase for it goes. A row that fits clears the same cells as before. The old erase after a row also reset the rest of the line that a row wraps onto. When a row continues mid-line at the pinned bottom and wraps, the wrap scrolls that line in, and terminals with background color erase fill it with the background of the cell that wraps, so a writer row on a background color left that color across the rest of the line. When the first row of a commit does not fit in the rest of its line, native now scrolls the next line in first, with default attributes, and moves back up (`ESC D`, `ESC[A`), so the wrap moves onto a line that already exists. Nothing is erased after a row, so no cell can be lost at a pending wrap, and the number of scrolled lines does not change. A table in `renderer.native-scene-split.test.ts` feeds the renderer's terminal bytes into libghostty-vt (`EmbeddedTerminalRenderable`) and compares the screen: captured lines shorter than, equal to, and longer than the width, with ASCII and wide characters, a line continued mid-row over old text on the start row, and writer rows on a red background at the pinned bottom, whose runs show in brackets. A footer marker on the screen checks the row count that the table used to read from `renderOffset`, and the full-width rows fail on main with the last cell missing. Deleting the first erase, widening it to `ESC[2K`, moving it back after the row, deleting the pre-scroll, dropping its `ESC[A`, or changing its test to `>=` or to the snapshot width each makes at least one row fail. Other split-footer defects found along the way are not part of this PR; they will be filed separately. Fixes #1580
This commit is contained in:
1 parent
5f6f286c3b
commit
af4b9592df
3 files changed
+50
-25
No files matched your search
@@ -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([
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in new issue
Block a user