fix(tui): preserve OSC 66 spacers at viewport top
This commit is contained in:
+16
-12
@@ -1217,12 +1217,10 @@ 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;
|
||||
// A scale-`s` OSC 66 heading reserves `s - 1` rows, and the protocol
|
||||
// caps `s` at 7. This bounds spacer lookups and supplies enough context
|
||||
// above the resize viewport to classify every legal heading exactly.
|
||||
static readonly #OSC66_MAX_SPACER_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.
|
||||
@@ -4317,7 +4315,13 @@ export class TUI extends Container {
|
||||
#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++;
|
||||
while (
|
||||
gap < TUI.#OSC66_MAX_SPACER_ROWS &&
|
||||
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);
|
||||
@@ -4812,7 +4816,7 @@ export class TUI extends Container {
|
||||
/**
|
||||
* 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, plus up to {@link #RESIZE_SPACER_CONTEXT_ROWS} rows above the
|
||||
* children, plus up to {@link #OSC66_MAX_SPACER_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
|
||||
@@ -4832,7 +4836,7 @@ export class TUI extends Container {
|
||||
width: number,
|
||||
height: number,
|
||||
): { framed: readonly string[]; viewportTop: number; contentRows: number } {
|
||||
const maxRows = height + TUI.#RESIZE_SPACER_CONTEXT_ROWS;
|
||||
const maxRows = height + TUI.#OSC66_MAX_SPACER_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 < maxRows; i--) {
|
||||
@@ -5094,7 +5098,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, this.#osc66SpacerGlyphWidth(window, r))}`;
|
||||
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo, this.#osc66SpacerGlyphWidth(frame, windowTop + r))}`;
|
||||
}
|
||||
// Rewrite any remaining changed rows after the shift.
|
||||
let firstChanged = -1;
|
||||
@@ -5117,7 +5121,7 @@ export class TUI extends Container {
|
||||
r,
|
||||
windowTop + r,
|
||||
chunkTo,
|
||||
this.#osc66SpacerGlyphWidth(window, r),
|
||||
this.#osc66SpacerGlyphWidth(frame, windowTop + r),
|
||||
);
|
||||
}
|
||||
cursorFromRow = windowTop + lastChanged;
|
||||
@@ -5193,7 +5197,7 @@ export class TUI extends Container {
|
||||
r,
|
||||
windowTop + r,
|
||||
this.#committedRows,
|
||||
this.#osc66SpacerGlyphWidth(window, r),
|
||||
this.#osc66SpacerGlyphWidth(frame, windowTop + r),
|
||||
);
|
||||
}
|
||||
buffer += fillSequence;
|
||||
|
||||
@@ -157,6 +157,26 @@ describe("issue #8318: scaled OSC 66 headings survive repaint and resize", () =>
|
||||
}
|
||||
});
|
||||
|
||||
it("protects all six reserved rows at the maximum legal scale", async () => {
|
||||
const term = new VirtualTerminal(80, 8);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new RawLines([`${OSC66}s=7;Max${ST}`, "", "", "", "", "", "", "Body"]));
|
||||
const writes = captureWrites(term);
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
writes.length = 0;
|
||||
|
||||
tui.requestRender(true, { clearScrollback: true });
|
||||
await settle(term);
|
||||
|
||||
const { spacers } = headingAndSpacers(writes, 6);
|
||||
for (const spacer of spacers) expectClearsRightOfGlyph(spacer, 21);
|
||||
} 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);
|
||||
@@ -210,6 +230,29 @@ describe("issue #8318: scaled OSC 66 headings survive repaint and resize", () =>
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the top spacer during an in-place viewport rewrite", async () => {
|
||||
const term = new VirtualTerminal(80, 4);
|
||||
const tui = new TUI(term);
|
||||
// The heading is immediately above the visible window while its reserved
|
||||
// lower row is window[0]. An in-place rewrite must classify that row from
|
||||
// the full frame rather than the context-free window slice.
|
||||
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;
|
||||
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
|
||||
const paint = writes.join("");
|
||||
expect(paint).toContain("\x1b[14C\x1b[K");
|
||||
} 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);
|
||||
|
||||
Reference in New Issue
Block a user