The asset panel stopped with its last group flush on the bottom edge, and both lists on the left jumped on every wheel step while the tabs next to them already zoom with a transition. - ScrollContainer gets tailSpace, empty room always kept below the content. Content that fits only without it scrolls by the rest. The content is made that much taller, so clicks and the context menu on that room work as on any empty part of the list. - The asset panel keeps six rows of it. The other lists keep none. - ScrollContainer gets wheelStep, which by default scrolls at once. - scrollTween() in viewTween.ts hands the wheel step to an axisTween. The duration is the zoom transition's from the General settings, quick steps add up and stop at the end of the list, and dragging the bar or a jump to a row ends the glide where it stands. - Only the asset panel and Scene objects use it. Inspector, menus and the console still jump. - Wiki: Concepts describes the glide and the room below the assets. Not checked in the browser by me. The user picked the six rows and asked for the glide after trying the room below the list. Tests: - Unit scroll-container: 10 tests (3 new for tailSpace). - Unit view-tween: 4 new with a fake clock (glide lands on target, quick steps add up and stop at the end, a jump ends it, 0 ms jumps). - Gate on melektaus-atx: CHECK EXIT 0, 299 files, 4593 tests (4586 before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
4.3 KiB
TypeScript
131 lines
4.3 KiB
TypeScript
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 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);
|
|
});
|
|
});
|