fix(tui): replay unresolved width epochs
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:
@@ -25,11 +25,14 @@ export class Text implements Component {
|
||||
#paddingY: number; // Top/bottom padding
|
||||
#customBgFn?: (text: string) => string;
|
||||
#styleFn?: (text: string) => string;
|
||||
#widthEpochRevision = 0;
|
||||
|
||||
#ignoreTight = false;
|
||||
|
||||
setIgnoreTight(ignore: boolean): this {
|
||||
if (this.#ignoreTight === ignore) return this;
|
||||
this.#ignoreTight = ignore;
|
||||
this.#widthEpochRevision++;
|
||||
this.invalidate();
|
||||
return this;
|
||||
}
|
||||
@@ -60,15 +63,21 @@ export class Text implements Component {
|
||||
this.#cachedWidth = undefined;
|
||||
this.#cachedWidthEpoch = undefined;
|
||||
this.#cachedLines = undefined;
|
||||
this.#widthEpochRevision++;
|
||||
return true;
|
||||
}
|
||||
|
||||
getNativeScrollbackWidthEpochRevision(): number {
|
||||
return this.#widthEpochRevision;
|
||||
}
|
||||
|
||||
setCustomBgFn(customBgFn?: (text: string) => string): void {
|
||||
this.#customBgFn = customBgFn;
|
||||
this.#cachedText = undefined;
|
||||
this.#cachedWidth = undefined;
|
||||
this.#cachedWidthEpoch = undefined;
|
||||
this.#cachedLines = undefined;
|
||||
this.#widthEpochRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +92,7 @@ export class Text implements Component {
|
||||
this.#cachedWidth = undefined;
|
||||
this.#cachedWidthEpoch = undefined;
|
||||
this.#cachedLines = undefined;
|
||||
this.#widthEpochRevision++;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
+26
-6
@@ -1287,6 +1287,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 unresolved captured source boundary was replayed from row zero. While
|
||||
// its live region remains pinned, advance the baseline only through rows
|
||||
// actually emitted; a reported final seam may otherwise skip deferred rows.
|
||||
#widthEpochReplayUnresolved = false;
|
||||
// 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.
|
||||
@@ -3495,14 +3499,15 @@ export class TUI extends Container {
|
||||
if (resizeEventOccurred) this.#forgetHardwareCursorState();
|
||||
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
||||
const widthEpochOccurred = widthChanged || (resizeEventOccurred && this.#multiplexerWidthEpochPending);
|
||||
const widthEpochBoundary = this.#multiplexerWidthEpochBoundary;
|
||||
const widthEpochSourceBoundary = widthEpochOccurred
|
||||
? this.resolveNativeScrollbackWidthEpoch(this.#multiplexerWidthEpochBoundary)
|
||||
? this.resolveNativeScrollbackWidthEpoch(widthEpochBoundary)
|
||||
: undefined;
|
||||
const widthEpochCurrentRows = widthEpochOccurred
|
||||
? this.#getNativeScrollbackWidthEpochCurrentRows(this.#multiplexerWidthEpochBoundary)
|
||||
? this.#getNativeScrollbackWidthEpochCurrentRows(widthEpochBoundary)
|
||||
: undefined;
|
||||
const widthEpochAppendOnly = widthEpochOccurred
|
||||
? this.#isNativeScrollbackWidthEpochAppendOnly(this.#multiplexerWidthEpochBoundary)
|
||||
? this.#isNativeScrollbackWidthEpochAppendOnly(widthEpochBoundary)
|
||||
: true;
|
||||
if (resizeEventOccurred) {
|
||||
this.#multiplexerWidthEpochBoundary = undefined;
|
||||
@@ -3657,6 +3662,14 @@ export class TUI extends Container {
|
||||
this.#widthEpochOverlayReplayPending = true;
|
||||
}
|
||||
const replayUnresolvedOverlayFrame = widthEpochReset && this.#widthEpochOverlayReplayPending;
|
||||
const replayUnresolvedWidthEpoch =
|
||||
replayUnresolvedOverlayFrame ||
|
||||
(widthEpochReset && liveRegionPinned && this.#widthEpochReplayUnresolved) ||
|
||||
(widthEpochReset &&
|
||||
resizeHadPendingRender &&
|
||||
widthEpochBoundary !== undefined &&
|
||||
widthEpochSourceBoundary === undefined);
|
||||
if (replayUnresolvedWidthEpoch) prevWindowTop = 0;
|
||||
|
||||
// 4. Classify. A resize is an explicit user gesture: normally the engine
|
||||
// erases and replays so history rewraps at the new geometry (the reader
|
||||
@@ -3697,10 +3710,11 @@ 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 = replayUnresolvedOverlayFrame
|
||||
this.#widthEpochBaselineRows = replayUnresolvedWidthEpoch
|
||||
? 0
|
||||
: (widthEpochSourceBoundary ??
|
||||
(resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength));
|
||||
this.#widthEpochReplayUnresolved = replayUnresolvedWidthEpoch;
|
||||
windowTop = Math.max(0, frameLength - height);
|
||||
chunkTo = this.#committedRows;
|
||||
widthEpochAppendFrom = this.#widthEpochBaselineRows;
|
||||
@@ -3865,6 +3879,7 @@ export class TUI extends Container {
|
||||
this.#clearScrollbackOnNextRender = false;
|
||||
this.#hasEverRendered = true;
|
||||
this.#widthEpochBaselineRows = undefined;
|
||||
this.#widthEpochReplayUnresolved = false;
|
||||
this.#widthEpochOverlayReplayPending = false;
|
||||
this.#widthEpochCommittedPrefix = undefined;
|
||||
this.#publishCommittedRows();
|
||||
@@ -3880,7 +3895,11 @@ export class TUI extends Container {
|
||||
let scrollRows: number;
|
||||
let commitFrom: number;
|
||||
let commitTo: number;
|
||||
if (logicalAppend && !logicalPrefixAppend) {
|
||||
if (replayUnresolvedWidthEpoch) {
|
||||
commitFrom = 0;
|
||||
commitTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
|
||||
scrollRows = commitTo;
|
||||
} else if (logicalAppend && !logicalPrefixAppend) {
|
||||
const sourceWindowTop = Math.max(0, widthEpochSourceBoundary - height);
|
||||
const logicalSuffixRows = Math.max(0, widthEpochCurrentRows - widthEpochSourceBoundary);
|
||||
const appendWindowMovement = Math.max(0, windowTop - sourceWindowTop);
|
||||
@@ -3926,10 +3945,11 @@ export class TUI extends Container {
|
||||
if (!hasVisibleOverlay) {
|
||||
this.#widthEpochOverlayReplayPending = false;
|
||||
if (liveRegionPinned) {
|
||||
this.#widthEpochBaselineRows = widthEpochAppendTo;
|
||||
this.#widthEpochBaselineRows = this.#widthEpochReplayUnresolved ? commitTo : widthEpochAppendTo;
|
||||
this.#windowTopRow = logicalAppend ? windowTop : prevWindowTop + scrollRows;
|
||||
} else {
|
||||
this.#widthEpochBaselineRows = frameLength;
|
||||
this.#widthEpochReplayUnresolved = false;
|
||||
this.#windowTopRow = windowTop;
|
||||
}
|
||||
this.#committedRows += scrollRows;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type RenderTimer,
|
||||
TUI,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui/components/text";
|
||||
import { VirtualTerminal } from "./virtual-terminal";
|
||||
|
||||
// Regression test for https://github.com/can1357/oh-my-pi/issues/2088
|
||||
@@ -121,6 +122,20 @@ class RecoveringWrappingLinesComponent extends WrappingLinesComponent implements
|
||||
}
|
||||
}
|
||||
|
||||
class UnresolvedWrappingLinesComponent extends WrappingLinesComponent implements NativeScrollbackWidthEpoch {
|
||||
captureNativeScrollbackWidthEpoch(): unknown {
|
||||
return {};
|
||||
}
|
||||
|
||||
resolveNativeScrollbackWidthEpoch(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getNativeScrollbackWidthEpochRows(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class WidthLabelComponent implements Component {
|
||||
invalidate(): void {}
|
||||
|
||||
@@ -337,9 +352,10 @@ class WrappingStreamComponent implements Component, NativeScrollbackLiveRegion,
|
||||
}
|
||||
}
|
||||
|
||||
class PinnedMutableLinesComponent implements Component, NativeScrollbackLiveRegion {
|
||||
class PinnedMutableLinesComponent implements Component, NativeScrollbackLiveRegion, NativeScrollbackWidthEpoch {
|
||||
#lines: string[];
|
||||
#pinned = true;
|
||||
#finalBoundary = 0;
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.#lines = [...lines];
|
||||
@@ -349,6 +365,10 @@ class PinnedMutableLinesComponent implements Component, NativeScrollbackLiveRegi
|
||||
this.#lines = [...lines];
|
||||
}
|
||||
|
||||
setFinalBoundary(rows: number): void {
|
||||
this.#finalBoundary = rows;
|
||||
}
|
||||
|
||||
finalize(): void {
|
||||
this.#pinned = false;
|
||||
}
|
||||
@@ -359,8 +379,20 @@ class PinnedMutableLinesComponent implements Component, NativeScrollbackLiveRegi
|
||||
return this.#lines.map(line => line.slice(0, width));
|
||||
}
|
||||
|
||||
captureNativeScrollbackWidthEpoch(): unknown {
|
||||
return {};
|
||||
}
|
||||
|
||||
resolveNativeScrollbackWidthEpoch(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getNativeScrollbackWidthEpochRows(): number {
|
||||
return this.#lines.length;
|
||||
}
|
||||
|
||||
getNativeScrollbackLiveRegionStart(): number | undefined {
|
||||
return this.#pinned ? 0 : undefined;
|
||||
return this.#pinned ? this.#finalBoundary : undefined;
|
||||
}
|
||||
|
||||
isNativeScrollbackLiveRegionPinned(): boolean {
|
||||
@@ -522,6 +554,19 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("propagates rendered-height changes from mutable text descendants", () => {
|
||||
const child = new Text("one", 0, 0);
|
||||
const container = new Container();
|
||||
container.addChild(child);
|
||||
container.render(40);
|
||||
const initialRevision = container.getNativeScrollbackWidthEpochRevision();
|
||||
|
||||
child.setText("one\ntwo");
|
||||
container.render(40);
|
||||
|
||||
expect(container.getNativeScrollbackWidthEpochRevision()).toBeGreaterThan(initialRevision);
|
||||
});
|
||||
|
||||
it("coalesces a burst of multiplexer resize events into a single settled render", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
@@ -1448,6 +1493,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
marker,
|
||||
).toHaveLength(1);
|
||||
}
|
||||
expect(buffer.filter(line => line === "")).toHaveLength(9);
|
||||
expect(visible(term).slice(-4)).toEqual(["draft-00", "draft-01", "draft-02", "editor"]);
|
||||
|
||||
const settledBaseY = term.getBufferPosition().baseY;
|
||||
@@ -1910,6 +1956,51 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replays unresolved output queued while a widening epoch reduces reflow rows", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const initial = Array.from(
|
||||
{ length: 12 },
|
||||
(_value, index) => `unresolved-initial-${index.toString().padStart(2, "0")} ${"I".repeat(20)}`,
|
||||
);
|
||||
const appended = Array.from(
|
||||
{ length: 8 },
|
||||
(_value, index) => `unresolved-new-${index.toString().padStart(2, "0")}`,
|
||||
);
|
||||
const settledInitial = ["changed-prefix-00", "changed-prefix-01", ...initial.slice(2)];
|
||||
const term = new VirtualTerminal(17, 6, 10_000);
|
||||
const component = new UnresolvedWrappingLinesComponent(initial);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
term.resize(40, 4);
|
||||
component.setLines([...settledInitial, ...appended]);
|
||||
tui.requestRender();
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
|
||||
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
|
||||
for (const line of settledInitial.slice(0, 2)) {
|
||||
expect(
|
||||
buffer.some(bufferLine => bufferLine.includes(line)),
|
||||
line,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const line of appended) {
|
||||
const marker = line;
|
||||
expect(
|
||||
buffer.some(bufferLine => bufferLine.includes(marker)),
|
||||
marker,
|
||||
).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("backfills unresolved growth queued behind an overlay during width settlement", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const initial = Array.from(
|
||||
@@ -1991,26 +2082,43 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
term.resize(17, 4);
|
||||
component.setLines(final);
|
||||
tui.requestRender();
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
|
||||
component.setLines(final);
|
||||
tui.requestRender(true);
|
||||
expect(term.getBufferPosition().baseY).toBe(0);
|
||||
expect(visible(term)).toEqual(final.slice(-4));
|
||||
term.resize(23, 4);
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
expect(term.getBufferPosition().baseY).toBe(0);
|
||||
expect(visible(term)).toEqual(final.slice(-4));
|
||||
|
||||
const extended = [...final, "pinned-10", "pinned-11"];
|
||||
component.setLines(extended);
|
||||
component.setFinalBoundary(6);
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
component.setFinalBoundary(8);
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
|
||||
component.finalize();
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
expect(term.getBufferPosition().baseY).toBe(6);
|
||||
expect(term.getBufferPosition().baseY).toBe(8);
|
||||
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
|
||||
for (const line of final) {
|
||||
for (const line of extended) {
|
||||
expect(
|
||||
buffer.filter(bufferLine => bufferLine === line),
|
||||
line,
|
||||
).toHaveLength(1);
|
||||
}
|
||||
const finalizedBaseY = term.getBufferPosition().baseY;
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
expect(term.getBufferPosition().baseY).toBe(finalizedBaseY);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user