mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(tui): refine overlay image intersection handling
This commit is contained in:
parent
c35a261993
commit
1ff4c57c13
3 changed files with 161 additions and 37 deletions
|
|
@ -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}. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue