mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
native: keep split-footer output off the footer when fewer than two rows remain (#1618)
In split-footer mode, native keeps appended output above the footer with a temporary scroll region (`ESC[1;Nr`, N = rows above the footer). With one row above the footer it sent `ESC[1;1r`, which terminals ignore, because a scroll region needs two rows; with no row it sent no region. Each CRLF on row 1 then moved into the footer and erased it, later frames did not repaint it, and nothing reached the scrollback. The default footer height (12) hits this in any terminal of 13 rows or fewer, for example a small tmux pane or after a resize. With fewer than two rows above the footer, native now uses the region `1;2`, which borrows the footer's top row, sends one more `ESC D` after the payload so that the last line is back on row 1, and repaints the footer. When a first row wraps onto the borrowed row, the existing pre-scroll also clears that row. With no row above the footer, a row that fills the width ends there and scrolls into the scrollback, and the next commit starts at column 1. A one-row terminal skips the extra `ESC D`. One case stays lossy: when the footer covers the whole terminal, a write shorter than the width and without a newline has no row to stay on, so it is lost if a later write continues it. The screen table in `renderer.native-scene-split.test.ts` gets rows with a terminal size: panes of two rows, one row (also under the default footer and in a two-row terminal), no row, a one-row terminal, and a footer that shrinks after a lost partial row. They write captured lines, a two-row writer snapshot, a wrapping first row, and a full row, then grow the VT with the cursor on the bottom row so the scrollback shows above the screen. The native ordering test runs at pinned offsets 2, 1, and 0. Reverting the region, dropping the extra `ESC D`, the footer repaint, the borrowed-row clear, the full-row end, the tail reset, or the one-row guard each makes at least one row fail. Fixes #1607
This commit is contained in:
1 parent
74a273e592
commit
7221adcbd9
3 files changed
+92
-80
No files matched your search
@@ -41,9 +41,12 @@ async function setup(options: TestRendererOptions = {}) {
|
||||
}
|
||||
|
||||
/** A set-up split-footer terminal whose native output reaches `stdout`, so tests can read the scrollback bytes. */
|
||||
async function setupTerminal(options: { columns?: number; maxBytes?: bigint } = {}) {
|
||||
async function setupTerminal(
|
||||
options: { columns?: number; height?: number; footerHeight?: number; maxBytes?: bigint } = {},
|
||||
) {
|
||||
const columns = options.columns ?? 40
|
||||
const stdout = new RecordingWriteStream(columns, 10) as RecordingWriteStream & NodeJS.WriteStream
|
||||
const height = options.height ?? 10
|
||||
const stdout = new RecordingWriteStream(columns, height) as RecordingWriteStream & NodeJS.WriteStream
|
||||
const nativeSession =
|
||||
options.maxBytes === undefined
|
||||
? undefined
|
||||
@@ -53,6 +56,8 @@ async function setupTerminal(options: { columns?: number; maxBytes?: bigint } =
|
||||
const clock = new ManualClock()
|
||||
const result = await setup({
|
||||
width: columns,
|
||||
height,
|
||||
footerHeight: options.footerHeight ?? 3,
|
||||
stdout,
|
||||
nativeSession,
|
||||
bufferedOutput: "stdout",
|
||||
@@ -260,6 +265,12 @@ test.each(["frames", "suspend", "destroy"] as const)(
|
||||
// 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.
|
||||
// A row with [terminal rows, footer height] lists its scrollback and screen down to the footer: a pane of one row or
|
||||
// none has no valid scroll region (DECSTBM needs two rows), so output must not reach the footer. With no row, a row that
|
||||
// never ends (`abcde`) has nowhere to stay and is lost (accepted). `{ footer }` sets the footer height.
|
||||
const smallPane = ["a\nb\n", { red: "w1\nw2\n" }, "abcde", { red: "XXXXXX\n" }, "abcdefghi", "x\n"] as const
|
||||
const smallPaneRows = ["a", "b", "[w1]", "[w2]", "abcde[XXXX]", "[XX]", "abcdefghi", "x", "", "FFFFFFFFF"] as const
|
||||
const noRowRows = ["a", "b", "[w1]", "[w2]", "[XXXXXX]", "abcdefghi", "x", "FFFFFFFFF"] as const
|
||||
test.each([
|
||||
["a captured ASCII line", ["abcdefghijklmnopqrs\n"], ["abcdefghi", "jklmnopqr", "s"]],
|
||||
["a captured line of wide characters", ["一二三四五六七八九\n"], ["一二三四", "五六七八", "九"]],
|
||||
@@ -283,29 +294,45 @@ test.each([
|
||||
["\n".repeat(8), "abcde", { red: "XXXXXX\n" }, "abcd", { red: "YY" }, "efg", "z\n", "ab", { red: "一二三四\n" }],
|
||||
["abcde[XXXX]", "[XX]", "abcd[YY]efg", "z", "ab[一二三]", "[四]"],
|
||||
],
|
||||
] 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" }))
|
||||
["output in a two-row pane", smallPane, smallPaneRows, [4, 2]],
|
||||
["output in a one-row pane", smallPane, smallPaneRows, [4, 3]],
|
||||
["output in a one-row pane under the default footer", smallPane, smallPaneRows, [13, 12]],
|
||||
["output with no row above the footer", smallPane, noRowRows, [4, 4]],
|
||||
["output in a one-row pane of a two-row terminal", smallPane, smallPaneRows, [2, 1]],
|
||||
["output in a one-row terminal", smallPane, noRowRows, [1, 1]],
|
||||
["output after the footer shrinks", ["abcde", { footer: 2 }, "fghijk", "!\n"], ["fghijk!", "", "FFFFFFFFF"], [4, 4]],
|
||||
] as const)("%s shows its terminal rows above the split footer", async (_name, writes, rows, ...[size]) => {
|
||||
const [height, footerHeight] = (size as readonly [number, number] | undefined) ?? [10, 3]
|
||||
const terminal = await setupTerminal({ columns: 9, height, footerHeight })
|
||||
terminal.renderer.root.add(new TextRenderable(terminal.renderer, { content: "FFFFFFFFF" }))
|
||||
for (const write of writes) {
|
||||
if (typeof write === "string") terminal.stdout.write(write)
|
||||
else if ("footer" in write) terminal.renderer.footerHeight = write.footer
|
||||
else
|
||||
terminal.renderer.writeToScrollback(({ renderContext }) => ({
|
||||
root: new TextRenderable(renderContext, { content: write.red.trimEnd(), width: 9, height: 1, bg: "#ff0000" }),
|
||||
root: new TextRenderable(renderContext, {
|
||||
content: write.red.trimEnd(),
|
||||
width: 9,
|
||||
height: write.red.trimEnd().split("\n").length,
|
||||
bg: "#ff0000",
|
||||
}),
|
||||
startOnNewLine: false,
|
||||
trailingNewline: write.red.endsWith("\n"),
|
||||
}))
|
||||
await terminal.frame()
|
||||
}
|
||||
const view = await createTestRenderer({ width: 9, height: 10 })
|
||||
const view = await createTestRenderer({ width: 9, height: height + 8 })
|
||||
renderers.push(view.renderer)
|
||||
const vt = new EmbeddedTerminalRenderable(view.renderer, { cols: 9, rows: 10 })
|
||||
const vt = new EmbeddedTerminalRenderable(view.renderer, { cols: 9, rows: height })
|
||||
view.renderer.root.add(vt)
|
||||
vt.write("~".repeat(9) + "\r" + terminal.stdout.text())
|
||||
// With the cursor on the bottom row, a taller VT pulls the scrollback down above the screen.
|
||||
if (size) (vt.write(`\x1b[${height};1H`), (vt.height = height + 8))
|
||||
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"))
|
||||
expect(screen.join("\n").replace(/ +$/gm, "").trimEnd()).toBe((size ? rows : [...rows, "", "FFFFFFFFF"]).join("\n"))
|
||||
})
|
||||
|
||||
// Each commit is "text:rowColumns", plus "\n" when it ends its line.
|
||||
|
||||
@@ -1996,6 +1996,7 @@ pub const CliRenderer = struct {
|
||||
const starts_mid_line = previousOutputColumn > 0 and start_on_new_line;
|
||||
const starts_wrapped_line = previousOutputColumn >= self.width;
|
||||
const previousFooterTopLine: u32 = @max(previousSurfaceOffset + 1, @as(u32, 1));
|
||||
var ends_last_row = trailing_newline;
|
||||
|
||||
if (snapshot_has_content) {
|
||||
// First update logical split scrollback state, then emit terminal I/O.
|
||||
@@ -2014,6 +2015,10 @@ pub const CliRenderer = struct {
|
||||
}
|
||||
|
||||
self.splitScrollback.published_rows = @min(self.splitScrollback.published_rows, pinned_render_offset);
|
||||
// With no output row, the footer covers the last row: a full row ends there, and the
|
||||
// next commit starts a new line at column 1 (no CRLF, which would scroll a footer row).
|
||||
ends_last_row = trailing_newline or (pinned_render_offset == 0 and self.splitScrollback.tail_column >= self.width);
|
||||
if (pinned_render_offset == 0) self.splitScrollback.tail_column = 0;
|
||||
}
|
||||
|
||||
const next_output_offset = self.splitScrollback.renderOffset(pinned_render_offset);
|
||||
@@ -2023,14 +2028,13 @@ pub const CliRenderer = struct {
|
||||
// appended output must scroll only the upper pane. Without a temporary DECSTBM
|
||||
// region, terminals advance into the footer rows and overwrite them in place.
|
||||
const use_bounded_scroll_region = snapshot_has_content and
|
||||
pinned_render_offset > 0 and
|
||||
next_render_offset == pinned_render_offset and
|
||||
next_output_offset == next_render_offset;
|
||||
// DECSTBM protects footer cells, but terminals can still scroll native
|
||||
// graphics placements. Repaint those placements after every pinned append.
|
||||
const repaint_native_images = use_bounded_scroll_region and
|
||||
(self.hasCommittedProtocol(.kitty) or self.hasCommittedProtocol(.sixel));
|
||||
const redraw_footer = force or previousSurfaceOffset != next_render_offset or repaint_native_images;
|
||||
// DECSTBM protects footer cells, but terminals can still scroll native graphics placements, and
|
||||
// a pane of one row or none borrows footer rows. Repaint the footer after those pinned appends.
|
||||
const repaint_footer = use_bounded_scroll_region and (pinned_render_offset < 2 or
|
||||
self.hasCommittedProtocol(.kitty) or self.hasCommittedProtocol(.sixel));
|
||||
const redraw_footer = force or previousSurfaceOffset != next_render_offset or repaint_footer;
|
||||
|
||||
if (snapshot_has_content or force) {
|
||||
if (snapshot_has_content) {
|
||||
@@ -2048,8 +2052,8 @@ pub const CliRenderer = struct {
|
||||
if (use_bounded_scroll_region) {
|
||||
// Temporarily bound scrolling to the upper pane so newline/wrap
|
||||
// from appended payload pushes history upward instead of stepping
|
||||
// through footer rows.
|
||||
writer.print("\x1b[1;{d}r", .{pinned_render_offset}) catch {};
|
||||
// through footer rows. Terminals ignore a region of fewer than two rows.
|
||||
writer.print("\x1b[1;{d}r", .{@max(pinned_render_offset, 2)}) catch {};
|
||||
}
|
||||
|
||||
moveToSplitOutputCursor(writer, previousOutputOffset, previousOutputColumn, self.width);
|
||||
@@ -2062,18 +2066,19 @@ pub const CliRenderer = struct {
|
||||
// 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 {};
|
||||
// A first row that wraps would scroll its next line in with the wrapping cell's background
|
||||
// (BCE). Scroll that line in first with default attributes, or clear a borrowed footer row.
|
||||
writer.writeAll(ansi.ANSI.reset ++ "\x1bD\x1b[2K\x1b[A") catch {};
|
||||
}
|
||||
|
||||
// Serialize payload rows at current output cursor.
|
||||
try self.writeSnapshotCommit(writer, snapshot, normalized_row_columns, trailing_newline, kitty_history_state);
|
||||
try self.writeSnapshotCommit(writer, snapshot, normalized_row_columns, ends_last_row, kitty_history_state);
|
||||
|
||||
if (use_bounded_scroll_region) {
|
||||
// Restore default full-height scroll region for regular repaint
|
||||
// and cursor operations after append is complete.
|
||||
writer.writeAll("\x1b[r") catch {};
|
||||
// Restore the full-height region. A pane of one row or none first scrolls its last line
|
||||
// back to row 1, unless the terminal has one row, where each CRLF already scrolled.
|
||||
const scroll_back = pinned_render_offset < 2 and pinned_render_offset + self.height > 1;
|
||||
writer.writeAll(if (scroll_back) "\x1bD\x1b[r" else "\x1b[r") catch {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2735,71 +2735,51 @@ test "renderer - explicit_cursor_positioning with CJK characters" {
|
||||
}
|
||||
|
||||
test "renderer - commitSplitFooterSnapshot writes append before footer repaint in one output" {
|
||||
var pools = TestPools.init(std.testing.allocator);
|
||||
defer pools.deinit();
|
||||
// Terminals ignore a scroll region of fewer than two rows, so a pane of one row or none sets 1;2r too.
|
||||
for ([_]u32{ 2, 1, 0 }) |pinned| {
|
||||
var pools = TestPools.init(std.testing.allocator);
|
||||
defer pools.deinit();
|
||||
|
||||
var test_cli_renderer = try TestRenderer.create(
|
||||
std.testing.allocator,
|
||||
12,
|
||||
3,
|
||||
&pools.graphemes,
|
||||
&pools.links,
|
||||
);
|
||||
defer test_cli_renderer.deinit();
|
||||
const cli_renderer = test_cli_renderer.renderer;
|
||||
var test_cli_renderer = try TestRenderer.create(std.testing.allocator, 12, 3, &pools.graphemes, &pools.links);
|
||||
defer test_cli_renderer.deinit();
|
||||
const cli_renderer = test_cli_renderer.renderer;
|
||||
|
||||
_ = cli_renderer.resetSplitScrollback(2, 2);
|
||||
_ = cli_renderer.resetSplitScrollback(pinned, pinned);
|
||||
|
||||
const next_buffer = cli_renderer.getNextBuffer();
|
||||
const fg = ansi.rgbaFromFloats(1.0, 1.0, 1.0, 1.0);
|
||||
const bg = ansi.rgbaFromFloats(0.0, 0.0, 0.0, 1.0);
|
||||
try next_buffer.drawText("FOOT", 0, 0, fg, bg, 0);
|
||||
const next_buffer = cli_renderer.getNextBuffer();
|
||||
const fg = ansi.rgbaFromFloats(1.0, 1.0, 1.0, 1.0);
|
||||
const bg = ansi.rgbaFromFloats(0.0, 0.0, 0.0, 1.0);
|
||||
try next_buffer.drawText("FOOT", 0, 0, fg, bg, 0);
|
||||
|
||||
var snapshot = try OptimizedBuffer.init(
|
||||
std.testing.allocator,
|
||||
11,
|
||||
1,
|
||||
.{ .link_pool = &pools.links, .pool = &pools.graphemes, .width_method = .unicode, .respectAlpha = false },
|
||||
);
|
||||
defer snapshot.deinit();
|
||||
var snapshot = try OptimizedBuffer.init(
|
||||
std.testing.allocator,
|
||||
11,
|
||||
1,
|
||||
.{ .link_pool = &pools.links, .pool = &pools.graphemes, .width_method = .unicode, .respectAlpha = false },
|
||||
);
|
||||
defer snapshot.deinit();
|
||||
|
||||
snapshot.clear(ansi.rgbaFromFloats(0.0, 0.0, 0.0, 0.0), 32);
|
||||
try snapshot.drawText("append-line", 0, 0, fg, ansi.rgbaFromFloats(0.0, 0.0, 0.0, 0.0), 0);
|
||||
snapshot.clear(ansi.rgbaFromFloats(0.0, 0.0, 0.0, 0.0), 32);
|
||||
try snapshot.drawText("append-line", 0, 0, fg, ansi.rgbaFromFloats(0.0, 0.0, 0.0, 0.0), 0);
|
||||
|
||||
const appended = "append-line";
|
||||
// This test documents the critical ordering contract:
|
||||
// 1) append payload is emitted in the same sync frame as repaint
|
||||
// 2) DECSTBM is active only during append when pinned
|
||||
// 3) footer repaint cursor move happens after append payload
|
||||
_ = cli_renderer.commitSplitFooterSnapshotBatched(snapshot, 11, false, true, 2, false, true, true);
|
||||
// This test documents the critical ordering contract:
|
||||
// 1) append payload is emitted in the same sync frame as repaint
|
||||
// 2) DECSTBM is active only during append when pinned
|
||||
// 3) footer repaint cursor move happens after append payload
|
||||
_ = cli_renderer.commitSplitFooterSnapshotBatched(snapshot, 11, false, true, pinned, false, true, true);
|
||||
|
||||
const output = test_cli_renderer.lastOutput();
|
||||
const append_index = std.mem.find(u8, output, appended);
|
||||
const scroll_region_set_index = std.mem.find(u8, output, "\x1b[1;2r");
|
||||
const scroll_region_reset_index = std.mem.find(u8, output, "\x1b[r");
|
||||
const sync_index = std.mem.find(u8, output, ansi.ANSI.syncSet);
|
||||
const footer_move_after_sync = if (sync_index) |sync_start|
|
||||
std.mem.find(u8, output[sync_start + ansi.ANSI.syncSet.len ..], "\x1b[3;1H")
|
||||
else
|
||||
null;
|
||||
const output = test_cli_renderer.lastOutput();
|
||||
const sync_index = std.mem.find(u8, output, ansi.ANSI.syncSet) orelse return error.TestUnexpectedResult;
|
||||
const region_index = std.mem.find(u8, output, "\x1b[1;2r") orelse return error.TestUnexpectedResult;
|
||||
const append_index = std.mem.find(u8, output, "append-line") orelse return error.TestUnexpectedResult;
|
||||
const reset_index = std.mem.find(u8, output, "\x1b[r") orelse return error.TestUnexpectedResult;
|
||||
var footer_move: [16]u8 = undefined;
|
||||
const footer_move_text = try std.fmt.bufPrint(&footer_move, "\x1b[{d};1H", .{pinned + 1});
|
||||
|
||||
try std.testing.expect(append_index != null);
|
||||
try std.testing.expect(scroll_region_set_index != null);
|
||||
try std.testing.expect(scroll_region_reset_index != null);
|
||||
try std.testing.expect(sync_index != null);
|
||||
try std.testing.expect(footer_move_after_sync != null);
|
||||
try std.testing.expect(sync_index.? < append_index.?);
|
||||
try std.testing.expect(scroll_region_set_index.? < append_index.?);
|
||||
try std.testing.expect(append_index.? < scroll_region_reset_index.?);
|
||||
|
||||
var sync_count: usize = 0;
|
||||
var pos: usize = 0;
|
||||
while (std.mem.find(u8, output[pos..], ansi.ANSI.syncSet)) |found| {
|
||||
sync_count += 1;
|
||||
pos += found + ansi.ANSI.syncSet.len;
|
||||
try std.testing.expect(sync_index < region_index and region_index < append_index and append_index < reset_index);
|
||||
try std.testing.expect(std.mem.find(u8, output[reset_index..], footer_move_text) != null);
|
||||
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, output, ansi.ANSI.syncSet));
|
||||
}
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), sync_count);
|
||||
}
|
||||
|
||||
test "renderer - pinned split scrollback repaints live native images after append" {
|
||||
|
||||
Reference in new issue
Block a user