Scroll the rows of the animation timeline up and down (#413)
game-maker / gate (push) Successful in 4m28s
game-maker / suite (push) Successful in 18m13s

An animation with more rows than the panel is tall cut the bottom rows
off. There was no way to reach them, their fields or their keys.

- The view has a new `scrollY`. Rows scroll up under the ruler, which
  stays where it is together with the tab head.
- The wheel over the header column scrolls the rows. Over the cells it
  still zooms, and `Shift`+wheel still scrolls across.
- A scrollbar at the right edge shows up only when the rows do not
  fit. Its thumb can be dragged, a click beside it jumps there.
- The middle button pans both ways. `Shift`+drag stays across only.
- A row picked or added out of view is scrolled just far enough to be
  seen whole. Value fields out of view are not drawn.
- The tab keeps its scroll like its zoom when switching.

Found on the way: `zoomCells` built a new view from `panX` and
`cellWidth` only, so every zoom would have dropped the scroll. The
type check caught it, and a unit test now holds it.

Tests:
- tsc, eslint, Prettier clean
- Unit timeline-view: 20 green, 6 new. Bending `rowAt` to ignore the
  scroll or `revealRow` to do nothing turns exactly those tests red.
- e2e 25 on melektaus-atx: ALL PASSED, 37 checks (25j, 25j-ii new).
  32 rows, the wheel brings the last field into view and typing keys
  77 there. Dragging the thumb up goes back to 0.
- Gate on melektaus-atx: green, 305 files, 4730 tests.
- Not checked in the browser: the middle button, revealing a picked
  row, the wheel right over a value field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 07:22:11 +02:00
1 parent 0648853356
commit 480db9c4e4
6 files changed
+341 -56

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2057 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2059 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+153 -31
View File
@@ -60,6 +60,7 @@ import { FramesDialog, openFramesDialog } from './FramesDialog';
import {
clampMove,
clampPan,
clampScroll,
DEFAULT_VIEW,
frameAt,
frameCentreX,
@@ -69,12 +70,16 @@ import {
keyAt,
keysWithin,
type Lane,
maxScrollY,
revealRow,
ROW_HEIGHT,
rowAt,
rowCentreY,
rowTop,
RULER_HEIGHT,
rulerStep,
scrollByThumb,
scrollThumb,
type TimelineView,
zoomCells,
} from './timelineView';
@@ -86,6 +91,8 @@ const LABEL_INSET = 8;
/** Width of the value field at the right of a row's header (#408). */
const FIELD_WIDTH = 104;
const FIELD_INSET = 4;
/** How far from the right edge a press takes the scrollbar rather than the cells under it. */
const SCROLLBAR_REACH = 10;
/** A row's value field, and what it was built for: a row aimed elsewhere needs another widget. */
interface ValueField {
@@ -122,8 +129,15 @@ interface Band {
*/
export class TimelineCanvas extends Widget {
private readonly bg = new Graphics();
/** Objects' heads and the picked row's header – what scrolls with the rows across both halves. */
private readonly rowsBg = new Graphics();
private readonly cells = new Graphics();
private readonly keyMarks = new Graphics();
/** The ruler's band and borders, drawn over rows scrolled up under it. */
private readonly chrome = new Graphics();
private readonly ruler = new Graphics();
private readonly overlay = new Graphics();
private readonly scrollbar = new Graphics();
private readonly labelLayer = new Container();
private readonly fieldLayer = new Container();
private readonly rulerLayer = new Container();
@@ -133,7 +147,12 @@ export class TimelineCanvas extends Widget {
private linesFor: { asset: AnimationAsset | null; project: object; lines: TimelineLine[] } | null = null;
private readonly rulerTexts: Text[] = [];
private readonly hint = crispText({ text: '', style: textStyle('muted') });
/** Right of the header column. */
private readonly clip = new Graphics();
/** Below the ruler. */
private readonly rowsClip = new Graphics();
/** Below the ruler and right of the header column. */
private readonly cellClip = new Graphics();
private view: TimelineView = { ...DEFAULT_VIEW };
/** Only across: a frame's cell is `cellWidth` wide and frame 0 starts at `panX`. */
private readonly tween = new ViewTween({
@@ -151,7 +170,11 @@ export class TimelineCanvas extends Widget {
/** Seconds since play began; the playhead is a function of it (see `animationPlayheadAt`). */
private time = 0;
private playingNow = false;
private panning: { pointer: Vec2; panX: number } | null = null;
/** The middle button pans both ways, Shift and the left button only across. */
private panning: { pointer: Vec2; panX: number; scrollY: number; vertical: boolean } | null = null;
private thumbDrag: { pointerY: number; scrollY: number } | null = null;
/** The picked row last brought into view: a pick that stays on its row leaves the scroll alone. */
private revealed = '';
private dragging: KeyDrag | null = null;
private band: Band | null = null;
private scrubbing = false;
@@ -164,15 +187,27 @@ export class TimelineCanvas extends Widget {
super();
this.addChild(
this.bg,
this.rowsBg,
this.cells,
this.overlay,
this.keyMarks,
this.labelLayer,
this.fieldLayer,
this.chrome,
this.ruler,
this.rulerLayer,
this.overlay,
this.scrollbar,
this.hint,
this.clip,
this.rowsClip,
this.cellClip,
);
this.cells.mask = this.clip;
this.rowsBg.mask = this.rowsClip;
this.labelLayer.mask = this.rowsClip;
this.fieldLayer.mask = this.rowsClip;
this.cells.mask = this.cellClip;
this.keyMarks.mask = this.cellClip;
this.ruler.mask = this.clip;
this.overlay.mask = this.clip;
this.rulerLayer.mask = this.clip;
this.hint.eventMode = 'none';
@@ -188,7 +223,10 @@ export class TimelineCanvas extends Widget {
this.linesFor = null;
this.redraw();
});
state.on('selection', () => this.redraw());
state.on('selection', () => {
this.revealPicked();
this.redraw();
});
state.on('load', () => {
this.linesFor = null;
this.view = { ...DEFAULT_VIEW };
@@ -219,10 +257,24 @@ export class TimelineCanvas extends Widget {
}
restoreView(view: unknown): void {
this.view = { ...(view as TimelineView) };
this.view = { ...DEFAULT_VIEW, ...(view as Partial<TimelineView>) };
this.redraw();
}
/** Scrolls the rows to `y` pixels down, within what there is to scroll. */
private scrollRowsTo(y: number): void {
this.view = clampScroll({ ...this.view, scrollY: Math.round(y) }, this.lines().length, this.h);
}
/** A row picked outside the view – by a click elsewhere, or a row just added – is brought into it. */
private revealPicked(): void {
const picked = this.state.selectedAnimationId === this.assetId ? (this.state.selectedTrackId ?? '') : '';
if (picked === this.revealed) return;
this.revealed = picked;
const row = picked ? this.rowOf(picked) : -1;
if (row >= 0) this.view = revealRow(this.view, row, this.lines().length, this.h);
}
get isPlaying(): boolean {
return this.playingNow;
}
@@ -451,7 +503,10 @@ export class TimelineCanvas extends Widget {
const row = this.rowOf(trackId);
if (row < 0) return null;
const origin = this.getGlobalPosition();
return { x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), y: origin.y + rowCentreY(row) };
return {
x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame),
y: origin.y + rowCentreY(row, this.view.scrollY),
};
}
/** Where a row's header sits on screen. */
@@ -459,7 +514,20 @@ export class TimelineCanvas extends Widget {
const row = this.rowOf(trackId);
if (row < 0) return null;
const origin = this.getGlobalPosition();
return { x: origin.x + HEADER_WIDTH / 2, y: origin.y + rowCentreY(row) };
return { x: origin.x + HEADER_WIDTH / 2, y: origin.y + rowCentreY(row, this.view.scrollY) };
}
/** The scrollbar's thumb on screen while the rows do not all fit, its height and the scroll's reach. */
thumbGlobal(): (Vec2 & { height: number; max: number }) | null {
const thumb = scrollThumb(this.view, this.lines().length, this.h);
if (!thumb) return null;
const origin = this.getGlobalPosition();
return {
x: origin.x + this.w - SCROLLBAR_REACH / 2,
y: origin.y + thumb.top + thumb.height / 2,
height: thumb.height,
max: maxScrollY(this.lines().length, this.h),
};
}
/** Where a frame sits on the ruler. */
@@ -539,7 +607,7 @@ export class TimelineCanvas extends Widget {
/** The row under `y`; undefined over an object's head, the ruler and below the last line. */
private laneAt(y: number): AnimationTrack | undefined {
const lanes = this.lanes();
const row = rowAt(y, lanes.length);
const row = rowAt(y, lanes.length, this.view.scrollY);
return (row >= 0 ? lanes[row] : null) ?? undefined;
}
@@ -553,9 +621,27 @@ export class TimelineCanvas extends Widget {
if (!asset || isWithin(e.target, this.fieldLayer)) return;
const local = this.toLocal(e.global);
const point = { x: local.x, y: local.y };
const thumb = scrollThumb(this.view, this.lines().length, this.h);
if (e.button === 0 && thumb && local.x >= this.w - SCROLLBAR_REACH && local.y >= RULER_HEIGHT) {
// A press beside the thumb brings the thumb's middle under the pointer, then drags from there.
const off = local.y < thumb.top || local.y > thumb.top + thumb.height;
if (off) {
const dy = local.y - (thumb.top + thumb.height / 2);
this.scrollRowsTo(scrollByThumb(this.view.scrollY, dy, this.lines().length, this.h));
this.redraw();
}
this.thumbDrag = { pointerY: local.y, scrollY: this.view.scrollY };
e.stopPropagation();
return;
}
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
this.tween.target(() => {
this.panning = { pointer: point, panX: this.view.panX };
this.panning = {
pointer: point,
panX: this.view.panX,
scrollY: this.view.scrollY,
vertical: e.button === 1,
};
});
e.stopPropagation();
return;
@@ -616,11 +702,19 @@ export class TimelineCanvas extends Widget {
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
if (this.thumbDrag) {
const { thumbDrag } = this;
const dy = local.y - thumbDrag.pointerY;
this.scrollRowsTo(scrollByThumb(thumbDrag.scrollY, dy, this.lines().length, this.h));
this.redraw();
return;
}
if (this.panning) {
const { panning } = this;
this.tween.carry(() => {
this.view = clampPan({ ...this.view, panX: panning.panX + (local.x - panning.pointer.x) });
});
if (panning.vertical) this.scrollRowsTo(panning.scrollY - (local.y - panning.pointer.y));
this.redraw();
return;
}
@@ -648,6 +742,7 @@ export class TimelineCanvas extends Widget {
private onPointerUp(): void {
this.panning = null;
this.thumbDrag = null;
this.scrubbing = false;
const drag = this.dragging;
const band = this.band;
@@ -680,12 +775,16 @@ export class TimelineCanvas extends Widget {
this.redraw();
}
/** The wheel zooms over the cells and scrolls the rows over their headers; with Shift it pans across. */
private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
if (e.shiftKey) {
this.tween.land();
this.view = clampPan({ ...this.view, panX: this.view.panX - e.deltaY });
this.redraw();
} else if (local.x < HEADER_WIDTH) {
this.scrollRowsTo(this.view.scrollY + e.deltaY);
this.redraw();
} else {
this.tween.change(() => {
this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH));
@@ -699,23 +798,28 @@ export class TimelineCanvas extends Widget {
override redraw(): void {
const c = Theme.colors;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.viewport);
this.clip
.clear()
.rect(HEADER_WIDTH, 0, Math.max(0, this.w - HEADER_WIDTH), this.h)
.fill(0xffffff);
this.cells.clear();
this.overlay.clear();
const asset = this.asset();
const lines = this.lines();
const lanes = this.lanes();
const selectedRow = this.state.selectedTrackId ?? '';
// Fewer rows, or a taller tab, leave less to scroll.
this.view = clampScroll(this.view, lines.length, this.h);
const below = Math.max(0, this.h - RULER_HEIGHT);
const right = Math.max(0, this.w - HEADER_WIDTH);
this.clip.clear().rect(HEADER_WIDTH, 0, right, this.h).fill(0xffffff);
this.rowsClip.clear().rect(0, RULER_HEIGHT, this.w, below).fill(0xffffff);
this.cellClip.clear().rect(HEADER_WIDTH, RULER_HEIGHT, right, below).fill(0xffffff);
for (const g of [this.rowsBg, this.cells, this.keyMarks, this.ruler, this.overlay, this.scrollbar])
g.clear();
// Header column and ruler chrome.
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.viewport);
this.bg.rect(0, 0, HEADER_WIDTH, this.h).fill(c.panel);
this.bg.rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader);
this.bg.rect(HEADER_WIDTH - 1, 0, 1, this.h).fill(c.border);
this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border);
this.chrome.clear().rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader);
this.chrome.rect(HEADER_WIDTH - 1, 0, 1, RULER_HEIGHT).fill(c.border);
this.chrome.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border);
if (asset) {
this.drawRows(asset, lanes, selectedRow);
@@ -724,26 +828,42 @@ export class TimelineCanvas extends Widget {
this.drawPlayhead();
}
if (this.band?.active) drawBand(this.overlay, this.band.start, this.band.current);
this.drawScrollbar(lines.length);
this.syncLabels(lines, selectedRow);
this.syncFields(lines);
this.syncHint(asset);
}
/** Whether any of a row starting at `top` shows between the ruler and the bottom edge. */
private inView(top: number): boolean {
return top + ROW_HEIGHT > RULER_HEIGHT && top < this.h;
}
private drawScrollbar(rowCount: number): void {
const thumb = scrollThumb(this.view, rowCount, this.h);
if (!thumb) return;
const width = Theme.scrollbarWidth;
this.scrollbar
.roundRect(this.w - width - 2, thumb.top, width, thumb.height, width / 2)
.fill({ color: Theme.colors.textMuted, alpha: 0.6 });
}
private drawRows(asset: AnimationAsset, lanes: readonly Lane[], selectedRow: string): void {
const c = Theme.colors;
const count = frameCount(asset);
const x0 = HEADER_WIDTH + frameToX(this.view, 0);
const x1 = HEADER_WIDTH + frameToX(this.view, count);
lanes.forEach((track, row) => {
const y = rowTop(row);
const y = rowTop(row, this.view.scrollY);
if (!this.inView(y)) return;
// An object's head: a band across both halves, no cells – nothing is keyed on it.
if (!track) {
this.bg.rect(0, y, this.w, ROW_HEIGHT).fill(c.panelHeader);
this.bg.rect(0, y + ROW_HEIGHT - 1, this.w, 1).fill({ color: c.border, alpha: 0.6 });
this.rowsBg.rect(0, y, this.w, ROW_HEIGHT).fill(c.panelHeader);
this.rowsBg.rect(0, y + ROW_HEIGHT - 1, this.w, 1).fill({ color: c.border, alpha: 0.6 });
return;
}
if (track.id === selectedRow)
this.bg.rect(0, y, HEADER_WIDTH, ROW_HEIGHT).fill({ color: c.selection, alpha: 0.6 });
this.rowsBg.rect(0, y, HEADER_WIDTH, ROW_HEIGHT).fill({ color: c.selection, alpha: 0.6 });
this.cells.rect(HEADER_WIDTH, y, this.w - HEADER_WIDTH, ROW_HEIGHT).fill({
color: 0xffffff,
alpha: row % 2 === 0 ? 0.02 : 0.04,
@@ -773,7 +893,7 @@ export class TimelineCanvas extends Widget {
if (count > 0) {
const x0 = HEADER_WIDTH + frameToX(this.view, 0);
const x1 = HEADER_WIDTH + frameToX(this.view, count);
this.cells.rect(x0, 0, x1 - x0, RULER_HEIGHT - 1).fill({ color: c.accent, alpha: 0.15 });
this.ruler.rect(x0, 0, x1 - x0, RULER_HEIGHT - 1).fill({ color: c.accent, alpha: 0.15 });
}
let used = 0;
const first = frameAt(this.view, 0);
@@ -781,7 +901,7 @@ export class TimelineCanvas extends Widget {
const x = HEADER_WIDTH + frameToX(this.view, frame);
if (x > this.w) break;
if (x < HEADER_WIDTH) continue;
this.cells.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted);
this.ruler.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted);
let text = this.rulerTexts[used];
if (!text) {
text = crispText({ style: textStyle('small') });
@@ -802,8 +922,8 @@ export class TimelineCanvas extends Widget {
const drag = this.dragging?.moved ? this.dragging : null;
const half = KEY_SIZE / 2;
lanes.forEach((track, row) => {
if (!track) return;
const y = rowCentreY(row);
if (!track || !this.inView(rowTop(row, this.view.scrollY))) return;
const y = rowCentreY(row, this.view.scrollY);
for (const key of track.keys) {
const ref = { trackId: track.id, frame: key.frame };
const selected = hasKey(picked, ref);
@@ -818,7 +938,7 @@ export class TimelineCanvas extends Widget {
const x = HEADER_WIDTH + frameCentreX(this.view, frame);
if (x < HEADER_WIDTH - half || x > this.w + half) return;
const lit = selected && !(dragged && drag.copy && i === 0);
drawKeyShape(this.overlay, interpolationOf(key), x, y, half)
drawKeyShape(this.keyMarks, interpolationOf(key), x, y, half)
.fill(lit ? c.accent : c.text)
.stroke({ width: 1, color: lit ? c.accentText : c.viewport });
});
@@ -871,8 +991,9 @@ export class TimelineCanvas extends Widget {
(line.kind === 'row' && line.def ? HEADER_WIDTH - FIELD_WIDTH - FIELD_INSET * 2 : HEADER_WIDTH) - x;
const text = truncate(line.kind === 'group' ? line.name : line.label, label.style, room - FIELD_INSET);
if (label.text !== text) label.text = text;
label.position.set(x, rowTop(row) + (ROW_HEIGHT - label.height) / 2);
label.visible = rowTop(row) + ROW_HEIGHT <= this.h;
const top = rowTop(row, this.view.scrollY);
label.position.set(x, top + (ROW_HEIGHT - label.height) / 2);
label.visible = this.inView(top);
});
for (const [id, label] of this.labels) {
if (alive.has(id)) continue;
@@ -916,9 +1037,10 @@ export class TimelineCanvas extends Widget {
const widget = entry.field.widget;
const w = entry.field.grow ? FIELD_WIDTH : Math.min(FIELD_WIDTH, widget.measure().w);
const h = Theme.controlHeight;
widget.position.set(HEADER_WIDTH - FIELD_INSET - w, rowTop(row) + (ROW_HEIGHT - h) / 2);
const top = rowTop(row, this.view.scrollY);
widget.position.set(HEADER_WIDTH - FIELD_INSET - w, top + (ROW_HEIGHT - h) / 2);
if (widget.w !== w || widget.h !== h) widget.resize(w, h);
widget.visible = rowTop(row) + ROW_HEIGHT <= this.h;
widget.visible = this.inView(top);
});
for (const [id, entry] of this.fields) {
if (alive.has(id)) continue;
+61 -12
View File
@@ -6,13 +6,15 @@ import { clamp } from '../../ui';
/**
* The timeline's geometry, apart from Pixi so a test can drive it: a column of row headers on the
* left, a frame ruler on top, and a grid of cells below it – one cell per row and frame. The view
* pans and zooms horizontally only; rows have a fixed height and are never scrolled away.
* pans and zooms across; rows have a fixed height and scroll up under the ruler (#413).
*/
export interface TimelineView {
/** Screen pixels the cell of frame 0 starts at, measured from the left edge of the cell area. */
panX: number;
/** Width of one frame's cell in screen pixels. */
cellWidth: number;
/** Pixels the rows are scrolled up by; the ruler stays where it is. */
scrollY: number;
}
/** A line of the timeline as the geometry sees it: a row, or `null` for an object's head (#408). */
@@ -32,7 +34,7 @@ export const KEY_SIZE = 10;
/** Every `n`-th frame gets a number on the ruler; chosen so the numbers never overlap. */
const RULER_LABEL_MIN_PX = 40;
export const DEFAULT_VIEW: TimelineView = { panX: 0, cellWidth: DEFAULT_CELL_WIDTH };
export const DEFAULT_VIEW: TimelineView = { panX: 0, cellWidth: DEFAULT_CELL_WIDTH, scrollY: 0 };
/** The x of the left edge of `frame`'s cell, in cell-area pixels. */
export function frameToX(view: TimelineView, frame: number): number {
@@ -54,26 +56,73 @@ export function frameAt(view: TimelineView, x: number): number {
return Math.max(0, Math.floor(xToFrame(view, x)));
}
/** The row under `y` (measured from the top of the ruler), or -1 above the rows and past the last one. */
export function rowAt(y: number, rowCount: number): number {
/**
* The row under `y` (measured from the top of the ruler), or -1 over the ruler – a row scrolled up
* under it included – and past the last one.
*/
export function rowAt(y: number, rowCount: number, scrollY = 0): number {
if (y < RULER_HEIGHT) return -1;
const row = Math.floor((y - RULER_HEIGHT) / ROW_HEIGHT);
const row = Math.floor((y - RULER_HEIGHT + scrollY) / ROW_HEIGHT);
return row < rowCount ? row : -1;
}
export function rowTop(row: number): number {
return RULER_HEIGHT + row * ROW_HEIGHT;
export function rowTop(row: number, scrollY = 0): number {
return RULER_HEIGHT + row * ROW_HEIGHT - scrollY;
}
export function rowCentreY(row: number): number {
return rowTop(row) + ROW_HEIGHT / 2;
export function rowCentreY(row: number, scrollY = 0): number {
return rowTop(row, scrollY) + ROW_HEIGHT / 2;
}
/** How far the rows scroll in a timeline `height` pixels tall: the last row may come up to the bottom edge. */
export function maxScrollY(rowCount: number, height: number): number {
return Math.max(0, rowCount * ROW_HEIGHT - Math.max(0, height - RULER_HEIGHT));
}
export function clampScroll(view: TimelineView, rowCount: number, height: number): TimelineView {
const scrollY = clamp(view.scrollY, 0, maxScrollY(rowCount, height));
return scrollY === view.scrollY ? view : { ...view, scrollY };
}
/** Scrolls just far enough that the whole of `row` is below the ruler and above the bottom edge. */
export function revealRow(view: TimelineView, row: number, rowCount: number, height: number): TimelineView {
const top = row * ROW_HEIGHT;
const room = Math.max(ROW_HEIGHT, height - RULER_HEIGHT);
const scrollY =
top < view.scrollY
? top
: top + ROW_HEIGHT > view.scrollY + room
? top + ROW_HEIGHT - room
: view.scrollY;
return clampScroll({ ...view, scrollY }, rowCount, height);
}
/** The scrollbar's thumb beside the rows, or null while every row fits. */
export function scrollThumb(
view: TimelineView,
rowCount: number,
height: number,
): { top: number; height: number } | null {
const max = maxScrollY(rowCount, height);
if (max <= 0) return null;
const track = height - RULER_HEIGHT;
const thumb = Math.min(track, Math.max(20, (track * track) / (rowCount * ROW_HEIGHT)));
return { top: RULER_HEIGHT + (clamp(view.scrollY, 0, max) / max) * (track - thumb), height: thumb };
}
/** The scroll a thumb dragged by `dy` pixels from where it was at `from` asks for. */
export function scrollByThumb(from: number, dy: number, rowCount: number, height: number): number {
const max = maxScrollY(rowCount, height);
const thumb = scrollThumb({ ...DEFAULT_VIEW, scrollY: from }, rowCount, height);
const travel = thumb ? height - RULER_HEIGHT - thumb.height : 0;
return travel > 0 ? clamp(from + (dy / travel) * max, 0, max) : 0;
}
/** Zooms the cells about `x` (cell-area pixels), so the frame under the pointer stays under it. */
export function zoomCells(view: TimelineView, factor: number, x: number): TimelineView {
const cellWidth = clamp(view.cellWidth * factor, MIN_CELL_WIDTH, MAX_CELL_WIDTH);
const frame = xToFrame(view, x);
return { panX: x - frame * cellWidth, cellWidth };
return { ...view, panX: x - frame * cellWidth, cellWidth };
}
/**
@@ -97,7 +146,7 @@ export function rulerStep(view: TimelineView): number {
* whole cell: at a wide zoom a click into the empty part of a keyed cell is a click on nothing.
*/
export function keyAt(view: TimelineView, tracks: readonly Lane[], point: Vec2): KeyRef | null {
const row = rowAt(point.y, tracks.length);
const row = rowAt(point.y, tracks.length, view.scrollY);
const track = row < 0 ? null : tracks[row];
if (!track) return null;
const reach = Math.max(KEY_SIZE / 2, view.cellWidth / 2);
@@ -116,7 +165,7 @@ export function keysWithin(view: TimelineView, tracks: readonly Lane[], a: Vec2,
const bottom = Math.max(a.y, b.y);
const out: KeyRef[] = [];
tracks.forEach((track, row) => {
const y = rowCentreY(row);
const y = rowCentreY(row, view.scrollY);
if (!track || y < top || y > bottom) return;
for (const key of track.keys) {
const x = frameCentreX(view, key.frame);
+2
View File
@@ -47,6 +47,8 @@ export function documentHooks({ state, editor }: HookContext) {
lines: () => editor.animationEditor.canvas.linesDrawn(),
valueAt: (trackId: string) => editor.animationEditor.canvas.valueGlobal(trackId),
view: () => editor.animationEditor.canvas.viewInfo(),
/** The scrollbar's thumb beside the rows (#413), or null while they all fit. */
thumb: () => editor.animationEditor.canvas.thumbGlobal(),
scrub: (frame: number) => editor.animationEditor.canvas.scrub(frame),
/** The open row dialog: what it offers and holds, and a way to pick without clicking through menus. */
rowDialog: () => currentRowDialog()?.debugInfo() ?? null,
+74 -11
View File
@@ -3,6 +3,7 @@ import type { AnimationTrack } from '../../src/core/model/Asset';
import {
clampMove,
clampPan,
clampScroll,
DEFAULT_VIEW,
frameAt,
frameCentreX,
@@ -10,13 +11,17 @@ import {
keyAt,
keysWithin,
MAX_CELL_WIDTH,
maxScrollY,
MIN_CELL_WIDTH,
revealRow,
ROW_HEIGHT,
rowAt,
rowCentreY,
rowTop,
RULER_HEIGHT,
rulerStep,
scrollByThumb,
scrollThumb,
type TimelineView,
xToFrame,
zoomCells,
@@ -37,7 +42,7 @@ const TRACKS: AnimationTrack[] = [
describe('timeline geometry', () => {
it('maps frames to cells and back', () => {
const view: TimelineView = { panX: 10, cellWidth: 20 };
const view: TimelineView = { panX: 10, cellWidth: 20, scrollY: 0 };
expect(frameToX(view, 0)).toBe(10);
expect(frameToX(view, 3)).toBe(70);
expect(frameCentreX(view, 3)).toBe(80);
@@ -47,7 +52,7 @@ describe('timeline geometry', () => {
});
it('never answers a frame below 0', () => {
expect(frameAt({ panX: 0, cellWidth: 16 }, -40)).toBe(0);
expect(frameAt({ panX: 0, cellWidth: 16, scrollY: 0 }, -40)).toBe(0);
});
it('finds rows below the ruler and nothing beyond the last', () => {
@@ -58,7 +63,7 @@ describe('timeline geometry', () => {
});
it('zooms about the pointer, so the frame under it stays put', () => {
const view: TimelineView = { panX: -100, cellWidth: 16 };
const view: TimelineView = { panX: -100, cellWidth: 16, scrollY: 0 };
const x = 200;
const before = xToFrame(view, x);
const zoomed = zoomCells(view, 1.5, x);
@@ -77,21 +82,21 @@ describe('timeline geometry', () => {
});
it('does not let frame 0 drift right of the left edge', () => {
expect(clampPan({ panX: 30, cellWidth: 16 }).panX).toBe(0);
expect(clampPan({ panX: -30, cellWidth: 16 }).panX).toBe(-30);
expect(clampPan({ panX: 30, cellWidth: 16, scrollY: 0 }).panX).toBe(0);
expect(clampPan({ panX: -30, cellWidth: 16, scrollY: 0 }).panX).toBe(-30);
});
it('labels the ruler with a step that keeps the numbers apart', () => {
expect(rulerStep({ panX: 0, cellWidth: 48 })).toBe(1);
expect(rulerStep({ panX: 0, cellWidth: 16 })).toBe(5);
expect(rulerStep({ panX: 0, cellWidth: 4 })).toBe(10);
expect(rulerStep({ panX: 0, cellWidth: 0.1 })).toBe(500);
expect(rulerStep({ panX: 0, cellWidth: MIN_CELL_WIDTH })).toBe(2000);
expect(rulerStep({ panX: 0, cellWidth: 48, scrollY: 0 })).toBe(1);
expect(rulerStep({ panX: 0, cellWidth: 16, scrollY: 0 })).toBe(5);
expect(rulerStep({ panX: 0, cellWidth: 4, scrollY: 0 })).toBe(10);
expect(rulerStep({ panX: 0, cellWidth: 0.1, scrollY: 0 })).toBe(500);
expect(rulerStep({ panX: 0, cellWidth: MIN_CELL_WIDTH, scrollY: 0 })).toBe(2000);
});
});
describe('picking keys', () => {
const view: TimelineView = { panX: 0, cellWidth: 16 };
const view: TimelineView = { panX: 0, cellWidth: 16, scrollY: 0 };
it('hits the key drawn at the centre of its cell', () => {
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual({
@@ -138,6 +143,19 @@ describe('picking keys', () => {
expect(all.map((k) => k.trackId)).toEqual(['a', 'a', 'b']);
});
it('finds the key of a row scrolled up, and nothing under the ruler (#413)', () => {
const scrolled: TimelineView = { ...view, scrollY: ROW_HEIGHT };
expect(keyAt(scrolled, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })?.trackId).toBe('b');
expect(rowAt(RULER_HEIGHT - 1, 2, ROW_HEIGHT)).toBe(-1);
const band = keysWithin(
scrolled,
TRACKS,
{ x: 0, y: RULER_HEIGHT },
{ x: frameToX(view, 10), y: rowTop(1) },
);
expect(band).toEqual([{ trackId: 'b', frame: 2 }]);
});
it('a move never takes a key before frame 0', () => {
const keys = [
{ trackId: 'a', frame: 2 },
@@ -148,3 +166,48 @@ describe('picking keys', () => {
expect(clampMove([], -3)).toBe(0);
});
});
describe('scrolling the rows (#413)', () => {
// Room for 4 rows below the ruler, and 10 of them: 6 rows' worth to scroll.
const height = RULER_HEIGHT + 4 * ROW_HEIGHT;
const at = (scrollY: number): TimelineView => ({ ...DEFAULT_VIEW, scrollY });
it('scrolls no further than the last row coming up to the bottom edge, and not above the first', () => {
expect(maxScrollY(10, height)).toBe(6 * ROW_HEIGHT);
expect(clampScroll(at(10_000), 10, height).scrollY).toBe(6 * ROW_HEIGHT);
expect(clampScroll(at(-40), 10, height).scrollY).toBe(0);
expect(maxScrollY(3, height)).toBe(0);
expect(clampScroll(at(50), 3, height).scrollY).toBe(0);
});
it('brings a picked row into view, just far enough, and leaves one in view alone', () => {
expect(revealRow(at(0), 9, 10, height).scrollY).toBe(6 * ROW_HEIGHT);
expect(revealRow(at(0), 5, 10, height).scrollY).toBe(2 * ROW_HEIGHT);
expect(revealRow(at(6 * ROW_HEIGHT), 1, 10, height).scrollY).toBe(ROW_HEIGHT);
expect(revealRow(at(2 * ROW_HEIGHT), 3, 10, height).scrollY).toBe(2 * ROW_HEIGHT);
// Half hidden under the ruler is out of view.
expect(revealRow(at(ROW_HEIGHT / 2), 0, 10, height).scrollY).toBe(0);
});
it('shows a thumb only while the rows do not fit, from top to bottom of its track', () => {
expect(scrollThumb(at(0), 4, height)).toBeNull();
const top = scrollThumb(at(0), 10, height)!;
const bottom = scrollThumb(at(6 * ROW_HEIGHT), 10, height)!;
expect(top.top).toBe(RULER_HEIGHT);
expect(top.height).toBeCloseTo((4 * ROW_HEIGHT * 4) / 10);
expect(bottom.top + bottom.height).toBeCloseTo(height);
});
it('a thumb dragged its whole travel scrolls the whole way, and no further', () => {
const thumb = scrollThumb(at(0), 10, height)!;
const travel = height - RULER_HEIGHT - thumb.height;
expect(scrollByThumb(0, travel, 10, height)).toBeCloseTo(6 * ROW_HEIGHT);
expect(scrollByThumb(0, travel / 2, 10, height)).toBeCloseTo(3 * ROW_HEIGHT);
expect(scrollByThumb(0, 10 * travel, 10, height)).toBe(6 * ROW_HEIGHT);
expect(scrollByThumb(ROW_HEIGHT, -10 * travel, 10, height)).toBe(0);
});
it('zooming keeps the rows where they were scrolled to', () => {
expect(zoomCells(at(40), 2, 0).scrollY).toBe(40);
});
});
+50 -1
View File
@@ -12,7 +12,7 @@ const SIZE = 40;
* removes everything it made.
*/
export async function animation(t) {
const { check, json, evaluate, mutate, shot, click, doubleClick, drag, press, typeText } = t;
const { check, json, evaluate, mutate, shot, click, doubleClick, drag, press, typeText, wheel } = t;
const { undo, settle, sleep, waitUntil, find } = t;
await press('Escape');
@@ -496,6 +496,55 @@ export async function animation(t) {
JSON.stringify({ shownX, keyedX }),
);
// 25j. More rows than room (#413): 16 objects with a row each. The wheel over the header column
// scrolls the rows, the last one comes into view and its field keys a typed value; the
// scrollbar's thumb dragged up takes them back to the top.
await mutate(`
const p = __gm.state.project;
const box = p.assets.find((a) => a.id === 'an-box');
const arm = box.objects[0];
for (let i = 2; i <= 16; i++)
box.objects.push({ ...structuredClone(arm), id: 'an-arm' + i, name: 'Arm ' + i });
p.assets.find((a) => a.id === 'an-walk').tracks = box.objects.map((o, i) => (
{ id: i === 15 ? 'an-last' : 'an-s' + i, target: o.id, property: 'x', keys: [] }
));
`);
await evaluate('__gm.animation.scrub(2)');
await sleep(150);
const before = { view: await json('__gm.animation.view()'), thumb: await json('__gm.animation.thumb()') };
const lastHidden = await json("__gm.animation.valueAt('an-last')");
const header = await json("__gm.animation.headerAt('an-s0')");
await wheel(header.x, header.y, 5000);
const scrolled = await json('__gm.animation.view()');
const lastField = await json("__gm.animation.valueAt('an-last')");
if (lastField) {
await click(lastField.x, lastField.y);
await typeText('77');
await press('Enter');
await sleep(150);
}
const lastKeys = (await walk()).tracks.find((r) => r.id === 'an-last').keys;
check(
'the wheel over the headers scrolls to the last row, and its field keys a typed value',
before.view.scrollY === 0 &&
before.thumb !== null &&
lastHidden === null &&
scrolled.scrollY === before.thumb.max &&
scrolled.cellWidth === before.view.cellWidth &&
lastField !== null &&
JSON.stringify(lastKeys) === JSON.stringify([{ frame: 2, value: 77 }]),
JSON.stringify({ before, lastHidden, scrolled, lastField, lastKeys }),
);
await shot('21-scrolled-rows');
const thumb = await json('__gm.animation.thumb()');
await drag(thumb.x, thumb.y, thumb.x, 5);
const toTop = await json('__gm.animation.view()');
check(
'the scrollbar thumb dragged up takes the rows back to the top',
toTop.scrollY === 0 && (await json("__gm.animation.valueAt('an-last')")) === null,
JSON.stringify({ thumb, toTop }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id));