mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
core: cut captured stdout rows where the terminal row continues (#1617)
In split-footer mode with captured stdout, a write that continued a row (the previous write had no newline) was cut into terminal rows as if it started at column 0. A wide character that would start on the last column then sat in a commit that crossed the terminal edge: the terminal moved it to the next row, but the renderer's row count did not. The next write overwrote a character, a padding space landed in the middle of a row, or the wide character was lost. Streamed CJK or emoji output (for example tokens written as they arrive) hit this whenever a chunk boundary left a wide character on the last column. The cut now starts the first row of each write at the predicted tail column, so no commit crosses the edge and the native row count stays right without a native change. This also counts tab stops from the start of the terminal row instead of the start of the write, and a tab now stops at the last column as in a terminal instead of pushing the next character to a new row. Every captured write now reads the predicted tail, so the renderer keeps the tail after the last queued commit instead of scanning the queue. Output faster than a frame drains stays linear: 4,000 progress-bar updates take 6,400 tail steps, not the 6.4 million a scan would. The split-footer screen tests gain rows that feed the writes through a VT and compare the screen (wide character at the last column, padding at the edge, tabs, a wide character that wraps early, unpinned and pinned), and the captured-stdout commit table gains the matching input-to-rows vectors. Each row fails on main, and reverting each changed expression (the row column, the tab clamp, the cache order) fails at least one row. Fixes #1608
This commit is contained in:
1 parent
17abb96808
commit
74a273e592
2 files changed
+44
-11
No files matched your search
@@ -463,7 +463,7 @@ class ExternalOutputQueue {
|
||||
return this.commits.length
|
||||
}
|
||||
|
||||
writeSnapshots(commits: readonly ExternalOutputCommit[]): void {
|
||||
writeSnapshots(commits: readonly ExternalOutputCommit[]): readonly QueuedCommit[] {
|
||||
// Every frame renders the queue head, so one invalid entry would fail every later frame and the close flush.
|
||||
for (const { rowColumns, snapshot } of commits) {
|
||||
if (!Number.isInteger(rowColumns) || rowColumns < 0 || rowColumns > snapshot.width) {
|
||||
@@ -480,6 +480,7 @@ class ExternalOutputQueue {
|
||||
rowWidths: snapshotRowWidths(commit.snapshot, commit.rowColumns),
|
||||
}))
|
||||
for (const entry of entries) this.commits.push(entry)
|
||||
return entries
|
||||
}
|
||||
|
||||
/** The head commits, at most `limit`, that fit one native split render. Never empty unless the queue is. */
|
||||
@@ -1041,6 +1042,8 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
private _splitHeight: number = 0
|
||||
private renderOffset: number = 0
|
||||
private splitTailColumn: number = 0
|
||||
// The tail after the last queued commit, so each captured write reads it in O(1); a reset or a resize voids it.
|
||||
private queuedSplitTail: { column: number; width: number } | null = null
|
||||
private pendingSplitFooterTransition: PendingSplitFooterTransition | null = null
|
||||
// One-shot latch used to request a full split repaint after transitions
|
||||
// (resize/mode/output-path changes). Cleared after completed presentation.
|
||||
@@ -2573,9 +2576,12 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
|
||||
private getPendingSplitTailColumn(): number {
|
||||
const width = Math.max(this.width, 1)
|
||||
const skipped = this.pendingNativeReplay?.remaining ?? 0
|
||||
const cached = this.queuedSplitTail
|
||||
if (this.externalOutputQueue.size > skipped && cached?.width === width) return cached.column
|
||||
let tailColumn = this.pendingNativeReplay ? 0 : this.splitTailColumn
|
||||
|
||||
for (const commit of this.externalOutputQueue.peek().slice(this.pendingNativeReplay?.remaining ?? 0)) {
|
||||
for (const commit of this.externalOutputQueue.peek().slice(skipped)) {
|
||||
tailColumn = this.getSplitTailColumnAfterCommit(commit, tailColumn, width)
|
||||
}
|
||||
|
||||
@@ -2609,7 +2615,12 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
|
||||
private enqueueSplitCommits(commits: readonly ExternalOutputCommit[]): void {
|
||||
for (const commit of commits) commit.nativeSnapshot = commit.snapshot._getSceneHandle(this.nativeScene)
|
||||
this.externalOutputQueue.writeSnapshots(commits)
|
||||
const width = Math.max(this.width, 1)
|
||||
let column = this.getPendingSplitTailColumn()
|
||||
for (const entry of this.externalOutputQueue.writeSnapshots(commits)) {
|
||||
column = this.getSplitTailColumnAfterCommit(entry, column, width)
|
||||
}
|
||||
this.queuedSplitTail = { column, width }
|
||||
this.requestRender()
|
||||
if (this.listenerCount(CliRenderEvents.EXTERNAL_OUTPUT) > 0) {
|
||||
for (const commit of commits) this.emit(CliRenderEvents.EXTERNAL_OUTPUT, commit)
|
||||
@@ -2629,6 +2640,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
// Chunk rows at the renderer width in display cells, never inside a grapheme, so
|
||||
// each commit maps to one terminal row append and keeps every cell.
|
||||
const width = Math.max(1, this.width)
|
||||
const tailColumn = this.getPendingSplitTailColumn()
|
||||
const rows: StdoutRow[] = []
|
||||
// Wrapped chunks of the current logical row start at this index; '\r' drops them.
|
||||
let rowStart = 0
|
||||
@@ -2652,13 +2664,15 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
continue
|
||||
}
|
||||
|
||||
// A tab advances to the next stop, as in a terminal, and never wraps.
|
||||
const tabCells = Math.min(STDOUT_TAB_WIDTH - (cells % STDOUT_TAB_WIDTH), Math.max(0, width - cells))
|
||||
// The first row of a write continues the terminal row at the tail column; native appends it there.
|
||||
const column = (rows.length === 0 && tailColumn < width ? tailColumn : 0) + cells
|
||||
// A tab advances to the next stop, as in a terminal, and stops at the last column.
|
||||
const tabCells = Math.min(STDOUT_TAB_WIDTH - (column % STDOUT_TAB_WIDTH), Math.max(0, width - 1 - column))
|
||||
const grapheme = segment === "\t" ? " ".repeat(tabCells) : segment
|
||||
const graphemeCells = stringWidth(grapheme)
|
||||
if (cells > 0 && cells + graphemeCells > width) {
|
||||
// A wide grapheme that does not fit starts a terminal row; pad to the full width so native counts that row.
|
||||
const padding = Math.max(0, width - cells)
|
||||
if (column > 0 && column + graphemeCells > width) {
|
||||
// A grapheme that does not fit starts a terminal row; pad a wide one's skipped cell so native counts the row.
|
||||
const padding = Math.max(0, width - column)
|
||||
rows.push({ line: line + " ".repeat(padding), cells: cells + padding, trailingNewline: false })
|
||||
line = ""
|
||||
cells = 0
|
||||
@@ -2795,6 +2809,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
pinnedRenderOffset: this.getSplitPinnedRenderOffset(),
|
||||
})
|
||||
this.splitTailColumn = 0
|
||||
this.queuedSplitTail = null
|
||||
}
|
||||
|
||||
private syncSplitScrollback(): void {
|
||||
|
||||
@@ -268,6 +268,16 @@ test.each([
|
||||
["a captured full-width line of wide characters", ["a一二三四\n"], ["a一二三四"]],
|
||||
["a captured short line", ["abc\n"], ["abc"]],
|
||||
["a captured line continued after wide characters", ["你好", "a世界", "x\n"], ["你好a世界", "x"]],
|
||||
["a continued wide character at the last column", ["abcdefgh", "一x", "y\n"], ["abcdefgh", "一xy"]],
|
||||
["a wide character at the edge of a continued row", ["abcde", "fghijklm一\n"], ["abcdefghi", "jklm一"]],
|
||||
["a tab in a continued row", ["abc", "\tX\n"], ["abc X"]],
|
||||
["a tab that reaches the right margin of a continued row", ["abcdefgh", "\tX\n"], ["abcdefghX"]],
|
||||
["a continued wide character that wraps early", ["abc", "defgh一", "x\n"], ["abcdefgh", "一x"]],
|
||||
[
|
||||
"the same early wrap at the pinned bottom",
|
||||
["\n".repeat(8), "abc", "defgh一", "x\n"],
|
||||
["", "", "", "", "abcdefgh", "一x"],
|
||||
],
|
||||
[
|
||||
"rows continued at the pinned bottom",
|
||||
["\n".repeat(8), "abcde", { red: "XXXXXX\n" }, "abcd", { red: "YY" }, "efg", "z\n", "ab", { red: "一二三四\n" }],
|
||||
@@ -306,6 +316,12 @@ test.each([
|
||||
["expands tabs to 8-cell stops within the row", "a\tb\tc\n", ["a b:9", "c:1\n"]],
|
||||
["ends a line at CRLF", "one\r\ntwo\r\n", ["one:3\n", "two:3\n"]],
|
||||
["restarts a wrapped line at CR", "0123456789%\r60%\n\n", ["60%:3\n", ":0\n"]],
|
||||
// Writes that continue a row are cut where the terminal row ends.
|
||||
["wraps a wide character at the last column of a continued row", ["abcdefgh", "一x"], ["abcdefgh:8", ":1", "一x:3"]],
|
||||
["pads at the terminal edge, not at the write width", ["abcde", "fghijklm一\n"], ["abcde:5", "fghi:4", "jklm一:6\n"]],
|
||||
["counts tab stops from the row, not the write", ["a", "bc", "\tX\n"], ["a:1", "bc:2", " X:6\n"]],
|
||||
["stops a tab at the last column", "\t\tb\n", [" b:9\n"]],
|
||||
["keeps a wide character that wraps early", ["abc", "defgh一", "x\n"], ["abc:3", "defgh:6", "一:2", "x:1\n"]],
|
||||
])("captured stdout %s", async (_name, text, commits) => {
|
||||
const stdout = createTestStdout(9, 10)
|
||||
const { renderer } = await setup({ width: 9, stdout })
|
||||
@@ -315,14 +331,16 @@ test.each([
|
||||
const row = decoder.decode(snapshot.getRealCharBytes(true)).trimEnd()
|
||||
written.push(`${row}:${rowColumns}${trailingNewline ? "\n" : ""}`)
|
||||
})
|
||||
stdout.write(text)
|
||||
for (const write of [text].flat()) stdout.write(write)
|
||||
expect(written).toEqual(commits)
|
||||
})
|
||||
|
||||
// After the frame the published tail is 4; the queued newline resets it.
|
||||
test("the predicted tail column counts every cell of a trailing wide character", async () => {
|
||||
const stdout = createTestStdout(24, 10)
|
||||
const { renderer } = await setup({ stdout })
|
||||
const { renderer, stdout, frame } = await setupTerminal()
|
||||
stdout.write("ab二")
|
||||
await frame()
|
||||
stdout.write("\nab二")
|
||||
let tailColumn = -1
|
||||
renderer.writeToScrollback(({ renderContext, tailColumn: tail }) => {
|
||||
tailColumn = tail
|
||||
|
||||
Reference in new issue
Block a user