fix(tui): skip clipped rows during layout painting

This commit is contained in:
Armin Ronacher 2026-08-02 16:56:38 +02:00
parent 73414d08b9
commit 4c01c70938
3 changed files with 25 additions and 5 deletions

View file

@ -8,7 +8,7 @@
- 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.
- Fixed alternate-screen redraws retransmitting Kitty image data, dropping adjacent row content when reusing placements, and rendering fixed-basis scroll content twice per frame.
- Fixed alternate-screen redraws retransmitting Kitty image data, dropping adjacent row content when reusing placements, rendering fixed-basis scroll content twice per frame, and scanning clipped transcript rows while painting.
### Added

View file

@ -304,10 +304,10 @@ function paintScrollbar(box: LayoutBox, screen: string[], totalWidth: number): v
function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void {
if (box.lines) {
const offset = box.lineOffset ?? 0;
for (let localRow = 0; localRow < box.rect.height; localRow++) {
const row = box.rect.y + localRow;
if (row < box.clip.y || row >= box.clip.y + box.clip.height || row < 0 || row >= screen.length) continue;
const sourceLine = box.lines[offset + localRow];
const firstRow = Math.max(box.rect.y, box.clip.y, 0);
const lastRow = Math.min(box.rect.y + box.rect.height, box.clip.y + box.clip.height, screen.length);
for (let row = firstRow; row < lastRow; row++) {
const sourceLine = box.lines[offset + row - box.rect.y];
if (sourceLine === undefined) continue;
let line = sourceLine.replace(OSC133_ZONE_PREFIX, "");
const imageMetadata = getKittyImageMetadata(line);

View file

@ -48,6 +48,26 @@ describe("viewport layout", () => {
assert.strictEqual(renderCount, 1);
});
it("paints only clipped rows from very large scroll content", () => {
const lineCount = 1_000_000_000;
const lines: string[] = [];
lines.length = lineCount;
lines[lineCount - 4] = "before";
lines[lineCount - 3] = "visible 1";
lines[lineCount - 2] = "visible 2";
lines[lineCount - 1] = "visible 3";
const transcript = new ScrollView(
{
render: () => lines,
invalidate: () => {},
},
{ follow: "end" },
);
const frame = renderLayoutFrame(transcript, 10, 3, () => {});
assert.deepStrictEqual(visibleLines(frame.lines), ["visible 1", "visible 2", "visible 3"]);
});
it("shrinks entries to their minimum sizes", () => {
const frame = renderLayoutFrame(
new VStack([