fix(tui): refine overlay image intersection handling

This commit is contained in:
eleven914 2026-11-05 15:40:14 +08:00
parent c35a261993
commit 1ff4c57c13
3 changed files with 161 additions and 37 deletions

View file

@ -393,6 +393,13 @@ function getExplicitKittyImageRows(controls: string): number | undefined {
return rows > 0 ? rows : undefined;
}
function getExplicitKittyImageColumns(controls: string): number | undefined {
const value = /(?:^|,)c=(\d+)(?:,|$)/.exec(controls)?.[1];
if (value === undefined) return undefined;
const columns = Number.parseInt(value, 10);
return columns > 0 ? columns : undefined;
}
function getKittyImageRowsFromControls(controls: string, fallbackRows: number): number {
return getExplicitKittyImageRows(controls) ?? fallbackRows;
}
@ -438,22 +445,46 @@ export function getKittyImagePlacementRows(line: string): number | undefined {
return getRegisteredKittyImageMetadataFromControls(controls)?.rows;
}
/** Row range occupied by an image, relative to the line containing its protocol sequence. */
export interface ImagePlacementRowRange {
start: number;
end: number;
/** Screen extent occupied by an image, relative to the line containing its protocol sequence. */
export interface ImagePlacementExtent {
protocol: "kitty" | "iterm2";
sequenceStart: number;
startRow: number;
endRow: number;
columns?: number;
}
export function getImagePlacementRowRange(line: string): ImagePlacementRowRange | undefined {
const kittyRows = getKittyImagePlacementRows(line);
if (kittyRows !== undefined) return { start: 0, end: kittyRows - 1 };
/** Read the screen extent of an image without scanning its payload. */
export function getImagePlacementExtent(line: string): ImagePlacementExtent | undefined {
const kittyMatch = /\x1b_G([^;]*);/.exec(line);
if (kittyMatch) {
const metadata = getRegisteredKittyImageMetadataFromControls(kittyMatch[1]);
const rows = getExplicitKittyImageRows(kittyMatch[1]) ?? metadata?.rows;
if (rows === undefined) return undefined;
return {
protocol: "kitty",
sequenceStart: kittyMatch.index,
startRow: 0,
endRow: rows - 1,
columns: getExplicitKittyImageColumns(kittyMatch[1]) ?? metadata?.columns,
};
}
const imageIndex = line.indexOf(ITERM2_PREFIX);
if (imageIndex === -1) return undefined;
const cursorUpMatch = /\x1b\[(\d+)A$/.exec(line.slice(0, imageIndex));
const rowsAbove = cursorUpMatch ? Number.parseInt(cursorUpMatch[1], 10) : 0;
return { start: -rowsAbove, end: 0 };
const width = /(?:^|;)width=(\d+)(?:;|:)/.exec(
line.slice(imageIndex + ITERM2_PREFIX.length, line.indexOf(":", imageIndex) + 1),
)?.[1];
return {
protocol: "iterm2",
sequenceStart: imageIndex,
startRow: -rowsAbove,
endRow: 0,
columns: width === undefined ? undefined : Number.parseInt(width, 10),
};
}
/** Build a placement-only command for an image line emitted by {@link renderImage}. */

View file

@ -12,7 +12,13 @@ import {
type TerminalColorScheme,
type TerminalColors,
} from "./terminal-colors.ts";
import { getCapabilities, getImagePlacementRowRange, isImageLine, setCellDimensions } from "./terminal-image.ts";
import {
getCapabilities,
getImagePlacementExtent,
getMaxKittyImageRows,
isImageLine,
setCellDimensions,
} from "./terminal-image.ts";
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts";
/**
@ -436,9 +442,10 @@ export function compositeTuiLine(
overlayWidth: number,
totalWidth: number,
): string {
if (isImageLine(baseLine)) return baseLine;
const afterStart = startCol + overlayWidth;
const compositableBase = isImageLine(baseLine) ? "" : baseLine;
const base = extractSegments(compositableBase, startCol, afterStart, totalWidth - afterStart, true);
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
const beforePad = Math.max(0, startCol - base.beforeWidth);
const overlayPad = Math.max(0, overlayWidth - overlay.width);
@ -1434,22 +1441,35 @@ export abstract class TuiBase extends Container implements TUI {
}
const viewportStart = Math.max(0, workingHeight - termHeight);
const viewportEnd = Math.min(result.length - 1, viewportStart + termHeight - 1);
const imageScanStart = Math.max(0, viewportStart - Math.max(0, getMaxKittyImageRows() - 1));
for (let anchor = imageScanStart; anchor <= viewportEnd; anchor++) {
const imageLine = result[anchor];
if (!isImageLine(imageLine)) continue;
const placement = getImagePlacementExtent(imageLine);
const intersectsOverlay = rendered.some(({ overlayLines, row, col, w }) => {
if (overlayLines.length === 0) return false;
const overlayStartRow = viewportStart + row;
const overlayEndRow = overlayStartRow + overlayLines.length - 1;
const imageStartRow = anchor + (placement?.startRow ?? 0);
const imageEndRow = anchor + (placement?.endRow ?? 0);
if (imageStartRow > overlayEndRow || imageEndRow < overlayStartRow) return false;
// iTerm2 restores the cursor after drawing above the anchor, so repaint the
// whole placement when any of its rows intersects an overlay.
if (!placement || placement.protocol === "iterm2" || placement.columns === undefined) return true;
const imageStartCol = visibleWidth(imageLine.slice(0, placement.sequenceStart));
const imageEndCol = imageStartCol + placement.columns;
return imageStartCol < col + w && imageEndCol > col;
});
if (intersectsOverlay) result[anchor] = "";
}
// Composite each overlay
for (const { overlayLines, row, col, w } of rendered) {
if (overlayLines.length > 0) {
const overlayStart = viewportStart + row;
const overlayEnd = overlayStart + overlayLines.length - 1;
for (let anchor = 0; anchor < result.length; anchor++) {
const placement = getImagePlacementRowRange(result[anchor]);
if (!placement) continue;
const imageStart = anchor + placement.start;
const imageEnd = anchor + placement.end;
if (imageStart <= overlayEnd && imageEnd >= overlayStart) result[anchor] = "";
}
}
for (let i = 0; i < overlayLines.length; i++) {
const idx = viewportStart + row + i;
if (idx >= 0 && idx < result.length) {
@ -1483,6 +1503,11 @@ export abstract class TuiBase extends Container implements TUI {
overlayWidth: number,
totalWidth: number,
): string {
if (isImageLine(baseLine)) {
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
const overlayPad = Math.max(0, overlayWidth - overlay.width);
return `${baseLine}${SEGMENT_RESET}\x1b[${startCol + 1}G${overlay.text}${" ".repeat(overlayPad)}${SEGMENT_RESET}`;
}
return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth);
}

View file

@ -1,11 +1,18 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { encodeITerm2, encodeKitty, isImageLine } from "../src/terminal-image.ts";
import {
deleteKittyImage,
encodeITerm2,
encodeKitty,
isImageLine,
registerKittyImageMetadata,
} from "../src/terminal-image.ts";
import { type Component, compositeTuiLine, TuiBase } from "../src/tui.ts";
import { TuiMainScreen } from "../src/tui-main-screen.ts";
import { VirtualTerminal } from "./virtual-terminal.ts";
class StaticOverlay implements Component {
private readonly lines: string[];
class StaticComponent implements Component {
lines: string[];
constructor(lines: string[]) {
this.lines = lines;
@ -18,6 +25,23 @@ class StaticOverlay implements Component {
invalidate(): void {}
}
class LoggingVirtualTerminal extends VirtualTerminal {
private writes: string[] = [];
override write(data: string): void {
this.writes.push(data);
super.write(data);
}
getWrites(): string {
return this.writes.join("");
}
clearWrites(): void {
this.writes = [];
}
}
class TestTui extends TuiBase {
readonly mode = "regular" as const;
@ -30,18 +54,16 @@ class TestTui extends TuiBase {
// Regression tests for https://github.com/earendil-works/pi/issues/11584
describe("overlays over terminal images", () => {
it("composites an overlay over an image protocol line", () => {
const imageLine = `${encodeKitty("AAAA", { columns: 20, rows: 5, imageId: 1, moveCursor: false })}${" ".repeat(80)}`;
const result = compositeTuiLine(imageLine, "PANEL", 30, 40, 100);
it("preserves the generic image-line compositing behavior", () => {
const imageLine = encodeKitty("AAAA", { columns: 20, rows: 5, imageId: 1, moveCursor: false });
assert.strictEqual(isImageLine(result), false);
assert.ok(result.includes("PANEL"));
assert.strictEqual(compositeTuiLine(imageLine, "PANEL", 10, 20, 100), imageLine);
});
it("hides a Kitty image when an overlay intersects a reserved row", () => {
it("hides a Kitty image when an overlay intersects a reserved row and column", () => {
const tui = new TestTui(new VirtualTerminal(100, 6));
const imageLine = encodeKitty("AAAA", { columns: 20, rows: 3, imageId: 2, moveCursor: false });
const overlay = tui.showOverlay(new StaticOverlay(["PANEL"]), { row: 1, col: 30, width: 40 });
const overlay = tui.showOverlay(new StaticComponent(["PANEL"]), { row: 1, col: 10, width: 20 });
const result = tui.composite([imageLine, "", "", "tail"], 100, 6);
@ -53,10 +75,33 @@ describe("overlays over terminal images", () => {
assert.strictEqual(isImageLine(restored[0]), true);
});
it("keeps a Kitty image when the overlay is horizontally disjoint", () => {
const tui = new TestTui(new VirtualTerminal(100, 6));
const imageLine = encodeKitty("AAAA", { columns: 20, rows: 3, imageId: 3, moveCursor: false });
tui.showOverlay(new StaticComponent(["PANEL"]), { row: 1, col: 30, width: 20 });
const result = tui.composite([imageLine, "", "", "tail"], 100, 6);
assert.strictEqual(isImageLine(result[0]), true);
assert.ok(result[1].includes("PANEL"));
});
it("composites a disjoint overlay on a Kitty anchor row", () => {
const tui = new TestTui(new VirtualTerminal(100, 6));
const imageLine = encodeKitty("AAAA", { columns: 20, rows: 3, imageId: 4, moveCursor: false });
tui.showOverlay(new StaticComponent(["PANEL"]), { row: 0, col: 30, width: 20 });
const result = tui.composite([imageLine, "", "", "tail"], 100, 6);
assert.strictEqual(isImageLine(result[0]), true);
assert.ok(result[0].includes("\x1b[31G"));
assert.ok(result[0].includes("PANEL"));
});
it("hides an iTerm2 image when an overlay intersects a row above its anchor", () => {
const tui = new TestTui(new VirtualTerminal(100, 6));
const imageLine = `\x1b[2A${encodeITerm2("AAAA", { width: 20, height: 3 })}`;
tui.showOverlay(new StaticOverlay(["PANEL"]), { row: 0, col: 30, width: 40 });
tui.showOverlay(new StaticComponent(["PANEL"]), { row: 0, col: 30, width: 20 });
const result = tui.composite(["", "", imageLine, "tail"], 100, 6);
@ -66,11 +111,34 @@ describe("overlays over terminal images", () => {
it("keeps an image visible behind an empty overlay", () => {
const tui = new TestTui(new VirtualTerminal(100, 6));
const imageLine = encodeKitty("AAAA", { columns: 20, rows: 3, imageId: 3, moveCursor: false });
tui.showOverlay(new StaticOverlay([]), { row: 1, col: 30, width: 40 });
const imageLine = encodeKitty("AAAA", { columns: 20, rows: 3, imageId: 5, moveCursor: false });
tui.showOverlay(new StaticComponent([]), { row: 1, col: 10, width: 20 });
const result = tui.composite([imageLine, "", "", "tail"], 100, 6);
assert.strictEqual(isImageLine(result[0]), true);
});
it("deletes an obscured Kitty image and restores it after the overlay closes", async () => {
const imageId = 6;
registerKittyImageMetadata({ imageId, columns: 20, rows: 3, widthPx: 200, heightPx: 60 });
const imageLine = encodeKitty("AAAA", { columns: 20, rows: 3, imageId, moveCursor: false });
const terminal = new LoggingVirtualTerminal(100, 6);
const tui = new TuiMainScreen(terminal);
const content = new StaticComponent([imageLine, "", "", "tail"]);
tui.addChild(content);
tui.start();
await terminal.waitForRender();
terminal.clearWrites();
const overlay = tui.showOverlay(new StaticComponent(["PANEL"]), { row: 1, col: 10, width: 20 });
await terminal.waitForRender();
assert.ok(terminal.getWrites().includes(deleteKittyImage(imageId)));
terminal.clearWrites();
overlay.setHidden(true);
await terminal.waitForRender();
assert.ok(terminal.getWrites().includes(imageLine));
tui.stop();
});
});