fix(tui): avoid full redraws on shrink

fixes #5825
This commit is contained in:
Vegard Stikbakke
2026-06-17 12:53:38 +02:00
Unverified
parent 29c1504cc1
commit a67b4be9b6
2 changed files with 163 additions and 14 deletions
+65 -9
View File
@@ -1309,12 +1309,56 @@ export class TUI extends Container {
return;
}
// Content shrunk below the working area and no overlays - re-render to clear empty rows
// (overlays need the padding, so only do this when no overlays are active)
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
fullRender(true);
const redrawViewport = (nextViewportTop: number): void => {
let buffer = "\x1b[?2026h";
buffer += this.deleteChangedKittyImages(prevViewportTop, prevViewportTop + height - 1);
let screenRow = 0;
while (screenRow < height) {
const lineIndex = nextViewportTop + screenRow;
const line = newLines[lineIndex] ?? "";
const isImage = isImageLine(line);
const imageReservedRows = isImage
? this.getKittyImageReservedRows(newLines, lineIndex, nextViewportTop + height - 1)
: 1;
buffer += `\x1b[${screenRow + 1};1H`;
buffer += "\x1b[2K";
if (isImage && imageReservedRows > 1 && screenRow + imageReservedRows <= height) {
for (let row = 1; row < imageReservedRows; row++) {
buffer += `\x1b[${screenRow + row + 1};1H`;
buffer += "\x1b[2K";
}
buffer += `\x1b[${screenRow + 1};1H`;
buffer += line;
screenRow += imageReservedRows;
continue;
}
buffer += line;
screenRow += 1;
}
buffer += "\x1b[?2026l";
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = nextViewportTop + height - 1;
this.maxLinesRendered = newLines.length;
this.previousViewportTop = nextViewportTop;
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
};
const shrinkClearEnabled =
this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0;
const shrinkViewportTop = Math.max(0, newLines.length - height);
if (shrinkClearEnabled && shrinkViewportTop < prevViewportTop) {
logRedraw(`clearOnShrink viewport redraw (maxLinesRendered=${this.maxLinesRendered})`);
redrawViewport(shrinkViewportTop);
return;
}
@@ -1364,7 +1408,11 @@ export class TUI extends Container {
const targetRow = Math.max(0, newLines.length - 1);
if (targetRow < prevViewportTop) {
logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
fullRender(true);
if (shrinkClearEnabled) {
redrawViewport(shrinkViewportTop);
} else {
fullRender(true);
}
return;
}
const lineDiff = computeLineDiff(targetRow);
@@ -1375,7 +1423,11 @@ export class TUI extends Container {
const extraLines = this.previousLines.length - newLines.length;
if (extraLines > height) {
logRedraw(`extraLines > height (${extraLines} > ${height})`);
fullRender(true);
if (shrinkClearEnabled) {
redrawViewport(shrinkViewportTop);
} else {
fullRender(true);
}
return;
}
const clearStartOffset = newLines.length === 0 ? 0 : 1;
@@ -1408,7 +1460,11 @@ export class TUI extends Container {
// If the first changed line is above the previous viewport, we need a full redraw.
if (firstChanged < prevViewportTop) {
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
fullRender(true);
if (shrinkClearEnabled) {
redrawViewport(shrinkViewportTop);
} else {
fullRender(true);
}
return;
}
+98 -5
View File
@@ -61,8 +61,12 @@ async function withEnv<T>(updates: Record<string, string | undefined>, run: () =
}
}
function getXterm(terminal: VirtualTerminal): XtermTerminalType {
return (terminal as unknown as { xterm: XtermTerminalType }).xterm;
}
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const xterm = getXterm(terminal);
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
@@ -402,8 +406,8 @@ describe("TUI resize handling", () => {
});
describe("TUI content shrinkage", () => {
it("clears empty rows when content shrinks significantly", async () => {
const terminal = new VirtualTerminal(40, 10);
it("clears empty rows differentially when content shrinks significantly", async () => {
const terminal = new LoggingVirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
@@ -415,14 +419,16 @@ describe("TUI content shrinkage", () => {
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
terminal.clearWrites();
// Shrink to fewer lines
component.lines = ["Line 0", "Line 1"];
tui.requestRender();
await terminal.waitForRender();
// Should have triggered a full redraw to clear empty rows
assert.ok(tui.fullRedraws > initialRedraws, "Content shrinkage should trigger full redraw");
assert.strictEqual(tui.fullRedraws, initialRedraws, "Content shrinkage should stay on the differential path");
assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Differential shrink should not clear the whole screen");
assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Differential shrink should not clear scrollback");
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "First line preserved");
@@ -434,6 +440,93 @@ describe("TUI content shrinkage", () => {
tui.stop();
});
it("preserves detached scrollback viewport when clearOnShrink redraws the viewport", async () => {
const terminal = new LoggingVirtualTerminal(20, 5);
const tui = new TUI(terminal);
tui.setClearOnShrink(true);
const component = new TestComponent();
tui.addChild(component);
component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`);
tui.start();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
terminal.clearWrites();
const xterm = getXterm(terminal);
xterm.scrollLines(-10);
const viewportYBefore = xterm.buffer.active.viewportY;
const viewportBefore = terminal.getViewport();
component.lines = Array.from({ length: 18 }, (_, i) => `Line ${i}`);
tui.requestRender();
await terminal.waitForRender();
assert.strictEqual(tui.fullRedraws, initialRedraws, "Tall shrink should avoid the full-render path");
assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Tall shrink should not clear the whole screen");
assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Tall shrink should not clear scrollback");
assert.strictEqual(
xterm.buffer.active.viewportY,
viewportYBefore,
"Detached viewport position should be preserved",
);
assert.deepStrictEqual(terminal.getViewport(), viewportBefore);
tui.stop();
});
it("keeps the bottom pinned when clearOnShrink redraws the viewport", async () => {
const terminal = new LoggingVirtualTerminal(20, 5);
const tui = new TUI(terminal);
tui.setClearOnShrink(true);
const component = new TestComponent();
tui.addChild(component);
component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`);
tui.start();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
terminal.clearWrites();
component.lines = Array.from({ length: 18 }, (_, i) => `Line ${i}`);
tui.requestRender();
await terminal.waitForRender();
assert.strictEqual(tui.fullRedraws, initialRedraws, "Tall shrink should avoid the full-render path");
assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Tall shrink should not clear the whole screen");
assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Tall shrink should not clear scrollback");
assert.deepStrictEqual(terminal.getViewport(), ["Line 13", "Line 14", "Line 15", "Line 16", "Line 17"]);
tui.stop();
});
it("redraws shrink viewport rows independently when lines fill the terminal width", async () => {
const terminal = new LoggingVirtualTerminal(20, 5);
const tui = new TUI(terminal);
tui.setClearOnShrink(true);
const component = new TestComponent();
tui.addChild(component);
const makeLine = (index: number): string => `Line ${index.toString().padStart(2, "0")} ${"x".repeat(12)}`;
component.lines = Array.from({ length: 20 }, (_, i) => makeLine(i));
tui.start();
await terminal.waitForRender();
terminal.clearWrites();
component.lines = Array.from({ length: 18 }, (_, i) => makeLine(i));
tui.requestRender();
await terminal.waitForRender();
assert.ok(!terminal.getWrites().includes("\r\n"), "Viewport redraw should not depend on newline row advances");
assert.deepStrictEqual(
terminal.getViewport(),
[13, 14, 15, 16, 17].map((index) => makeLine(index)),
);
tui.stop();
});
it("handles shrink to single line", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);