mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
parent
1387af7b42
commit
672000c80c
5 changed files with 124 additions and 22 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed fullscreen Kitty images collapsing to a one-row strip after scrolling in WezTerm ([#10319](https://github.com/earendil-works/pi/issues/10319)).
|
||||
|
||||
## [1.0.0] - 2026-10-01
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@ export interface KittyImagePlacement {
|
|||
transmissionGeneration: number;
|
||||
transmissionBytes: number;
|
||||
estimatedDecodedBytes: number;
|
||||
rows: number;
|
||||
sequence: string;
|
||||
replacementLine: string;
|
||||
}
|
||||
|
|
@ -349,13 +350,27 @@ export function registerKittyImageMetadata(metadata: KittyImageMetadata): void {
|
|||
}
|
||||
}
|
||||
|
||||
function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined {
|
||||
const controls = /\x1b_G([^;]*);/.exec(line)?.[1];
|
||||
if (!controls) return undefined;
|
||||
function getRegisteredKittyImageMetadataFromControls(controls: string): RegisteredKittyImageMetadata | undefined {
|
||||
const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1];
|
||||
return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10));
|
||||
}
|
||||
|
||||
function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined {
|
||||
const controls = /\x1b_G([^;]*);/.exec(line)?.[1];
|
||||
return controls === undefined ? undefined : getRegisteredKittyImageMetadataFromControls(controls);
|
||||
}
|
||||
|
||||
function getExplicitKittyImageRows(controls: string): number | undefined {
|
||||
const value = /(?:^|,)r=(\d+)(?:,|$)/.exec(controls)?.[1];
|
||||
if (value === undefined) return undefined;
|
||||
const rows = Number.parseInt(value, 10);
|
||||
return rows > 0 ? rows : undefined;
|
||||
}
|
||||
|
||||
function getKittyImageRowsFromControls(controls: string, fallbackRows: number): number {
|
||||
return getExplicitKittyImageRows(controls) ?? fallbackRows;
|
||||
}
|
||||
|
||||
export function getKittyImageMetadata(line: string): KittyImageMetadata | undefined {
|
||||
const metadata = getRegisteredKittyImageMetadata(line);
|
||||
if (!metadata) return undefined;
|
||||
|
|
@ -388,11 +403,21 @@ const KITTY_PLACEMENT_CONTROL_KEYS = new Set([
|
|||
"V",
|
||||
]);
|
||||
|
||||
/** Read the number of rows covered by an image placement without scanning its payload. */
|
||||
export function getKittyImagePlacementRows(line: string): number | undefined {
|
||||
const controls = /\x1b_G([^;]*);/.exec(line)?.[1];
|
||||
if (controls === undefined) return undefined;
|
||||
const explicitRows = getExplicitKittyImageRows(controls);
|
||||
if (explicitRows !== undefined) return explicitRows;
|
||||
return getRegisteredKittyImageMetadataFromControls(controls)?.rows;
|
||||
}
|
||||
|
||||
/** Build a placement-only command for an image line emitted by {@link renderImage}. */
|
||||
export function getKittyImagePlacement(line: string): KittyImagePlacement | undefined {
|
||||
const match = /\x1b_G([^;]*);/.exec(line);
|
||||
const metadata = getRegisteredKittyImageMetadata(line);
|
||||
if (!match || !metadata) return undefined;
|
||||
if (!match) return undefined;
|
||||
const metadata = getRegisteredKittyImageMetadataFromControls(match[1]);
|
||||
if (!metadata) return undefined;
|
||||
|
||||
let commandStart = match.index;
|
||||
let commandControls = match[1];
|
||||
|
|
@ -418,6 +443,7 @@ export function getKittyImagePlacement(line: string): KittyImagePlacement | unde
|
|||
transmissionGeneration: metadata.transmissionGeneration,
|
||||
transmissionBytes: transmissionEnd - match.index,
|
||||
estimatedDecodedBytes: metadata.widthPx * metadata.heightPx * 4,
|
||||
rows: getKittyImageRowsFromControls(match[1], metadata.rows),
|
||||
sequence,
|
||||
replacementLine: `${line.slice(0, match.index)}${sequence}${line.slice(transmissionEnd)}`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
deleteKittyImage,
|
||||
getCapabilities,
|
||||
getKittyImagePlacement,
|
||||
getKittyImagePlacementRows,
|
||||
type ImageProtocol,
|
||||
isImageLine,
|
||||
setCapabilities,
|
||||
|
|
@ -1693,10 +1694,25 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
|
||||
const fullRedraw =
|
||||
this.previousScreen.length === 0 || this.previousScreenWidth !== width || this.previousScreenHeight !== height;
|
||||
const imagesNeedRedraw = screen.some(
|
||||
(line, row) =>
|
||||
line !== this.previousScreen[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")),
|
||||
const changedRows = screen.map((line, row) => line !== this.previousScreen[row]);
|
||||
const imageAnchorsNeedRedraw = screen.some(
|
||||
(line, row) => changedRows[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")),
|
||||
);
|
||||
const isWezTerm = Boolean(process.env.WEZTERM_PANE) || process.env.TERM_PROGRAM?.toLowerCase() === "wezterm";
|
||||
const imageCellsNeedRedraw =
|
||||
!imageAnchorsNeedRedraw &&
|
||||
isWezTerm &&
|
||||
this.imageProtocol === "kitty" &&
|
||||
changedRows.some(Boolean) &&
|
||||
screen.some((line, row) => {
|
||||
const placementRows = getKittyImagePlacementRows(line);
|
||||
if (placementRows === undefined) return false;
|
||||
for (let coveredRow = row; coveredRow < row + placementRows; coveredRow++) {
|
||||
if (changedRows[coveredRow]) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const imagesNeedRedraw = imageAnchorsNeedRedraw || imageCellsNeedRedraw;
|
||||
const redrawImages = fullRedraw || imagesNeedRedraw;
|
||||
const hadUploadedKittyImages = this.uploadedKittyImages.size > 0;
|
||||
const preparedKittyScreen =
|
||||
|
|
@ -1718,24 +1734,31 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
}
|
||||
buffer += preparedKittyScreen.evictedImageDeletion;
|
||||
|
||||
// WezTerm erases intersecting Kitty image cells when a later EL clears a covered row.
|
||||
// Only separate clearing from drawing for WezTerm frames that place images; preserve the
|
||||
// existing interleaved output for text-only frames and every other terminal.
|
||||
const clearRowsBeforeKittyImages =
|
||||
redrawImages &&
|
||||
this.imageProtocol === "kitty" &&
|
||||
screen.some(isImageLine) &&
|
||||
(Boolean(process.env.WEZTERM_PANE) || process.env.TERM_PROGRAM?.toLowerCase() === "wezterm");
|
||||
if (clearRowsBeforeKittyImages) {
|
||||
// WezTerm erases intersecting Kitty image cells when a later row write touches a covered row.
|
||||
// Draw image placements after every clear and text write so nothing later intersects them; preserve
|
||||
// the existing interleaved output for text-only frames and every other terminal.
|
||||
const drawKittyImagesLast =
|
||||
redrawImages && this.imageProtocol === "kitty" && screen.some(isImageLine) && isWezTerm;
|
||||
if (drawKittyImagesLast) {
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
buffer += `\x1b[${row + 1};1H\x1b[2K`;
|
||||
}
|
||||
}
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
buffer += `\x1b[${row + 1};1H${clearRowsBeforeKittyImages ? "" : "\x1b[2K"}${preparedKittyScreen.lines[row] ?? ""}`;
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
if (isImageLine(preparedKittyScreen.lines[row] ?? "")) continue;
|
||||
buffer += `\x1b[${row + 1};1H${preparedKittyScreen.lines[row] ?? ""}`;
|
||||
}
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
if (!isImageLine(preparedKittyScreen.lines[row] ?? "")) continue;
|
||||
buffer += `\x1b[${row + 1};1H${preparedKittyScreen.lines[row] ?? ""}`;
|
||||
}
|
||||
} else {
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (cursorPos) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
getCapabilities,
|
||||
getKittyImageMetadata,
|
||||
getKittyImagePlacement,
|
||||
getKittyImagePlacementRows,
|
||||
hyperlink,
|
||||
imageFallback,
|
||||
isImageLine,
|
||||
|
|
@ -463,6 +464,11 @@ describe("Kitty image cursor movement", () => {
|
|||
assert.ok(sequence.startsWith("\x1b_Ga=T,f=100,q=2,C=1,c=2,r=2;"));
|
||||
});
|
||||
|
||||
it("reads explicit placement rows without registered metadata", () => {
|
||||
const sequence = encodeKitty("AAAA", { columns: 2, rows: 3, moveCursor: false });
|
||||
assert.strictEqual(getKittyImagePlacementRows(sequence), 3);
|
||||
});
|
||||
|
||||
it("suppresses Kitty replies for delete commands", () => {
|
||||
assert.strictEqual(deleteKittyImage(42), "\x1b_Ga=d,d=I,i=42,q=2\x1b\\");
|
||||
assert.strictEqual(deleteAllKittyImages(), "\x1b_Ga=d,d=A,q=2\x1b\\");
|
||||
|
|
@ -532,8 +538,10 @@ describe("Kitty image cursor movement", () => {
|
|||
const line = `left ${cropKittyImageLine(transmission, 2, 1)} right`;
|
||||
const placement = getKittyImagePlacement(line);
|
||||
assert.ok(placement);
|
||||
assert.strictEqual(getKittyImagePlacementRows(line), 1);
|
||||
assert.strictEqual(placement.transmissionBytes, line.length - "left ".length - " right".length);
|
||||
assert.strictEqual(placement.estimatedDecodedBytes, 100 * 100 * 4);
|
||||
assert.strictEqual(placement.rows, 1);
|
||||
assert.strictEqual(placement.sequence, "\x1b_Ga=p,q=2,C=1,c=3,i=42,y=66,h=34,r=1\x1b\\");
|
||||
assert.strictEqual(placement.replacementLine, `left ${placement.sequence} right`);
|
||||
assert.ok(!placement.replacementLine.includes("AAAA"));
|
||||
|
|
|
|||
|
|
@ -1062,6 +1062,47 @@ describe("TuiAltScreen", () => {
|
|||
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;
|
||||
let tui: TuiAltScreen | undefined;
|
||||
process.env.WEZTERM_PANE = "1";
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
try {
|
||||
const terminal = new RecordingTerminal(20, 4);
|
||||
const imageId = 10319;
|
||||
const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false });
|
||||
registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 });
|
||||
let coveredLine = "";
|
||||
tui = new TuiAltScreen(terminal);
|
||||
tui.setLayoutRoot({
|
||||
render: () => [imageLine, coveredLine, "", "after"],
|
||||
invalidate: () => {},
|
||||
});
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const eventCount = terminal.events.length;
|
||||
|
||||
coveredLine = "changed";
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
const redrawWrites = terminal.events
|
||||
.slice(eventCount)
|
||||
.filter((event): event is { type: "write"; data: string } => event.type === "write")
|
||||
.map((event) => event.data)
|
||||
.join("");
|
||||
const placementIndex = redrawWrites.indexOf("\x1b_Ga=p,q=2");
|
||||
assert.ok(redrawWrites.includes("\x1b_Ga=d,d=a,q=2\x1b\\"));
|
||||
assert.ok(placementIndex > redrawWrites.indexOf("changed"));
|
||||
assert.ok(!redrawWrites.includes("\x1b_Ga=T"));
|
||||
} finally {
|
||||
tui?.stop();
|
||||
resetCapabilitiesCache();
|
||||
if (weztermPane === undefined) delete process.env.WEZTERM_PANE;
|
||||
else process.env.WEZTERM_PANE = weztermPane;
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses moved Kitty images without dropping HStack siblings", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue