fix(tui): preserve resize top spacer with frame context

The resize fast path composed only the visible window, so a blank first row
whose scaled OSC 66 heading had scrolled just above the fold could not be
recognized and was erased, clearing the glyph's lower half until the settle
repaint (a resize flicker into the invisible-placeholder state).

#composeResizeViewport now collects a bounded band of context rows above the
fold (up to the protocol's max reserved-row count) and returns the visible
window preceded by that context in frame order; #emitResizeViewport resolves
each spacer width against the framed array. Add a regression driving a width
drag with the heading one row above the viewport top.
This commit is contained in:
roboomp
2026-08-12 06:26:01 +00:00
parent af5554c9c8
commit 4af147cd42
2 changed files with 79 additions and 18 deletions
+50 -18
View File
@@ -975,6 +975,12 @@ export class TUI extends Container {
// the drag has been quiet for this long. Multiplexer sessions keep their own
// debounce (`#armMultiplexerResizeTimer`, see #2088) and never take this path.
static readonly #RESIZE_VIEWPORT_SETTLE_MS = 120;
// Extra rows composed above the resize viewport so a first-visible blank can
// still be identified as the reserved lower half of a scaled OSC 66 heading
// that scrolled just above the fold (issue #8318). A scale-`s` heading
// reserves `s - 1` rows and the protocol caps `s` at 7, so six rows of
// context classify every legal heading exactly.
static readonly #RESIZE_SPACER_CONTEXT_ROWS = 6;
// Ghostty can drop Kitty graphics commands sent during its first post-startup
// settle window, leaving only Unicode placeholder cells. Hold the first image
// paint until that window has passed; later images render normally.
@@ -3938,43 +3944,59 @@ export class TUI extends Container {
// off a partial walk. The settle paint's own beginPass()/endPass() is the
// authoritative accounting, and its beginPass() wipes these frames.
this.#imageBudget.beginPass(true);
const { window, contentRows } = this.#composeResizeViewport(width, height);
this.#emitResizeViewport(window, height, contentRows, width);
const { framed, viewportTop, contentRows } = this.#composeResizeViewport(width, height);
this.#emitResizeViewport(framed, viewportTop, height, contentRows, width);
this.#resizeViewportPaintCount += 1;
}
/**
* Build the viewport window for a resize fast-path frame: the bottom
* `height` rows of the would-be full frame, collected bottom-up across root
* children. {@link ViewportTailProvider}s (the transcript) yield only their
* tail; the small live-region children below render in full — so every child
* children, plus up to {@link #RESIZE_SPACER_CONTEXT_ROWS} rows above the
* fold. {@link ViewportTailProvider}s (the transcript) yield only their tail;
* the small live-region children below render in full — so every child
* entirely above the fold is skipped. A frame shorter than the viewport is
* top-aligned with blank rows below, matching the full-paint window geometry
* (windowTop = max(0, frameLength - height)). Cursor markers are stripped
* (the drag hides the hardware cursor) and rows are width-fitted via the
* stateless preparer, so no persistent prepared-frame cache is touched.
*
* Returns the visible rows preceded by the context rows in frame order
* (`framed`), the index where the viewport begins (`viewportTop`), and the
* visible content count. The context rows are never emitted; they only let
* {@link #osc66SpacerGlyphWidth} see a scaled heading that scrolled just
* above the fold, so its reserved rows are preserved instead of erased
* (issue #8318).
*/
#composeResizeViewport(width: number, height: number): { window: readonly string[]; contentRows: number } {
const tail: string[] = []; // bottom-first
#composeResizeViewport(
width: number,
height: number,
): { framed: readonly string[]; viewportTop: number; contentRows: number } {
const maxRows = height + TUI.#RESIZE_SPACER_CONTEXT_ROWS;
const tail: string[] = []; // bottom-first: viewport rows plus context above
const children = this.children;
for (let i = children.length - 1; i >= 0 && tail.length < height; i--) {
for (let i = children.length - 1; i >= 0 && tail.length < maxRows; i--) {
const child = children[i]!;
const provider = asViewportTailProvider(child);
const rows = provider ? provider.renderViewportTail(width, height - tail.length) : child.render(width);
for (let r = rows.length - 1; r >= 0 && tail.length < height; r--) {
const rows = provider ? provider.renderViewportTail(width, maxRows - tail.length) : child.render(width);
for (let r = rows.length - 1; r >= 0 && tail.length < maxRows; r--) {
tail.push(rows[r]!);
}
}
const count = tail.length;
const contentRows = Math.min(tail.length, height);
const extra = tail.length - contentRows; // context rows above the fold
const window: string[] = new Array(height);
for (let screenRow = 0; screenRow < height; screenRow++) {
// `tail` holds the bottom `count` frame rows, bottom-first. They fill
// the viewport when the frame overflows it and sit at the top (blanks
// below) when it underflows.
window[screenRow] = screenRow < count ? tail[count - 1 - screenRow]! : "";
// `tail` holds the bottom rows first. The bottom `contentRows` fill the
// viewport (top-aligned with blanks below on underflow).
window[screenRow] = screenRow < contentRows ? tail[contentRows - 1 - screenRow]! : "";
}
this.#extractCursorMarkers(window);
return { window: this.#prepareLinesArray(window, width), contentRows: count };
// Frame order: context rows above the fold (top-first) then the window.
const framed: string[] = new Array(extra + height);
for (let k = 0; k < extra; k++) framed[k] = tail[tail.length - 1 - k]!;
for (let screenRow = 0; screenRow < height; screenRow++) framed[extra + screenRow] = window[screenRow]!;
return { framed: this.#prepareLinesArray(framed, width), viewportTop: extra, contentRows };
}
/**
@@ -4044,19 +4066,29 @@ export class TUI extends Container {
* flash, #5854). Normal-screen history is rebuilt once at settle via
* `#emitFullPaint`.
*/
#emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
#emitResizeViewport(
framed: readonly string[],
viewportTop: number,
height: number,
contentRows: number,
width: number,
): void {
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
const altEnter = widthChanged ? this.#enterResizeAltSequence() : "";
let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
for (let r = 0; r < height; r++) {
if (r > 0) buffer += "\r\n";
// `framed` carries context rows above the fold; the visible window
// starts at `viewportTop`, and the spacer lookup scans within `framed`
// so a heading just above the fold is still seen (issue #8318).
const idx = viewportTop + r;
buffer += this.#lineRewriteSequence(
window[r] ?? "",
framed[idx] ?? "",
width,
r,
-1,
this.#committedRows,
this.#osc66SpacerGlyphWidth(window, r),
this.#osc66SpacerGlyphWidth(framed, idx),
);
}
// Park the hardware cursor at the real content bottom, not the padded
@@ -209,4 +209,33 @@ describe("issue #8318: scaled OSC 66 headings survive repaint and resize", () =>
tui.stop();
}
});
it("preserves the top spacer when the heading scrolls above the resize viewport", async () => {
const term = new VirtualTerminal(80, 4);
const tui = new TUI(term);
// windowTop = frameLength - height = 7 - 4 = 3. The heading sits at row 2
// (just above the fold) and its reserved spacer at row 3 = window[0], so
// the resize fast path composes it as the first visible row.
tui.addChild(new RawLines(["f0", "f1", `${OSC66}s=2;Heading${ST}`, "", "b0", "b1", "b2"]));
const writes = captureWrites(term);
try {
tui.start();
await settle(term);
writes.length = 0;
// A width drag paints the viewport synchronously via #emitResizeViewport
// before the settle full paint. Capture that throwaway frame directly.
term.resize(70, 4);
const viewportPaint = writes.find(
write => write.includes("\x1b[H") && !write.includes("\x1b[2J") && !write.includes("\x1b[3J"),
);
expect(viewportPaint).toBeDefined();
// Row 0 (the spacer) is emitted right after the final cursor-home.
const seg0 = viewportPaint!.split("\r\n")[0]!;
const row0 = seg0.slice(seg0.lastIndexOf("\x1b[H") + 3);
expectClearsRightOfGlyph(row0, 14);
} finally {
tui.stop();
}
});
});