diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 63d1ecd89..74a05ab64 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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 diff --git a/packages/tui/test/issue-8318-repro.test.ts b/packages/tui/test/issue-8318-repro.test.ts index df7d27ffe..c64017b51 100644 --- a/packages/tui/test/issue-8318-repro.test.ts +++ b/packages/tui/test/issue-8318-repro.test.ts @@ -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(); + } + }); });