Glide the asset and object lists and scroll past their end (#309)
game-maker / gate (push) Successful in 4m21s
game-maker / suite (push) Canceled after 8m42s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 22:14:54 +02:00
1 parent 322fc39d16
commit be41b1f0d7
6 files changed
+133 -5

No files matched your search

+4
View File
@@ -44,6 +44,7 @@ import { TOOLBAR_ICON_SVG } from './toolbarIcons';
import { activateAsset, confirmDeleteAsset } from '../actions'; import { activateAsset, confirmDeleteAsset } from '../actions';
import { openReferenceItems } from '../objectMenu'; import { openReferenceItems } from '../objectMenu';
import { generalSettings, onGeneralSettings } from '../generalSettings'; import { generalSettings, onGeneralSettings } from '../generalSettings';
import { scrollTween } from '../viewTween';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { openFontPicker } from '../font/FontPicker'; import { openFontPicker } from '../font/FontPicker';
import { openSoundPicker } from '../sound/SoundPicker'; import { openSoundPicker } from '../sound/SoundPicker';
@@ -108,6 +109,9 @@ export class AssetPanel extends Panel {
this.setCategoryFolded(id.slice(CATEGORY.length) as AssetCategory, !expand); this.setCategoryFolded(id.slice(CATEGORY.length) as AssetCategory, !expand);
else this.setFolded(id.slice(HEADING.length) as AssetType, !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.scroll.setContent(this.list);
this.content.add(this.scroll, 1); this.content.add(this.scroll, 1);
+2
View File
@@ -21,6 +21,7 @@ import { ASSET_ICON_SVG, GROUP_ICON_SVG } from './assetIcons';
import { hierarchyDrop } from './hierarchyDrop'; import { hierarchyDrop } from './hierarchyDrop';
import { hierarchyRange } from './hierarchyRange'; import { hierarchyRange } from './hierarchyRange';
import { type EditorState, objectSelection } from '../state'; 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 * 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); if (assetId) state.pingAsset(assetId);
}; };
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
scrollTween(this.scroll);
this.scroll.setContent(this.list); this.scroll.setContent(this.list);
this.content.add(this.scroll, 1); this.content.add(this.scroll, 1);
+17 -1
View File
@@ -1,4 +1,4 @@
import { requestRender, ui } from '../ui'; import { requestRender, type ScrollContainer, ui } from '../ui';
import { generalSettings } from './generalSettings'; import { generalSettings } from './generalSettings';
/** /**
@@ -161,6 +161,22 @@ export interface AxisMap {
offset: number; 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. */ /** A tween along one axis; the music tabs keep one per axis, so either zooms on its own. */
export function axisTween( export function axisTween(
read: () => AxisMap, read: () => AxisMap,
+14 -3
View File
@@ -18,13 +18,24 @@ export class ScrollContainer extends Widget {
private draggingBar = false; private draggingBar = false;
private dragStart = 0; private dragStart = 0;
private dragScrollStart = 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() { constructor() {
super(); super();
this.addChild(this.viewportNode, this.maskShape, this.scrollbar); this.addChild(this.viewportNode, this.maskShape, this.scrollbar);
this.viewportNode.mask = this.maskShape; this.viewportNode.mask = this.maskShape;
this.on('wheel', (e: FederatedWheelEvent) => { this.on('wheel', (e: FederatedWheelEvent) => {
this.scrollTo(this.scrollY + e.deltaY); this.wheelStep(() => this.scrollTo(this.scrollY + e.deltaY));
e.preventDefault(); e.preventDefault();
}); });
this.scrollbar.eventMode = 'static'; this.scrollbar.eventMode = 'static';
@@ -86,9 +97,9 @@ export class ScrollContainer extends Widget {
relayoutContent(): void { relayoutContent(): void {
if (!this.content) return; if (!this.content) return;
// Wrapped text grows taller when the scrollbar takes its width, so measure again at that width. // 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)); 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); const height = Math.max(needed, this.h);
// Content shorter than the viewport is pinned to the viewport height either way, so `resize` // 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. // would see no change and skip the layout -- leaving rows added since stacked at the origin.
+66 -1
View File
@@ -9,7 +9,15 @@ import {
} from '../../src/editor/generalSettings'; } from '../../src/editor/generalSettings';
import { zoomedAbout } from '../../src/editor/graph/graphView'; import { zoomedAbout } from '../../src/editor/graph/graphView';
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView'; 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() { function fakeClock() {
let time = 0; 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<typeof fakeClock>) {
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', () => { describe('the general settings', () => {
function memoryStore() { function memoryStore() {
const items = new Map<string, string>(); const items = new Map<string, string>();
+30
View File
@@ -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', () => { describe('a scroll container and its scrollbar', () => {
const filled = (viewportH: number, rowCount: number) => { const filled = (viewportH: number, rowCount: number) => {
const scroll = new ScrollContainer(); const scroll = new ScrollContainer();