fix(tui): backfill unresolved overlay growth
Amp-Thread-ID: https://ampcode.com/threads/T-019ff125-f53d-739b-8462-6027fe70f914 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
+29
-6
@@ -1182,6 +1182,10 @@ export class TUI extends Container {
|
||||
// index in an in-place resize session. This is the current-width frame
|
||||
// baseline; only later physical-row growth may advance the append ledger.
|
||||
#widthEpochBaselineRows: number | undefined;
|
||||
// An overlay-covered width reset with unresolved pending growth owes a
|
||||
// conservative replay from row zero. Sticky across later covered resizes —
|
||||
// even if their source boundary resolves — until an uncovered paint pays it.
|
||||
#widthEpochOverlayReplayPending = false;
|
||||
// Same-width snapshots physically appended after a width transition. The
|
||||
// ordinary committed prefix includes opaque old-width native rows and can
|
||||
// no longer be indexed against the reflowed frame; this local ledger lets
|
||||
@@ -2198,7 +2202,7 @@ export class TUI extends Container {
|
||||
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
|
||||
// `requestRender(true)`, so the clear-scrollback intent is preserved.
|
||||
if (this.#multiplexerResizeTimer) {
|
||||
this.#armMultiplexerResizeTimer({ clearScrollback: !isMultiplexerSession() });
|
||||
this.#armMultiplexerResizeTimer({ clearScrollback: !isMultiplexerSession(), hasPendingRender: true });
|
||||
return;
|
||||
}
|
||||
this.#prepareForcedRender(!isMultiplexerSession());
|
||||
@@ -2223,6 +2227,7 @@ export class TUI extends Container {
|
||||
if (this.#multiplexerResizeTimer) {
|
||||
this.#armMultiplexerResizeTimer({
|
||||
clearScrollback: options?.clearScrollback === true,
|
||||
hasPendingRender: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -3467,6 +3472,14 @@ export class TUI extends Container {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Without a logical source boundary, pending growth folded into an
|
||||
// overlay-covered width reset cannot be separated from reflow. Replay
|
||||
// conservatively from row zero after the overlay closes: duplication is
|
||||
// preferable to dropping rows that were never emitted anywhere.
|
||||
if (widthEpochReset && hasVisibleOverlay && widthEpochSourceBoundary === undefined && resizeHadPendingRender) {
|
||||
this.#widthEpochOverlayReplayPending = true;
|
||||
}
|
||||
const replayUnresolvedOverlayFrame = widthEpochReset && this.#widthEpochOverlayReplayPending;
|
||||
|
||||
// 4. Classify. A resize is an explicit user gesture: normally the engine
|
||||
// erases and replays so history rewraps at the new geometry (the reader
|
||||
@@ -3507,9 +3520,10 @@ export class TUI extends Container {
|
||||
// Components without the source contract retain the conservative
|
||||
// legacy fallback, but never compare cross-width counts when a marker
|
||||
// resolved successfully.
|
||||
this.#widthEpochBaselineRows =
|
||||
widthEpochSourceBoundary ??
|
||||
(resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength);
|
||||
this.#widthEpochBaselineRows = replayUnresolvedOverlayFrame
|
||||
? 0
|
||||
: (widthEpochSourceBoundary ??
|
||||
(resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength));
|
||||
windowTop = Math.max(0, frameLength - height);
|
||||
chunkTo = this.#committedRows;
|
||||
widthEpochAppendFrom = this.#widthEpochBaselineRows;
|
||||
@@ -3674,13 +3688,17 @@ export class TUI extends Container {
|
||||
this.#clearScrollbackOnNextRender = false;
|
||||
this.#hasEverRendered = true;
|
||||
this.#widthEpochBaselineRows = undefined;
|
||||
this.#widthEpochOverlayReplayPending = false;
|
||||
this.#widthEpochCommittedPrefix = undefined;
|
||||
this.#publishCommittedRows();
|
||||
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
||||
return;
|
||||
}
|
||||
if (this.#widthEpochBaselineRows !== undefined) {
|
||||
const logicalAppend = widthEpochSourceBoundary !== undefined && widthEpochCurrentRows !== undefined;
|
||||
const logicalAppend =
|
||||
!replayUnresolvedOverlayFrame &&
|
||||
widthEpochSourceBoundary !== undefined &&
|
||||
widthEpochCurrentRows !== undefined;
|
||||
const logicalPrefixAppend = logicalAppend && widthEpochAppendOnly;
|
||||
let scrollRows: number;
|
||||
let commitFrom: number;
|
||||
@@ -3729,6 +3747,7 @@ export class TUI extends Container {
|
||||
});
|
||||
this.#pendingAltExit = "";
|
||||
if (!hasVisibleOverlay) {
|
||||
this.#widthEpochOverlayReplayPending = false;
|
||||
if (liveRegionPinned) {
|
||||
this.#widthEpochBaselineRows = widthEpochAppendTo;
|
||||
this.#windowTopRow = logicalAppend ? windowTop : prevWindowTop + scrollRows;
|
||||
@@ -3764,7 +3783,11 @@ export class TUI extends Container {
|
||||
// The overlay freezes commits and subsequent hidden-growth movement,
|
||||
// but the resize itself changed physical-row coordinates. Rebase the
|
||||
// window reference once so growth backfills from the settled width.
|
||||
this.#windowTopRow = logicalAppend ? Math.max(0, widthEpochSourceBoundary! - height) : windowTop;
|
||||
this.#windowTopRow = replayUnresolvedOverlayFrame
|
||||
? 0
|
||||
: logicalAppend
|
||||
? Math.max(0, widthEpochSourceBoundary! - height)
|
||||
: windowTop;
|
||||
}
|
||||
if (widthEpochReset) {
|
||||
this.#widthEpochCommittedPrefix = {
|
||||
|
||||
@@ -93,6 +93,34 @@ class WrappingLinesComponent implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
class RecoveringWrappingLinesComponent extends WrappingLinesComponent implements NativeScrollbackWidthEpoch {
|
||||
#resolveAttempts = 0;
|
||||
#lastRows = 0;
|
||||
|
||||
override render(width: number): string[] {
|
||||
const rows = super.render(width);
|
||||
this.#lastRows = rows.length;
|
||||
return rows;
|
||||
}
|
||||
|
||||
captureNativeScrollbackWidthEpoch(): unknown {
|
||||
return {};
|
||||
}
|
||||
|
||||
resolveNativeScrollbackWidthEpoch(): number | undefined {
|
||||
this.#resolveAttempts++;
|
||||
return this.#resolveAttempts === 1 ? undefined : this.#lastRows;
|
||||
}
|
||||
|
||||
getNativeScrollbackWidthEpochRows(): number {
|
||||
return this.#lastRows;
|
||||
}
|
||||
|
||||
isNativeScrollbackWidthEpochAppendOnly(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class WidthLabelComponent implements Component {
|
||||
invalidate(): void {}
|
||||
|
||||
@@ -1418,6 +1446,74 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("backfills unresolved growth queued behind an overlay during width settlement", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const initial = Array.from(
|
||||
{ length: 8 },
|
||||
(_value, index) => `initial-${index.toString().padStart(2, "0")} ${"I".repeat(20)}`,
|
||||
);
|
||||
const appended = Array.from(
|
||||
{ length: 8 },
|
||||
(_value, index) => `hidden-${index.toString().padStart(2, "0")} ${"H".repeat(20)}`,
|
||||
);
|
||||
const term = new VirtualTerminal(17, 6, 10_000);
|
||||
const component = new RecoveringWrappingLinesComponent(initial);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
const overlay = tui.showOverlay(new MutableLinesComponent(["overlay"]), {
|
||||
anchor: "top-left",
|
||||
row: 1,
|
||||
col: 1,
|
||||
});
|
||||
await settle(term);
|
||||
|
||||
const writes = captureWrites(term);
|
||||
term.resize(40, 6);
|
||||
component.setLines([...initial, ...appended]);
|
||||
tui.requestRender(true);
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
|
||||
expect(writes.join("")).not.toContain("\r\n");
|
||||
const coveredBaseY = term.getBufferPosition().baseY;
|
||||
writes.length = 0;
|
||||
|
||||
// A later pure width reset under the same overlay must retain the
|
||||
// conservative replay debt established by the growth frame.
|
||||
term.resize(50, 6);
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
expect(term.getBufferPosition().baseY).toBe(coveredBaseY);
|
||||
expect(writes.join("")).not.toContain("\r\n");
|
||||
|
||||
writes.length = 0;
|
||||
overlay.hide();
|
||||
term.resize(60, 6);
|
||||
tui.requestRender(true);
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
|
||||
expect(term.getBufferPosition().baseY).toBeGreaterThan(coveredBaseY);
|
||||
expect(writes.join("")).toContain("\r\n");
|
||||
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
|
||||
for (const line of appended) {
|
||||
const marker = line.slice(0, line.indexOf(" "));
|
||||
expect(
|
||||
buffer.filter(bufferLine => bufferLine.includes(marker)),
|
||||
marker,
|
||||
).toHaveLength(1);
|
||||
}
|
||||
expect(visible(term)).toEqual(appended.slice(-6));
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("defers pinned live-region growth until width-epoch finalization", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const initial = ["pinned-00", "pinned-01"];
|
||||
|
||||
Reference in New Issue
Block a user