diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index caf45f6af..5d564aff9 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -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}. */ diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index d6ec88668..d7fee133f 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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); } diff --git a/packages/tui/test/regression-overlay-terminal-image.test.ts b/packages/tui/test/regression-overlay-terminal-image.test.ts index 6285d7242..57c94035c 100644 --- a/packages/tui/test/regression-overlay-terminal-image.test.ts +++ b/packages/tui/test/regression-overlay-terminal-image.test.ts @@ -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(); + }); });