A trailer needs its music and effects on the same timeline as its pictures. Until now a composition could only place objects, so sound had to be started by hand from a behavior and drifted against the picture. - Audio items: a sound, a clip of a sound, a pattern or a song dragged from the asset panel onto a track becomes an item as long as one pass of it. It may share a track with object and video items and overlap them. - Editing works as for other items: move, resize, trim on the left (moves the offset), fit to media, delete, copy and paste, box selection. Sounds draw their waveform, the colour tells the kind. - The inspector shows what it plays, start, length, offset, volume (-60 to +12 dB) and bus. The bus is "From asset", the master or one of the buses, as asked for in the issue. - An item longer than its material plays in rounds. Sounds and patterns start over, a song runs on in its loop. Loop marks on the item follow the same rule, a song's first mark sits at its loop end. - compositionAudio.ts turns the items into sequences, each with its own tempo. CompositionSound plays them, in the tab and in a game. - In the tab the items are heard from the playhead on, straight into the master like every tab. A scrub is silent. Playing a song or pattern in its own tab stops the composition, and the other way round. - In a game they are planned on the audio clock for the moment the composition clock reaches them, with the scheduler's LEAD made up. While the clock stands (waiting for a video, paused, placement off) they are silent and resume at the right spot. Found along the way: - The asset panel never let a song out of its list, so a song could not be dragged anywhere. Song and scene tab reject it as they reject a pattern. - The asset panel scrolled to the selected asset on every rebuild of its list, so folding a group far below the selection jumped back up. Only a new selection scrolls now. - At the end of the list, folding a group pulled every row down by the rows that went away. ScrollContainer has a new opt-in holdOffset, set only in the asset panel, that keeps the offset until the user scrolls up. Limits: a time scale other than 1 or a frame longer than 0.1 s moves the picture and not the sound. A baked song plays its notes here, not the bake. Tests: - tsc, eslint, Prettier clean. Gate on melektaus-atx green, 309 files, 4788 tests (4772 plus 16 new). - Unit: composition-audio.test.ts 13 new, scroll-container 3 new. Counter-checks: without catching up on load time or without the silence on pause the unit tests go red. - e2e 68h: the sound is planned 1.2 ms after the composition reaches 0.5 s. Without the LEAD make-up it is 54 ms late and red. - e2e 04 and 69 ALL PASSED, 86 checks. 69o-ii drags a pattern and a song onto a track, 20 frames each. - e2e 04: folding Sound at the end of the list keeps its header at y 234. With the old panel it jumps to 984 and the check is red. - Not run in a browser: the loop marks of a song item, copy and paste of audio items, the bus choice. - README and wiki count 2072 checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
5.5 KiB
TypeScript
169 lines
5.5 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 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);
|
|
});
|
|
});
|