From be41b1f0d771231133f098ab1b28f4db03a21780 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 11 Oct 2026 22:14:54 +0200 Subject: [PATCH] Glide the asset and object lists and scroll past their end (#309) 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 --- src/editor/panels/AssetPanel.ts | 4 ++ src/editor/panels/HierarchyPanel.ts | 2 + src/editor/viewTween.ts | 18 +++++++- src/ui/widgets/ScrollContainer.ts | 17 ++++++-- tests/editor/view-tween.test.ts | 67 ++++++++++++++++++++++++++++- tests/ui/scroll-container.test.ts | 30 +++++++++++++ 6 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index d4ae4fe9..85713f5c 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -44,6 +44,7 @@ import { TOOLBAR_ICON_SVG } from './toolbarIcons'; import { activateAsset, confirmDeleteAsset } from '../actions'; import { openReferenceItems } from '../objectMenu'; import { generalSettings, onGeneralSettings } from '../generalSettings'; +import { scrollTween } from '../viewTween'; import type { EditorState } from '../state'; import { openFontPicker } from '../font/FontPicker'; import { openSoundPicker } from '../sound/SoundPicker'; @@ -108,6 +109,9 @@ export class AssetPanel extends Panel { this.setCategoryFolded(id.slice(CATEGORY.length) as AssetCategory, !expand); else this.setFolded(id.slice(HEADING.length) as AssetType, !expand); }; + // Room to scroll the last group up off the panel's bottom edge, a few rows' worth. + this.scroll.tailSpace = 6 * this.list.rowHeight; + scrollTween(this.scroll); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index cf39068d..7a3cb1e9 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -21,6 +21,7 @@ import { ASSET_ICON_SVG, GROUP_ICON_SVG } from './assetIcons'; import { hierarchyDrop } from './hierarchyDrop'; import { hierarchyRange } from './hierarchyRange'; import { type EditorState, objectSelection } from '../state'; +import { scrollTween } from '../viewTween'; /** * Objects of the scene or container being edited, as a tree; dragging re-parents or reorders and always @@ -66,6 +67,7 @@ export class HierarchyPanel extends Panel { if (assetId) state.pingAsset(assetId); }; this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); + scrollTween(this.scroll); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); diff --git a/src/editor/viewTween.ts b/src/editor/viewTween.ts index 15c480e0..23058227 100644 --- a/src/editor/viewTween.ts +++ b/src/editor/viewTween.ts @@ -1,4 +1,4 @@ -import { requestRender, ui } from '../ui'; +import { requestRender, type ScrollContainer, ui } from '../ui'; import { generalSettings } from './generalSettings'; /** @@ -161,6 +161,22 @@ export interface AxisMap { offset: number; } +/** + * Shows a wheel step of a scrolled list as a transition, as long as a zoom's. Quick steps add up as + * they do there, and dragging the bar or a jump to a row ends the transition where it stands. + */ +export function scrollTween(scroll: ScrollContainer, duration?: () => number, clock?: TweenClock): ViewTween { + const tween = axisTween( + () => ({ scale: 1, offset: scroll.offset }), + ({ offset }) => scroll.scrollTo(offset), + () => {}, + duration, + clock, + ); + scroll.wheelStep = (step) => tween.change(step); + return tween; +} + /** A tween along one axis; the music tabs keep one per axis, so either zooms on its own. */ export function axisTween( read: () => AxisMap, diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index 27f1528f..4775bdb0 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -18,13 +18,24 @@ export class ScrollContainer extends Widget { private draggingBar = false; private dragStart = 0; private dragScrollStart = 0; + /** + * Empty room always kept below the content, so its last row can be scrolled up away from the + * bottom edge. Content that fits only without it scrolls by the difference. Opt-in; the content + * is made this much taller, so a list lays its empty area (clicks, context menu) over it as well. + */ + tailSpace = 0; + /** + * Carries out a wheel step, by default at once. A panel can hand it to a transition instead, + * which makes the step and then shows the way to where it led. + */ + wheelStep: (step: () => void) => void = (step) => step(); constructor() { super(); this.addChild(this.viewportNode, this.maskShape, this.scrollbar); this.viewportNode.mask = this.maskShape; this.on('wheel', (e: FederatedWheelEvent) => { - this.scrollTo(this.scrollY + e.deltaY); + this.wheelStep(() => this.scrollTo(this.scrollY + e.deltaY)); e.preventDefault(); }); this.scrollbar.eventMode = 'static'; @@ -86,9 +97,9 @@ export class ScrollContainer extends Widget { relayoutContent(): void { if (!this.content) return; // Wrapped text grows taller when the scrollbar takes its width, so measure again at that width. - let needed = this.content.heightFor(this.w); + let needed = this.content.heightFor(this.w) + this.tailSpace; const width = Math.max(0, this.w - (needed > this.h ? Theme.scrollbarWidth : 0)); - if (width !== this.w) needed = this.content.heightFor(width); + if (width !== this.w) needed = this.content.heightFor(width) + this.tailSpace; const height = Math.max(needed, this.h); // Content shorter than the viewport is pinned to the viewport height either way, so `resize` // would see no change and skip the layout -- leaving rows added since stacked at the origin. diff --git a/tests/editor/view-tween.test.ts b/tests/editor/view-tween.test.ts index b4652c88..de4032c9 100644 --- a/tests/editor/view-tween.test.ts +++ b/tests/editor/view-tween.test.ts @@ -9,7 +9,15 @@ import { } from '../../src/editor/generalSettings'; import { zoomedAbout } from '../../src/editor/graph/graphView'; import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView'; -import { axisTween, easeOut, tweenView, type ViewMap, ViewTween } from '../../src/editor/viewTween'; +import { + axisTween, + easeOut, + scrollTween, + tweenView, + type ViewMap, + ViewTween, +} from '../../src/editor/viewTween'; +import { ScrollContainer, Spacer, VStack } from '../../src/ui'; function fakeClock() { let time = 0; @@ -241,6 +249,63 @@ describe('a view whose axes zoom apart (#377)', () => { }); }); +describe('a list scrolled by the wheel', () => { + function list(clock: ReturnType) { + const scroll = new ScrollContainer(); + scroll.resize(200, 100); + const rows = new VStack(); + for (let i = 0; i < 20; i++) rows.add(new Spacer(20)); + scroll.setContent(rows); + const tween = scrollTween(scroll, () => 200, clock); + const wheel = (deltaY: number) => scroll.wheelStep(() => scroll.scrollTo(scroll.offset + deltaY)); + return { scroll, tween, wheel }; + } + + it('glides to where the step leads and lands there', () => { + const clock = fakeClock(); + const { scroll, tween, wheel } = list(clock); + wheel(100); + expect(scroll.offset).toBe(0); + clock.advance(80); + expect(scroll.offset).toBeGreaterThan(0); + expect(scroll.offset).toBeLessThan(100); + clock.advance(200); + expect(scroll.offset).toBe(100); + expect(tween.moving).toBe(false); + }); + + it('adds up quick steps and stops at the end of the list', () => { + const clock = fakeClock(); + const { scroll, wheel } = list(clock); + wheel(100); + clock.advance(50); + wheel(100); + clock.advance(400); + expect(scroll.offset).toBe(200); + wheel(500); + clock.advance(400); + expect(scroll.offset).toBe(scroll.maxScroll); + }); + + it('lets go when the bar or a jump to a row moves the list', () => { + const clock = fakeClock(); + const { scroll, tween, wheel } = list(clock); + wheel(100); + clock.advance(50); + scroll.scrollTo(250); + clock.advance(10); + expect(tween.moving).toBe(false); + expect(scroll.offset).toBe(250); + }); + + it('jumps at once with the transition switched off', () => { + const { scroll } = list(fakeClock()); + scrollTween(scroll, () => 0, fakeClock()); + scroll.wheelStep(() => scroll.scrollTo(60)); + expect(scroll.offset).toBe(60); + }); +}); + describe('the general settings', () => { function memoryStore() { const items = new Map(); diff --git a/tests/ui/scroll-container.test.ts b/tests/ui/scroll-container.test.ts index 6411be95..31566920 100644 --- a/tests/ui/scroll-container.test.ts +++ b/tests/ui/scroll-container.test.ts @@ -37,6 +37,36 @@ describe('a scroll container told its content changed', () => { }); }); +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();