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

+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);
});
});