import assert from "node:assert"; import { describe, it } from "node:test"; import { HStack } from "../src/components/h-stack.ts"; 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[] { return lines.map((line) => stripTerminalSequences(line).trimEnd()); } describe("viewport layout", () => { it("allocates vertical grow space deterministically", () => { const frame = renderLayoutFrame( new VStack([ { component: new Text("top", 0, 0), basis: 1, shrink: 0 }, { component: new Text("body", 0, 0), basis: 0, grow: 1 }, ]), 10, 4, () => {}, ); assert.deepStrictEqual( frame.root.children.map((child) => child.rect.height), [1, 3], ); assert.deepStrictEqual(visibleLines(frame.lines), ["top", "body", "", ""]); }); it("shrinks entries to their minimum sizes", () => { const frame = renderLayoutFrame( new VStack([ { component: new Text("a1\na2\na3", 0, 0), shrink: 1, minSize: 1 }, { component: new Text("b1\nb2\nb3", 0, 0), shrink: 0 }, ]), 10, 4, () => {}, ); assert.deepStrictEqual( frame.root.children.map((child) => child.rect.height), [1, 3], ); assert.deepStrictEqual(visibleLines(frame.lines), ["a1", "b1", "b2", "b3"]); }); it("omits gaps around invisible entries", () => { const stack = new VStack( [new Text("one", 0, 0), { component: new Text("hidden", 0, 0), visible: () => false }, new Text("two", 0, 0)], { gap: 1 }, ); assert.deepStrictEqual( stack.render(10).map((line) => line.trimEnd()), ["one", "", "two"], ); }); 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([ { component: new Text("left", 0, 0), basis: 6, shrink: 0 }, { component: new Text("right", 0, 0), basis: 6, shrink: 0 }, ]), 12, 1, () => {}, ); assert.deepStrictEqual(visibleLines(frame.lines), ["left right"]); }); it("does not paint zero-width horizontal children", () => { const frame = renderLayoutFrame( new HStack([ { component: new Text("hidden", 0, 0), basis: 0, shrink: 0 }, { component: new Text("shown", 0, 0), basis: 0, grow: 1 }, ]), 5, 1, () => {}, ); assert.deepStrictEqual(visibleLines(frame.lines), ["shown"]); }); it("tracks follow-end state and returns unused scroll delta", () => { const scrollView = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0), { follow: "end", primary: true, }); renderLayoutFrame(scrollView, 10, 3, () => {}); assert.strictEqual(scrollView.scrollTop, 3); assert.strictEqual(scrollView.isFollowingEnd, true); assert.strictEqual(scrollView.scrollBy(-2), 0); assert.strictEqual(scrollView.scrollTop, 1); assert.strictEqual(scrollView.isFollowingEnd, false); assert.strictEqual(scrollView.scrollBy(-3), -2); assert.strictEqual(scrollView.scrollTop, 0); assert.strictEqual(scrollView.scrollBy(10), 7); assert.strictEqual(scrollView.scrollTop, 3); assert.strictEqual(scrollView.isFollowingEnd, true); }); it("renders a transient proportional scrollbar without replacing cell content", async () => { const sourceLines = ["abcd界", "abcde2", "abcde3", "abcde4", "abcde5", "abcde6", "abcde7", "abcde8"]; const contentBackground = "\x1b[42m"; const scrollbarBackground = "\x1b[48;5;1m"; const scrollbarStyle = (text: string) => `${scrollbarBackground}${text}\x1b[49m`; const content = new Text(sourceLines.join("\n"), 0, 0, (text) => `${contentBackground}${text}\x1b[49m`); const scrollView = new ScrollView(content, { scrollbar: "auto", scrollbarStyle, scrollbarHideDelayMs: 10, }); const render = () => renderLayoutFrame(scrollView, 6, 4, () => {}).lines; const thumbRows = (lines: string[]) => lines.map((line) => line.includes(scrollbarBackground)); let lines = render(); assert.deepStrictEqual(thumbRows(lines), [false, false, false, false]); assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(0, 4)); scrollView.scrollBy(2); lines = render(); assert.deepStrictEqual(thumbRows(lines), [false, true, true, false]); assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(2, 6)); assert.ok(lines[1]!.lastIndexOf(contentBackground) < lines[1]!.lastIndexOf(scrollbarBackground)); await new Promise((resolve) => setTimeout(resolve, 30)); lines = render(); assert.deepStrictEqual(thumbRows(lines), [false, false, false, false]); scrollView.scrollToEnd(); lines = render(); assert.deepStrictEqual(thumbRows(lines), [false, false, true, true]); assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(4)); const followedContent = new Text(sourceLines.join("\n"), 0, 0); const followed = new ScrollView(followedContent, { follow: "end", scrollbar: "auto", scrollbarStyle, }); renderLayoutFrame(followed, 6, 4, () => {}); assert.strictEqual(followed.scrollTop, 4); followedContent.setText(`${sourceLines.join("\n")}\nabcde9`); const growthFrame = renderLayoutFrame(followed, 6, 4, () => {}); assert.strictEqual(followed.scrollTop, 5); assert.ok(growthFrame.lines.every((line) => !line.includes(scrollbarBackground))); const fittingContent = new Text("1\n2", 0, 0); const automatic = new ScrollView(fittingContent, { scrollbar: "auto", scrollbarStyle }); renderLayoutFrame(automatic, 6, 4, () => {}); automatic.scrollBy(1); assert.ok( renderLayoutFrame(automatic, 6, 4, () => {}).lines.every((line) => !line.includes(scrollbarBackground)), ); const alwaysFitting = new ScrollView(fittingContent, { scrollbar: "always", scrollbarStyle }); const alwaysFittingFrame = renderLayoutFrame(alwaysFitting, 6, 4, () => {}); assert.strictEqual(alwaysFittingFrame.root.children[0]?.rect.width, 5); assert.ok(alwaysFittingFrame.lines.every((line) => line.includes(scrollbarBackground))); const alwaysOverflowing = new ScrollView(content, { scrollbar: "always", scrollbarStyle }); const alwaysOverflowingFrame = renderLayoutFrame(alwaysOverflowing, 6, 4, () => {}); assert.strictEqual(alwaysOverflowingFrame.root.children[0]?.rect.width, 5); assert.strictEqual(alwaysOverflowingFrame.lines.filter((line) => line.includes(scrollbarBackground)).length, 2); const thumbHeightFor = (contentHeight: number) => { const sized = new ScrollView(new Text(Array.from({ length: contentHeight }, () => "x").join("\n"), 0, 0), { scrollbar: "auto", scrollbarStyle, }); renderLayoutFrame(sized, 6, 20, () => {}); sized.scrollBy(1); return renderLayoutFrame(sized, 6, 20, () => {}).lines.filter((line) => line.includes(scrollbarBackground)) .length; }; assert.strictEqual(thumbHeightFor(21), 19); assert.strictEqual(thumbHeightFor(40), 10); assert.strictEqual(thumbHeightFor(100), 4); assert.strictEqual(thumbHeightFor(400), 2); }); it("updates reserved scrollbar layout at runtime", () => { const scrollView = new ScrollView(new Text("123456", 0, 0), { scrollbar: "always" }); const render = () => renderLayoutFrame(new HStack([scrollView], { align: "start" }), 6, 2, () => {}); const always = render(); assert.deepStrictEqual(visibleLines(always.lines), ["12345", "6"]); assert.strictEqual(always.root.children[0]?.rect.width, 6); assert.strictEqual(always.root.children[0]?.children[0]?.rect.width, 5); scrollView.setScrollbar("hidden"); assert.strictEqual(render().root.children[0]?.children[0]?.rect.width, 6); assert.strictEqual(scrollView.isScrollbarVisible, false); }); it("measures nested scroll content from constrained child geometry", () => { const inner = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0)); const outer = new ScrollView(new VStack([{ component: inner, basis: 2 }, new Text("tail", 0, 0)])); renderLayoutFrame(outer, 10, 2, () => {}); assert.strictEqual(inner.viewportHeight, 2); assert.strictEqual(outer.scrollBy(10), 9); assert.strictEqual(outer.scrollTop, 1); }); it("rebuilds geometry after content changes", () => { const text = new Text("one", 0, 0); const root = new VStack([text]); const first = renderLayoutFrame(root, 10, 4, () => {}); text.setText("one\ntwo\nthree"); const second = renderLayoutFrame(root, 10, 4, () => {}); assert.strictEqual(first.root.children[0]?.lines?.length, 1); assert.strictEqual(second.root.children[0]?.lines?.length, 3); }); });