fix(tui): bound image line search by tallest registered image

Placeholder rows below a Kitty image line can contain box padding,
borders, or side-by-side text. Stopping the backward search at the
first non-blank row dropped those images once their first row scrolled
above the viewport. Search back at most the tallest registered image
height instead; the existing hiddenRows check keeps results correct.
This commit is contained in:
Petr Houška 2026-10-23 21:51:17 +02:00
parent 4464442f95
commit 1c8cb17817
3 changed files with 46 additions and 13 deletions

View file

@ -1,7 +1,7 @@
import type { ScrollView } from "./components/scroll-view.ts";
import { allocateStackSizes, visibleStackEntries } from "./components/stack.ts";
import { getLayoutNode } from "./layout-node.ts";
import { cropKittyImageLine, getKittyImageMetadata, isImageLine } from "./terminal-image.ts";
import { cropKittyImageLine, getKittyImageMetadata, getMaxKittyImageRows, isImageLine } from "./terminal-image.ts";
import { type Component, CURSOR_MARKER, compositeTuiLine } from "./tui.ts";
import {
extractAnsiCode,
@ -357,21 +357,23 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void {
for (const child of box.children) paintBox(child, screen, totalWidth);
if (box.scrollView && box.scrollContentLines && box.scrollView.scrollTop > 0 && box.rect.height > 0) {
for (let imageRow = box.scrollView.scrollTop - 1; imageRow >= 0; imageRow--) {
// Find the nearest image line above the viewport. Placeholder rows below an
// image line may contain padding, borders, or side-by-side text, so the search
// is bounded by the tallest registered image instead of by row content.
const scrollTop = box.scrollView.scrollTop;
const minRow = Math.max(0, scrollTop - getMaxKittyImageRows());
for (let imageRow = scrollTop - 1; imageRow >= minRow; imageRow--) {
const imageLine = box.scrollContentLines[imageRow] ?? "";
if (!isImageLine(imageLine)) continue;
const metadata = getKittyImageMetadata(imageLine);
if (metadata) {
const hiddenRows = box.scrollView.scrollTop - imageRow;
if (hiddenRows < metadata.rows) {
const visibleRows = Math.min(box.rect.height, metadata.rows - hiddenRows);
const cropped = cropKittyImageLine(imageLine, hiddenRows, visibleRows);
if (box.rect.x === 0 && box.rect.width >= totalWidth) screen[box.rect.y] = cropped;
}
break;
if (!metadata) continue;
const hiddenRows = scrollTop - imageRow;
if (hiddenRows < metadata.rows) {
const visibleRows = Math.min(box.rect.height, metadata.rows - hiddenRows);
const cropped = cropKittyImageLine(imageLine, hiddenRows, visibleRows);
if (box.rect.x === 0 && box.rect.width >= totalWidth) screen[box.rect.y] = cropped;
}
// Image placeholder rows inside padded or colored boxes contain only
// spaces and SGR codes; keep searching past them for the image line.
if (imageLine.replace(/\x1b\[[0-9;:]*m/g, "").trim() !== "") break;
break;
}
}

View file

@ -349,9 +349,13 @@ export interface KittyImagePlacement {
const kittyImageMetadata = new Map<number, RegisteredKittyImageMetadata>();
let kittyTransmissionGeneration = 0;
// Tallest registered image. Bounds backward searches for image lines above a
// viewport: no image line further up than this can reach into it.
let maxKittyImageRows = 0;
export function registerKittyImageMetadata(metadata: KittyImageMetadata): void {
kittyTransmissionGeneration += 1;
maxKittyImageRows = Math.max(maxKittyImageRows, metadata.rows);
kittyImageMetadata.delete(metadata.imageId);
kittyImageMetadata.set(metadata.imageId, { ...metadata, transmissionGeneration: kittyTransmissionGeneration });
if (kittyImageMetadata.size > 1000) {
@ -360,6 +364,11 @@ export function registerKittyImageMetadata(metadata: KittyImageMetadata): void {
}
}
/** Return the row height of the tallest Kitty image registered so far. */
export function getMaxKittyImageRows(): number {
return maxKittyImageRows;
}
function getRegisteredKittyImageMetadataFromControls(controls: string): RegisteredKittyImageMetadata | undefined {
const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1];
return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10));

View file

@ -1099,6 +1099,28 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("crops a Kitty image whose placeholder rows contain text when its first line is above the viewport", async () => {
// Regression test for #11271: borders or side-by-side text on placeholder rows stopped the search.
const terminal = new RecordingTerminal(20, 3);
const tui = new TuiAltScreen(terminal);
const imageId = 11272;
const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false });
registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 });
tui.addChild({ render: () => ["before", imageLine, " | side", " | side"], invalidate: () => {} });
tui.addChild({ render: () => ["after", "end"], invalidate: () => {} });
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 3);
assert.ok(
terminal.events.some(
(event) => event.type === "write" && event.data.includes("i=11272") && event.data.includes("y=66,h=34,r=1"),
),
);
tui.stop();
});
it("redraws WezTerm Kitty images after writes to covered rows", async () => {
// Regression test for #10319: a scrollbar update below an unchanged image anchor erased its cells.
const weztermPane = process.env.WEZTERM_PANE;