From 480db9c4e4831a9fea4a46e02b98c86e1dc9eb6a Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 13 Oct 2026 07:22:11 +0200 Subject: [PATCH] Scroll the rows of the animation timeline up and down (#413) 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 --- README.md | 2 +- src/editor/animation/TimelineCanvas.ts | 184 ++++++++++++++++++++----- src/editor/animation/timelineView.ts | 73 ++++++++-- src/editor/debug/documentHooks.ts | 2 + tests/editor/timeline-view.test.ts | 85 ++++++++++-- tools/e2e/25-animation.mjs | 51 ++++++- 6 files changed, 341 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index bf929a19..4c1bd433 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 195a928c..cf44956e 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -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) }; 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; diff --git a/src/editor/animation/timelineView.ts b/src/editor/animation/timelineView.ts index 70c06b21..f94922d7 100644 --- a/src/editor/animation/timelineView.ts +++ b/src/editor/animation/timelineView.ts @@ -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); diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index 00f7d7f2..d76e6b04 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -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, diff --git a/tests/editor/timeline-view.test.ts b/tests/editor/timeline-view.test.ts index 1bcfb11b..448a47ce 100644 --- a/tests/editor/timeline-view.test.ts +++ b/tests/editor/timeline-view.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index bd862b88..018d1b39 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -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));