import { describe, expect, it } from 'vitest'; import { ScrollContainer, Spacer, Theme, VStack, Widget } from '../../src/ui'; /** Row tops after a layout; an unlaid-out stack leaves every child at 0. */ const tops = (stack: VStack) => stack.children.map((c) => c.y); describe('a scroll container told its content changed', () => { it('lays out rows that leave the height pinned to the viewport', () => { const scroll = new ScrollContainer(); scroll.resize(200, 300); const rows = new VStack(); // The list tab hands over the stack before filling it, so the empty content is measured first // and pinned to the viewport height. scroll.setContent(rows); rows.add(new Spacer(20)); rows.add(new Spacer(20)); rows.add(new Spacer(20)); // 60 < 300, so the content keeps the height it already had: `resize` alone would see no // change and skip the layout, leaving all three rows stacked at the top. scroll.relayoutContent(); expect(tops(rows)).toEqual([0, 20, 40]); }); it('still lays out rows that make the content taller than the viewport', () => { const scroll = new ScrollContainer(); scroll.resize(200, 50); const rows = new VStack(); scroll.setContent(rows); rows.add(new Spacer(20)); rows.add(new Spacer(20)); rows.add(new Spacer(20)); scroll.relayoutContent(); expect(tops(rows)).toEqual([0, 20, 40]); expect(scroll.maxScroll).toBe(10); }); }); describe('a scroll container with tail space', () => { const filled = (viewportH: number, rowCount: number) => { const scroll = new ScrollContainer(); scroll.tailSpace = 40; scroll.resize(200, viewportH); const rows = new VStack(); for (let i = 0; i < rowCount; i++) rows.add(new Spacer(20)); scroll.setContent(rows); return { scroll, rows }; }; it('scrolls that far past the end of content that scrolls', () => { const { scroll, rows } = filled(50, 3); expect(rows.h).toBe(100); expect(scroll.maxScroll).toBe(50); }); it('keeps it below content that fits only without it', () => { const { scroll, rows } = filled(80, 3); expect(rows.h).toBe(100); expect(scroll.maxScroll).toBe(20); }); it('adds nothing to scroll when content and tail fit', () => { const { scroll, rows } = filled(300, 3); expect(rows.h).toBe(300); expect(scroll.maxScroll).toBe(0); }); }); describe('a scroll container holding its offset', () => { const scrolledToEnd = (holdOffset: boolean) => { const scroll = new ScrollContainer(); scroll.holdOffset = holdOffset; scroll.resize(200, 100); const rows = new VStack(); for (let i = 0; i < 10; i++) rows.add(new Spacer(20)); scroll.setContent(rows); scroll.scrollTo(100); // Three rows go, as a folded group's would. rows.removeChildren(7); scroll.relayoutContent(); return { scroll, rows }; }; it('keeps the offset when the content shrinks, the room left below', () => { const { scroll, rows } = scrolledToEnd(true); expect(scroll.offset).toBe(100); expect(rows.h).toBe(200); }); it('lets the room go as the view is scrolled up off it', () => { const { scroll, rows } = scrolledToEnd(true); scroll.scrollTo(70); expect(rows.h).toBe(170); expect(scroll.maxScroll).toBe(70); scroll.scrollTo(20); expect(rows.h).toBe(140); expect(scroll.maxScroll).toBe(40); }); it('without it clamps the offset, sliding every row down', () => { const { scroll, rows } = scrolledToEnd(false); expect(scroll.offset).toBe(40); expect(rows.h).toBe(140); }); }); describe('a scroll container and its scrollbar', () => { const filled = (viewportH: number, rowCount: number) => { const scroll = new ScrollContainer(); scroll.resize(200, viewportH); const rows = new VStack(); for (let i = 0; i < rowCount; i++) rows.add(new Spacer(20)); scroll.setContent(rows); return { scroll, rows }; }; it('gives content that fits the full width', () => { expect(filled(300, 3).rows.w).toBe(200); }); it('keeps the gutter free for the bar while the content scrolls', () => { expect(filled(50, 3).rows.w).toBe(200 - Theme.scrollbarWidth); }); it('hands the gutter back once the content fits again', () => { const { scroll, rows } = filled(50, 3); scroll.resize(200, 300); expect(rows.w).toBe(200); }); }); /** Stands in for wrapped text: 20 px per line, a line per 100 px of text. */ class Wrapping extends Widget { constructor(private readonly textWidth: number) { super(); } override heightFor(width: number): number { return Math.ceil(this.textWidth / width) * 20; } } describe('a scroll container with content that wraps', () => { it('gives a row the height it needs at the width it gets', () => { const scroll = new ScrollContainer(); scroll.resize(200, 300); const rows = new VStack(); const text = rows.add(new Wrapping(500)); rows.add(new Spacer(20)); scroll.setContent(rows); expect(text.h).toBe(60); expect(tops(rows)).toEqual([0, 60]); }); it('measures again once the scrollbar narrows the content', () => { const scroll = new ScrollContainer(); scroll.resize(200, 100); const rows = new VStack(); // 400 px of text is two lines at 200 px, but three beside the bar. const text = rows.add(new Wrapping(400)); rows.add(new Spacer(70)); scroll.setContent(rows); expect(rows.w).toBe(200 - Theme.scrollbarWidth); expect(text.h).toBe(60); expect(scroll.maxScroll).toBe(30); }); });