Merge PR #8320: fix(tui): keep scaled OSC 66 heading reserved rows intact on repaint (@roboomp)
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed repeated pane-width changes corrupting native scrollback: direct HerdR panes now use the same ED3 source replay as direct terminals, preventing settled viewport paints from hardening host-reflowed soft wraps, while ED3-unsafe multiplexers retain opaque logical width epochs that append only source output queued during resize ([#6147](https://github.com/can1357/oh-my-pi/issues/6147), [#7026](https://github.com/can1357/oh-my-pi/issues/7026)).
|
||||
- Fixed scaled OSC 66 Markdown headings ("Large Headings" on Kitty) rendering as an invisible placeholder after a redraw or terminal resize: the blank row a `s`-scaled heading flows into was rewritten with an erase, which cleared the multicell glyph's lower half. The renderer now treats those reserved rows as untouchable across every repaint path — full replay, incremental diff, and the resize viewport — and covers all `s - 1` rows of scale ≥ 3 headings ([#8318](https://github.com/can1357/oh-my-pi/issues/8318)).
|
||||
|
||||
## [17.2.13] - 2026-08-11
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
encodeTextSized,
|
||||
getPaddingX,
|
||||
getSegmenter,
|
||||
isOsc66Line,
|
||||
padding,
|
||||
replaceTabs,
|
||||
truncateToWidth,
|
||||
@@ -43,15 +44,11 @@ function normalizeOsc8Terminators(text: string): string {
|
||||
}
|
||||
|
||||
// OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible
|
||||
// unit by the H1 render path. Like image-protocol lines, they must bypass
|
||||
// ANSI wrapping and width padding: re-wrapping splits/normalizes the sized span
|
||||
// (recomputing the explicit `w=` cell count and hoisting SGR out of the OSC
|
||||
// payload), and padding would append trailing cells past the doubled glyph.
|
||||
const OSC66_LINE_PREFIX = "\x1b]66;";
|
||||
|
||||
function isOsc66Line(line: string): boolean {
|
||||
return line.includes(OSC66_LINE_PREFIX);
|
||||
}
|
||||
// unit by the H1 render path. Like image-protocol lines, they bypass ANSI
|
||||
// wrapping and width padding (see `isOsc66Line` in ../utils): re-wrapping
|
||||
// splits/normalizes the sized span (recomputing the explicit `w=` cell count
|
||||
// and hoisting SGR out of the OSC payload), and padding would append trailing
|
||||
// cells past the doubled glyph.
|
||||
|
||||
function normalizeHtmlEntitiesForTerminal(raw: string): string {
|
||||
const parseCodePoint = (value: number): string => {
|
||||
|
||||
+139
-25
@@ -39,7 +39,9 @@ import {
|
||||
import {
|
||||
Ellipsis,
|
||||
extractSegments,
|
||||
isOsc66Line,
|
||||
normalizeTerminalOutput,
|
||||
osc66MaxScale,
|
||||
sliceByColumn,
|
||||
sliceWithWidth,
|
||||
truncateToWidth,
|
||||
@@ -1215,6 +1217,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.
|
||||
@@ -2611,6 +2619,7 @@ export class TUI extends Container {
|
||||
screenStart + i,
|
||||
segment.start + i,
|
||||
this.#committedRows,
|
||||
this.#osc66SpacerGlyphWidth(this.#preparedFrame, segment.start + i),
|
||||
);
|
||||
}
|
||||
const cursorControl = this.#cursorControlSequence(
|
||||
@@ -4296,7 +4305,42 @@ export class TUI extends Container {
|
||||
return col;
|
||||
}
|
||||
|
||||
#lineRewriteSequence(line: string, width: number, screenRow = -1, frameRow = -1, committedTo = -1): string {
|
||||
/**
|
||||
* Columns to preserve when `lines[index]` is a blank row that a scaled OSC 66
|
||||
* heading flows into, or `-1` when it is not such a row. A scale-`s` heading
|
||||
* occupies `s` rows and `visibleWidth` columns, so the `s - 1` blank rows
|
||||
* beneath it hold the multicell glyph's lower half; those columns must never
|
||||
* be erased or overdrawn or the glyph vanishes, leaving reserved-but-invisible
|
||||
* space (issue #8318). Scans upward across the contiguous blank run so every
|
||||
* reserved row of a scale ≥ 3 heading is covered, not just the first.
|
||||
*/
|
||||
#osc66SpacerGlyphWidth(lines: readonly string[], index: number): number {
|
||||
if (index <= 0 || lines[index] !== "") return -1;
|
||||
let gap = 1;
|
||||
while (index - gap > 0 && lines[index - gap] === "") gap++;
|
||||
const above = lines[index - gap];
|
||||
if (above === undefined || !isOsc66Line(above) || gap > osc66MaxScale(above) - 1) return -1;
|
||||
return visibleWidth(above);
|
||||
}
|
||||
|
||||
#lineRewriteSequence(
|
||||
line: string,
|
||||
width: number,
|
||||
screenRow = -1,
|
||||
frameRow = -1,
|
||||
committedTo = -1,
|
||||
spacerGlyphWidth = -1,
|
||||
): string {
|
||||
// Reserved lower half of a scaled OSC 66 heading. The glyph re-emitted on
|
||||
// the row above owns columns `[0, spacerGlyphWidth)` here, so preserve
|
||||
// them (any erase there clears the glyph — issue #8318) but still clear
|
||||
// stale cells to their right: a row can reflow from wider text into this
|
||||
// spacer, and the glyph write never covers those columns. Leading reset
|
||||
// keeps the erase on the default background (BCE).
|
||||
if (spacerGlyphWidth >= 0) {
|
||||
if (spacerGlyphWidth >= width) return "";
|
||||
return `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`;
|
||||
}
|
||||
if (TERMINAL.isImageLine(line)) {
|
||||
return ERASE_LINE + this.#imageLineSequence(line, screenRow, frameRow, committedTo);
|
||||
}
|
||||
@@ -4631,7 +4675,14 @@ export class TUI extends Container {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
const writeRow = Math.min(i, height - 1);
|
||||
buffer += options.clearScrollback
|
||||
? this.#lineRewriteSequence(frame[i] ?? "", width, writeRow, i, chunkTo)
|
||||
? this.#lineRewriteSequence(
|
||||
frame[i] ?? "",
|
||||
width,
|
||||
writeRow,
|
||||
i,
|
||||
chunkTo,
|
||||
this.#osc66SpacerGlyphWidth(frame, i),
|
||||
)
|
||||
: this.#terminalLine(frame[i] ?? "", writeRow, i, chunkTo);
|
||||
}
|
||||
for (let screenRow = 0; screenRow < height; screenRow++) {
|
||||
@@ -4640,7 +4691,14 @@ export class TUI extends Container {
|
||||
const writeRow = Math.min(chunkTo + screenRow, height - 1);
|
||||
const frameRow = windowTop + screenRow;
|
||||
buffer += options.clearScrollback
|
||||
? this.#lineRewriteSequence(line, width, writeRow, frameRow, chunkTo)
|
||||
? this.#lineRewriteSequence(
|
||||
line,
|
||||
width,
|
||||
writeRow,
|
||||
frameRow,
|
||||
chunkTo,
|
||||
this.#osc66SpacerGlyphWidth(frame, frameRow),
|
||||
)
|
||||
: this.#terminalLine(line, writeRow, frameRow, chunkTo);
|
||||
}
|
||||
} else {
|
||||
@@ -4652,7 +4710,14 @@ export class TUI extends Container {
|
||||
const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? "");
|
||||
const writeRow = Math.min(i, height - 1);
|
||||
buffer += options.clearScrollback
|
||||
? this.#lineRewriteSequence(line, width, writeRow, -1, chunkTo)
|
||||
? this.#lineRewriteSequence(
|
||||
line,
|
||||
width,
|
||||
writeRow,
|
||||
-1,
|
||||
chunkTo,
|
||||
this.#osc66SpacerGlyphWidth(paintLines, i),
|
||||
)
|
||||
: this.#terminalLine(line, writeRow, -1, chunkTo);
|
||||
}
|
||||
}
|
||||
@@ -4739,43 +4804,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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4845,13 +4926,30 @@ 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";
|
||||
buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, -1, this.#committedRows);
|
||||
// `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(
|
||||
framed[idx] ?? "",
|
||||
width,
|
||||
r,
|
||||
-1,
|
||||
this.#committedRows,
|
||||
this.#osc66SpacerGlyphWidth(framed, idx),
|
||||
);
|
||||
}
|
||||
// Park the hardware cursor at the real content bottom, not the padded
|
||||
// viewport bottom: a later height shrink would otherwise scroll the live
|
||||
@@ -4917,7 +5015,7 @@ export class TUI extends Container {
|
||||
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
||||
for (let r = 0; r < height; r++) {
|
||||
if (r > 0) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1);
|
||||
buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1, this.#osc66SpacerGlyphWidth(fitted, r));
|
||||
}
|
||||
buffer += this.#paintEndSequence;
|
||||
this.terminal.write(buffer);
|
||||
@@ -4996,7 +5094,7 @@ export class TUI extends Container {
|
||||
const moveToBottom = height - 1 - currentScreenRow;
|
||||
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
||||
for (let r = height - scroll; r < height; r++) {
|
||||
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo)}`;
|
||||
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo, this.#osc66SpacerGlyphWidth(window, r))}`;
|
||||
}
|
||||
// Rewrite any remaining changed rows after the shift.
|
||||
let firstChanged = -1;
|
||||
@@ -5013,7 +5111,14 @@ export class TUI extends Container {
|
||||
buffer += "\r";
|
||||
for (let r = firstChanged; r <= lastChanged; r++) {
|
||||
if (r > firstChanged) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, windowTop + r, chunkTo);
|
||||
buffer += this.#lineRewriteSequence(
|
||||
window[r] ?? "",
|
||||
width,
|
||||
r,
|
||||
windowTop + r,
|
||||
chunkTo,
|
||||
this.#osc66SpacerGlyphWidth(window, r),
|
||||
);
|
||||
}
|
||||
cursorFromRow = windowTop + lastChanged;
|
||||
}
|
||||
@@ -5088,6 +5193,7 @@ export class TUI extends Container {
|
||||
r,
|
||||
windowTop + r,
|
||||
this.#committedRows,
|
||||
this.#osc66SpacerGlyphWidth(window, r),
|
||||
);
|
||||
}
|
||||
buffer += fillSequence;
|
||||
@@ -5119,7 +5225,14 @@ export class TUI extends Container {
|
||||
let wroteLine = false;
|
||||
for (let i = chunkFrom; i < chunkTo; i++) {
|
||||
if (wroteLine) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(frame[i] ?? "", width, Math.min(i - chunkFrom, height - 1), i, chunkTo);
|
||||
buffer += this.#lineRewriteSequence(
|
||||
frame[i] ?? "",
|
||||
width,
|
||||
Math.min(i - chunkFrom, height - 1),
|
||||
i,
|
||||
chunkTo,
|
||||
this.#osc66SpacerGlyphWidth(frame, i),
|
||||
);
|
||||
wroteLine = true;
|
||||
}
|
||||
for (let screenRow = 0; screenRow < height; screenRow++) {
|
||||
@@ -5130,6 +5243,7 @@ export class TUI extends Container {
|
||||
Math.min(chunkTo - chunkFrom + screenRow, height - 1),
|
||||
windowTop + screenRow,
|
||||
chunkTo,
|
||||
this.#osc66SpacerGlyphWidth(frame, windowTop + screenRow),
|
||||
);
|
||||
wroteLine = true;
|
||||
}
|
||||
|
||||
@@ -371,6 +371,34 @@ export function visibleWidth(str: string): number {
|
||||
return correctHangulCompatibilityJamoWidth(width, str);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a row carries a Kitty OSC 66 text-sizing span (`\x1b]66;…`).
|
||||
* Scaled spans must bypass wrapping/padding and, when scaled up, reserve the
|
||||
* terminal rows their multicell glyphs flow into.
|
||||
*/
|
||||
export function isOsc66Line(line: string): boolean {
|
||||
return line.includes(OSC66_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest `s=` scale among the OSC 66 spans in a line (1 when none is scaled).
|
||||
* A scale-`s` heading occupies `s` terminal rows, so the `s - 1` blank rows
|
||||
* beneath it are the glyph's lower half and must never be erased or overdrawn.
|
||||
*/
|
||||
export function osc66MaxScale(line: string): number {
|
||||
if (!line.includes(OSC66_PREFIX)) return 1;
|
||||
let max = 1;
|
||||
OSC66_SPAN_REGEX.lastIndex = 0;
|
||||
for (let m = OSC66_SPAN_REGEX.exec(line); m !== null; m = OSC66_SPAN_REGEX.exec(line)) {
|
||||
for (const part of m[1].split(":")) {
|
||||
if (part.indexOf("=") !== 1 || part[0] !== "s") continue;
|
||||
const value = Number.parseInt(part.slice(2), 10);
|
||||
if (Number.isFinite(value) && value > max && value <= 7) max = value;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it, vi } from "bun:test";
|
||||
import { type Component, type NativeScrollbackLiveRegion, TUI } from "@oh-my-pi/pi-tui";
|
||||
import { VirtualTerminal } from "./virtual-terminal";
|
||||
|
||||
// Kitty OSC 66 text-sizing marker and the erase sequences the renderer emits.
|
||||
// A scale-`s` heading renders `s` cells tall and `visibleWidth` cells wide, so
|
||||
// the blank rows beneath it hold the multicell glyph's lower half: those
|
||||
// columns must survive every repaint or the glyph vanishes and leaves
|
||||
// reserved-but-invisible space (issue #8318). The `s=2` "Heading" glyph is
|
||||
// 2 * 7 = 14 cells wide; the `s=3` "Big" glyph is 3 * 3 = 9.
|
||||
const OSC66 = "\x1b]66;";
|
||||
const ST = "\x1b\\";
|
||||
const ERASE_LINE = "\x1b[2K";
|
||||
|
||||
class RawLines implements Component {
|
||||
#lines: string[];
|
||||
constructor(lines: string[]) {
|
||||
this.#lines = lines;
|
||||
}
|
||||
setLines(lines: string[]): void {
|
||||
this.#lines = lines;
|
||||
}
|
||||
invalidate(): void {}
|
||||
render(): string[] {
|
||||
return this.#lines;
|
||||
}
|
||||
}
|
||||
|
||||
class SeamRawLines extends RawLines implements NativeScrollbackLiveRegion {
|
||||
getNativeScrollbackLiveRegionStart(): number {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush the real render scheduler. Its throttle and post-paint settle windows
|
||||
// are driven by the platform clock, so these integration tests wait real time
|
||||
// (the suite-wide convention in deccara/image-budget tests) rather than mock a
|
||||
// scheduler that would not exercise the resize-settle full paint under test.
|
||||
async function settle(term: VirtualTerminal): Promise<void> {
|
||||
const nextTick = Promise.withResolvers<void>();
|
||||
process.nextTick(nextTick.resolve);
|
||||
await nextTick.promise;
|
||||
await Bun.sleep(40);
|
||||
await term.flush();
|
||||
}
|
||||
|
||||
// A non-multiplexer resize paints the viewport immediately and defers the
|
||||
// authoritative full paint until the drag settles (120 ms window).
|
||||
async function settleResize(term: VirtualTerminal): Promise<void> {
|
||||
await Bun.sleep(160);
|
||||
await settle(term);
|
||||
}
|
||||
|
||||
function captureWrites(term: VirtualTerminal): string[] {
|
||||
const writes: string[] = [];
|
||||
const realWrite = term.write.bind(term);
|
||||
vi.spyOn(term, "write").mockImplementation((data: string) => {
|
||||
writes.push(data);
|
||||
realWrite(data);
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the paint write that carries the sized heading into terminal rows and
|
||||
* return the heading row plus the `spacerCount` rows written directly beneath
|
||||
* it. Rows are `\r\n`-separated in the emitted buffer; the OSC 66 ST (`ESC \\`)
|
||||
* never contains a newline, so the split keeps each span intact.
|
||||
*/
|
||||
function headingAndSpacers(writes: string[], spacerCount: number): { heading: string; spacers: string[] } {
|
||||
const paint = writes.find(write => write.includes(OSC66));
|
||||
expect(paint).toBeDefined();
|
||||
const rows = paint!.split("\r\n");
|
||||
const idx = rows.findIndex(row => row.includes(OSC66));
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
return { heading: rows[idx]!, spacers: rows.slice(idx + 1, idx + 1 + spacerCount) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A reserved spacer row must preserve the glyph's own columns `[0, glyphWidth)`
|
||||
* while clearing any stale cells to their right (a row can reflow from wider
|
||||
* text into the spacer). So: no whole-line erase, no erase-to-end before the
|
||||
* glyph, and exactly one cursor-forward to `glyphWidth` followed by erase-to-end.
|
||||
*/
|
||||
function expectClearsRightOfGlyph(spacer: string, glyphWidth: number): void {
|
||||
expect(spacer).not.toContain(ERASE_LINE);
|
||||
expect(spacer).not.toMatch(/^(?:\x1b\[0m)?\x1b\[K/);
|
||||
const match = spacer.match(/\x1b\[(\d+)C\x1b\[K/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(Number(match![1])).toBe(glyphWidth);
|
||||
}
|
||||
|
||||
describe("issue #8318: scaled OSC 66 headings survive repaint and resize", () => {
|
||||
it("re-emits the heading and preserves its reserved row on a full repaint", async () => {
|
||||
const term = new VirtualTerminal(80, 6);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new RawLines([`${OSC66}s=2;Heading${ST}`, "", "Body"]));
|
||||
const writes = captureWrites(term);
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
writes.length = 0;
|
||||
|
||||
// Destructive full replay — the same gesture a redraw/session replace
|
||||
// uses, routed through the per-row erase path (#lineRewriteSequence).
|
||||
tui.requestRender(true, { clearScrollback: true });
|
||||
await settle(term);
|
||||
|
||||
const { heading, spacers } = headingAndSpacers(writes, 1);
|
||||
expect(heading).toContain("Heading");
|
||||
expectClearsRightOfGlyph(spacers[0]!, 14);
|
||||
expect(writes.find(write => write.includes(OSC66))).toContain("Body");
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the reserved row across a resize repaint", async () => {
|
||||
const term = new VirtualTerminal(80, 6);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new RawLines([`${OSC66}s=2;Heading${ST}`, "", "Body"]));
|
||||
const writes = captureWrites(term);
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
writes.length = 0;
|
||||
|
||||
term.resize(70, 6);
|
||||
await settleResize(term);
|
||||
|
||||
const { heading, spacers } = headingAndSpacers(writes, 1);
|
||||
expect(heading).toContain("Heading");
|
||||
expectClearsRightOfGlyph(spacers[0]!, 14);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("protects every reserved row of a scale-3 heading (the /debug probe case)", async () => {
|
||||
const term = new VirtualTerminal(80, 6);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new RawLines([`${OSC66}s=3;Big${ST}`, "", "", "Body"]));
|
||||
const writes = captureWrites(term);
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
writes.length = 0;
|
||||
|
||||
tui.requestRender(true, { clearScrollback: true });
|
||||
await settle(term);
|
||||
|
||||
const { heading, spacers } = headingAndSpacers(writes, 2);
|
||||
expect(heading).toContain("Big");
|
||||
for (const spacer of spacers) expectClearsRightOfGlyph(spacer, 9);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears stale cells when a wide row reflows into the reserved spacer", async () => {
|
||||
const term = new VirtualTerminal(80, 6);
|
||||
const tui = new TUI(term);
|
||||
// Row 1 starts as text far wider than the eventual 14-cell glyph.
|
||||
const content = new RawLines(["intro", `wide prior text ${"x".repeat(40)}`, "tail"]);
|
||||
tui.addChild(content);
|
||||
const writes = captureWrites(term);
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
writes.length = 0;
|
||||
|
||||
// Reflow: row 0 becomes the sized heading, row 1 becomes its reserved
|
||||
// spacer. The glyph write covers only columns [0, 14); the stale wide
|
||||
// text to the right must still be erased.
|
||||
content.setLines([`${OSC66}s=2;Heading${ST}`, "", "tail"]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
const { heading, spacers } = headingAndSpacers(writes, 1);
|
||||
expect(heading).toContain("Heading");
|
||||
expectClearsRightOfGlyph(spacers[0]!, 14);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses full-frame context when the spacer is the first row below the commit seam", async () => {
|
||||
const term = new VirtualTerminal(80, 4);
|
||||
const tui = new TUI(term);
|
||||
const content = new SeamRawLines(["old heading row", `wide prior text ${"x".repeat(40)}`, "tail-0", "tail-1"]);
|
||||
tui.addChild(content);
|
||||
const writes = captureWrites(term);
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
writes.length = 0;
|
||||
|
||||
// Appending one row commits frame[0] through the chunk loop. The
|
||||
// reserved frame[1] row becomes window[0], so window-local context
|
||||
// cannot see the heading immediately above the commit seam.
|
||||
content.setLines([`${OSC66}s=2;Heading${ST}`, "", "tail-0", "tail-1", "tail-2"]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
const { heading, spacers } = headingAndSpacers(writes, 1);
|
||||
expect(heading).toContain("Heading");
|
||||
expectClearsRightOfGlyph(spacers[0]!, 14);
|
||||
} finally {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user