fix(tui): clip Kitty images to layout boundaries

This commit is contained in:
Armin Ronacher 2026-07-31 17:49:05 +02:00
parent b3ed27b3fe
commit af187eee4b
5 changed files with 33 additions and 4 deletions

View file

@ -13,6 +13,7 @@
### Fixed
- Fixed Kitty image previews in fullscreen mode overlapping the sticky editor and footer dock while scrolling.
- Fixed spaces in `/settings` searches toggling the highlighted setting while typing multi-word queries such as **UI mode** or **Quiet startup**.
- Fixed custom editors not inheriting the default editor's autocomplete dropdown item limit ([#7333](https://github.com/earendil-works/pi/issues/7333)).
- Fixed malformed resource arrays in package manifests crashing session startup ([#7187](https://github.com/earendil-works/pi/issues/7187)).

View file

@ -7,6 +7,7 @@
- Fixed terminal width accounting for Indic conjunct grapheme clusters ([#6124](https://github.com/earendil-works/pi/issues/6124) by [@petrroll](https://github.com/petrroll)).
- Fixed phantom alternate-screen text selection from unmatched mouse events when changing terminal pane focus.
- Fixed spaces in searchable settings queries changing the selected value instead of filtering multi-word labels.
- Fixed alternate-screen Kitty images crossing vertical layout clip boundaries and overlapping sticky regions while scrolling.
### Added

View file

@ -305,7 +305,13 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void {
if (row < box.clip.y || row >= box.clip.y + box.clip.height || row < 0 || row >= screen.length) continue;
const sourceLine = box.lines[offset + localRow];
if (sourceLine === undefined) continue;
const line = sourceLine.replace(OSC133_ZONE_PREFIX, "");
let line = sourceLine.replace(OSC133_ZONE_PREFIX, "");
const imageMetadata = getKittyImageMetadata(line);
if (imageMetadata) {
const clipBottom = Math.min(screen.length, box.clip.y + box.clip.height);
const visibleRows = Math.min(imageMetadata.rows, clipBottom - row);
if (visibleRows < imageMetadata.rows) line = cropKittyImageLine(line, 0, visibleRows);
}
if (isImageLine(line) && box.rect.x === 0 && box.rect.width >= totalWidth) screen[row] = line;
else screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth);
}

View file

@ -284,12 +284,14 @@ export function getKittyImageMetadata(line: string): KittyImageMetadata | undefi
export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows: number): string {
const metadata = getKittyImageMetadata(line);
const match = /\x1b_G([^;]*);/.exec(line);
if (!metadata || !match || hiddenRows <= 0 || visibleRows <= 0) return line;
if (!metadata || !match || hiddenRows < 0 || hiddenRows >= metadata.rows || visibleRows <= 0) return line;
const croppedRows = Math.min(visibleRows, metadata.rows - hiddenRows);
if (hiddenRows === 0 && croppedRows === metadata.rows) return line;
const sourceY = Math.floor((metadata.heightPx * hiddenRows) / metadata.rows);
const sourceEnd = Math.ceil((metadata.heightPx * (hiddenRows + visibleRows)) / metadata.rows);
const sourceEnd = Math.ceil((metadata.heightPx * (hiddenRows + croppedRows)) / metadata.rows);
const sourceHeight = Math.max(1, Math.min(metadata.heightPx, sourceEnd) - sourceY);
const controls = match[1].split(",").filter((control) => !/^[yhr]=/.test(control));
controls.push(`y=${sourceY}`, `h=${sourceHeight}`, `r=${visibleRows}`);
controls.push(`y=${sourceY}`, `h=${sourceHeight}`, `r=${croppedRows}`);
return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`;
}

View file

@ -5,6 +5,7 @@ import { ScrollView } from "../src/components/scroll-view.ts";
import { Text } from "../src/components/text.ts";
import { VStack } from "../src/components/v-stack.ts";
import { renderLayoutFrame } from "../src/layout.ts";
import { encodeKitty, registerKittyImageMetadata } from "../src/terminal-image.ts";
import { stripTerminalSequences } from "../src/utils.ts";
function visibleLines(lines: string[]): string[] {
@ -59,6 +60,24 @@ describe("viewport layout", () => {
);
});
it("crops Kitty images at a scroll view's lower boundary", () => {
const imageId = 124;
const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false });
registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 });
const transcript = new ScrollView({
render: () => ["one", "two", imageLine, "", ""],
invalidate: () => {},
});
const frame = renderLayoutFrame(
new VStack([{ component: transcript, basis: 0, grow: 1 }, new Text("dock", 0, 0)]),
20,
4,
() => {},
);
assert.ok(frame.lines[2]?.includes("y=0,h=34,r=1"));
});
it("composes horizontal children at allocated widths", () => {
const frame = renderLayoutFrame(
new HStack([