fix(tui): retain hidden resize 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:
+40
-11
@@ -732,10 +732,15 @@ export class Container
|
||||
? currentRows.length !== captured.rowCount
|
||||
: getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
|
||||
) {
|
||||
if (currentRows?.length === captured.rowCount) {
|
||||
rows += currentRows.length;
|
||||
continue;
|
||||
let capturedRows = 0;
|
||||
for (let index = trailingIndex; index < marker.trailing.length; index++) {
|
||||
capturedRows += marker.trailing[index]!.rowCount;
|
||||
}
|
||||
let settledRows = 0;
|
||||
for (let index = currentIndex; index < this.#memoChildLines.length; index++) {
|
||||
settledRows += this.#memoChildLines[index]!.length;
|
||||
}
|
||||
rows += Math.min(capturedRows, settledRows);
|
||||
break;
|
||||
}
|
||||
rows += currentRows.length;
|
||||
@@ -793,7 +798,9 @@ export class Container
|
||||
(captured.revision === undefined
|
||||
? currentRows.length !== captured.rowCount
|
||||
: getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision);
|
||||
if (changed && captured.hadRows && currentRows?.length === captured.rowCount) return false;
|
||||
if (changed && (captured.hadRows || marker.trailing.slice(trailingIndex + 1).some(child => child.hadRows))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary);
|
||||
const currentRows = source?.getNativeScrollbackWidthEpochRows();
|
||||
@@ -1299,6 +1306,10 @@ export class TUI extends Container {
|
||||
// opaque marker survives width reflow and resolves after the settled render.
|
||||
#multiplexerWidthEpochBoundary: unknown;
|
||||
#multiplexerWidthEpochPending = false;
|
||||
// Normal-buffer boundary borrowed by a fullscreen alt overlay. If the host
|
||||
// resizes while the transcript is hidden, this remains the physical seam
|
||||
// from before the overlay instead of adopting hidden growth on exit.
|
||||
#altWidthEpochBoundary: unknown;
|
||||
|
||||
// Frame row currently mapped to screen row 0. Monotonic between full
|
||||
// paints: a shrink never re-exposes scrolled-off rows (they cannot be
|
||||
@@ -1516,19 +1527,24 @@ export class TUI extends Container {
|
||||
for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
|
||||
const captured = marker.trailing[trailingIndex]!;
|
||||
const candidate = this.#frameSegments[marker.sourceIndex + 1 + trailingIndex];
|
||||
// Changed/removed tails are not cross-width comparable. Treat their
|
||||
// entire settled contribution as new in the current boundary: this can
|
||||
// conservatively duplicate rows, but cannot omit displaced transcript.
|
||||
// Changed/removed tails are not individually cross-width comparable.
|
||||
// Preserve the shared physical row count of the remaining tail as one
|
||||
// span; only aggregate height growth belongs to the current suffix.
|
||||
if (
|
||||
candidate?.component !== captured.component ||
|
||||
(captured.revision === undefined
|
||||
? candidate.rowCount !== captured.rowCount
|
||||
: candidate.widthEpochRevision !== captured.revision)
|
||||
) {
|
||||
if (candidate?.rowCount === captured.rowCount) {
|
||||
rows += candidate.rowCount;
|
||||
continue;
|
||||
let capturedRows = 0;
|
||||
for (let index = trailingIndex; index < marker.trailing.length; index++) {
|
||||
capturedRows += marker.trailing[index]!.rowCount;
|
||||
}
|
||||
let settledRows = 0;
|
||||
for (let index = marker.sourceIndex + 1 + trailingIndex; index < this.#frameSegments.length; index++) {
|
||||
settledRows += this.#frameSegments[index]!.rowCount;
|
||||
}
|
||||
rows += Math.min(capturedRows, settledRows);
|
||||
break;
|
||||
}
|
||||
rows += candidate.rowCount;
|
||||
@@ -1566,7 +1582,12 @@ export class TUI extends Container {
|
||||
(captured.revision === undefined
|
||||
? current.rowCount !== captured.rowCount
|
||||
: current.widthEpochRevision !== captured.revision);
|
||||
if (changed && captured.rowCount > 0 && current?.rowCount === captured.rowCount) return false;
|
||||
if (
|
||||
changed &&
|
||||
(captured.rowCount > 0 || marker.trailing.slice(trailingIndex + 1).some(segment => segment.rowCount > 0))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary);
|
||||
const currentRows = source?.getNativeScrollbackWidthEpochRows();
|
||||
@@ -2041,6 +2062,12 @@ export class TUI extends Container {
|
||||
if (this.#altEnterWidth === this.terminal.columns && this.#altEnterHeight !== this.terminal.rows) {
|
||||
this.#altToggleResizesInPlace = true;
|
||||
}
|
||||
if (this.#previousWidth > 0 && this.terminal.columns !== this.#previousWidth) {
|
||||
this.#multiplexerWidthEpochPending = true;
|
||||
if (this.#multiplexerWidthEpochBoundary === undefined) {
|
||||
this.#multiplexerWidthEpochBoundary = this.#altWidthEpochBoundary;
|
||||
}
|
||||
}
|
||||
this.#resizeEventPending = true;
|
||||
this.requestRender();
|
||||
return;
|
||||
@@ -3323,6 +3350,7 @@ export class TUI extends Container {
|
||||
this.#altPreviousLines = [];
|
||||
this.#altEnterWidth = width;
|
||||
this.#altEnterHeight = height;
|
||||
this.#altWidthEpochBoundary = this.captureNativeScrollbackWidthEpoch();
|
||||
} else if (!wantAlt && this.#altActive) {
|
||||
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
|
||||
const enhancementExit = this.#keyboardEnhancementExit();
|
||||
@@ -3340,6 +3368,7 @@ export class TUI extends Container {
|
||||
this.#altActive = false;
|
||||
this.#altMouseTrackingActive = false;
|
||||
this.#altPreviousLines = [];
|
||||
this.#altWidthEpochBoundary = undefined;
|
||||
// A resize while on the alt buffer reflowed the terminal's saved
|
||||
// normal screen; it no longer matches our accounting, so force the
|
||||
// geometry rebuild path instead of a stale diff. A pure height change
|
||||
|
||||
@@ -1092,6 +1092,51 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retains transcript growth hidden by a fullscreen overlay across resize", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 6, 10_000);
|
||||
const tui = new TUI(term);
|
||||
const transcript = new WrappingStreamComponent();
|
||||
for (let index = 0; index < 8; index++) {
|
||||
transcript.append(`falt-${index.toString().padStart(2, "0")} ${"A".repeat(46)}`);
|
||||
}
|
||||
tui.addChild(transcript);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
const overlay = tui.showOverlay(new MutableLinesComponent(["fullscreen-overlay"]), {
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
margin: 0,
|
||||
fullscreen: true,
|
||||
});
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
|
||||
term.resize(17, 6);
|
||||
transcript.append(`falt-final ${"F".repeat(46)}`);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
overlay.hide();
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
|
||||
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const marker = `falt-${index.toString().padStart(2, "0")}`;
|
||||
expect(
|
||||
buffer.filter(line => line.includes(marker)),
|
||||
marker,
|
||||
).toHaveLength(1);
|
||||
}
|
||||
expect(buffer.filter(line => line.includes("falt-final"))).toHaveLength(1);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("retains transcript rows displaced by a trailing root that grows during resize settlement", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 6, 10_000);
|
||||
@@ -1204,7 +1249,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves populated tails after a replaced empty root", async () => {
|
||||
it("preserves populated tails after an empty root gains rows", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 6, 10_000);
|
||||
const tui = new TUI(term);
|
||||
@@ -1222,7 +1267,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
term.resize(17, 6);
|
||||
emptyStatus.setLines([]);
|
||||
emptyStatus.setLines(["status"]);
|
||||
tui.requestComponentRender(emptyStatus);
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
@@ -1235,7 +1280,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
marker,
|
||||
).toHaveLength(1);
|
||||
}
|
||||
expect(visible(term).at(-1)).toBe("editor");
|
||||
expect(visible(term).slice(-2)).toEqual(["status", "editor"]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -1331,8 +1376,10 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
const editor = new RevisionMutableLinesComponent(["editor"]);
|
||||
const editorRoot = new Container();
|
||||
editorRoot.addChild(editor);
|
||||
editorRoot.addChild(new MutableLinesComponent(["nested-footer"]));
|
||||
tui.addChild(transcript);
|
||||
tui.addChild(editorRoot);
|
||||
tui.addChild(new MutableLinesComponent(["root-footer"]));
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
@@ -1352,7 +1399,15 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
marker,
|
||||
).toHaveLength(1);
|
||||
}
|
||||
expect(visible(term).slice(-4)).toEqual(["draft-00", "draft-01", "draft-02", "editor"]);
|
||||
expect(buffer.filter(line => line === "")).toHaveLength(9);
|
||||
expect(visible(term)).toEqual([
|
||||
"draft-00",
|
||||
"draft-01",
|
||||
"draft-02",
|
||||
"editor",
|
||||
"nested-footer",
|
||||
"root-footer",
|
||||
]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -1443,7 +1498,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves populated nested tails after a replaced empty child", async () => {
|
||||
it("preserves populated nested tails after an empty child gains rows", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 6, 10_000);
|
||||
const tui = new TUI(term);
|
||||
@@ -1463,7 +1518,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
term.resize(17, 6);
|
||||
emptyStatus.setLines([]);
|
||||
emptyStatus.setLines(["status"]);
|
||||
tui.requestComponentRender(emptyStatus);
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
@@ -1476,7 +1531,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
marker,
|
||||
).toHaveLength(1);
|
||||
}
|
||||
expect(visible(term).at(-1)).toBe("editor");
|
||||
expect(visible(term).slice(-2)).toEqual(["status", "editor"]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user