Group timeline rows by object and edit values in the row (#408)

The rows of the animation timeline stood in the order they were keyed.
With several objects and properties the list was hard to read, and a
value could only be changed by picking a key and using the inspector.
The Play button also kept saying Pause after scrubbing had paused.

- Each object gets a header row with its name, indented by depth. The
  groups follow the scene hierarchy, the root first. Rows whose object
  is missing go to the end.
- Under an object the rows follow the order in which it lists its
  properties: X, Y, Z, then the order of the inspector. Only the view
  is sorted, the animation itself is unchanged and there is no undo
  step. After a move in the hierarchy the order is right by itself.
- The row head shows the same field as the inspector with the value at
  the playhead. Without keys it shows the value of the object, for a
  relative row the offset.
- A change in that field sets or changes the key at the playhead,
  selects it and is one undo step. A running playback pauses first.
- The Play/Pause button follows every change of the playing state, so
  scrubbing that pauses shows Play again.

Tests:
- Unit animation + timeline-view: 100 passed, 3 new.
- e2e 25 on melektaus-atx: ALL PASSED, 34 checks, 3 new (groups,
  order and values at frame 4, a typed 55 becomes a selected key on
  frame 4, Play then a click on the ruler shows Play and pauses).
- Gate on melektaus-atx: exit 0, 297 files, 4561 tests.
- tsc, eslint, Prettier clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 17:42:10 +02:00
1 parent dd60a795e1
commit 8679dfbe65
9 files changed
+449 -69

No files matched your search

+76
View File
@@ -36,6 +36,7 @@ import {
frameCount,
isPositionRow,
leadingPlayer,
lineValueAt,
NOT_PLAYING,
offeredDefs,
pauseAnimation,
@@ -49,6 +50,8 @@ import {
stopAnimation,
targetDefs,
targetNamePath,
type TimelineLine,
timelineLines,
trackDef,
trackLabel,
trackValueAt,
@@ -422,6 +425,79 @@ describe('editing the timeline', () => {
});
});
describe("the timeline's header (#408)", () => {
/** A scene with `A` and `C` at the top and `B` under `A`, placed in the order A, C, B. */
function scene() {
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 5, y: 0 });
const c = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 0, y: 0 }, a);
const named = (id: string, name: string) => (f.sceneOf().objects.find((o) => o.id === id)!.name = name);
named(a, 'A');
named(b, 'B');
named(c, 'C');
return { f, a, b, c };
}
const shape = (lines: TimelineLine[]) =>
lines.map((l) => (l.kind === 'group' ? `${l.name}@${l.depth}` : ` ${l.track.property}`));
it('groups the rows by object in the order of the hierarchy, each in the order of its properties', () => {
const { f, a, b, c } = scene();
const walk = addAnimation(f, f.sceneId);
addRow(f, walk, 'rotation', [], c);
addRow(f, walk, 'scale', [], a);
addRow(f, walk, 'rotation', [], b);
addRow(f, walk, 'backgroundColor', []);
addRow(f, walk, 'x', [], a);
addRow(f, walk, 'enabled', [], a);
current(f, walk).tracks.push({ id: 'lost', target: 'gone', property: 'x', keys: [] });
expect(shape(timelineLines(f.project, current(f, walk)))).toEqual([
`${f.sceneOf().name}@0`,
' backgroundColor',
'A@0',
' x',
' enabled',
' scale',
'B@1',
' rotation',
'C@0',
' rotation',
'(missing)@0',
' x',
]);
// Only the view is sorted.
expect(current(f, walk).tracks.map((t) => t.property)[0]).toBe('rotation');
});
it('shows the value at the playhead, what the object holds before the first key, and an offset when relative', () => {
const { f, a } = scene();
const walk = addAnimation(f, f.sceneId);
addRow(f, walk, 'x', [], a);
addRow(
f,
walk,
'scale',
[
[0, 1],
[10, 3],
],
a,
);
const y = addRow(f, walk, 'y', [], a);
f.stack.execute(new SetTrackRelativeCommand(walk.id, y, true));
const row = (property: string) =>
timelineLines(f.project, current(f, walk)).find(
(l): l is TimelineLine & { kind: 'row' } => l.kind === 'row' && l.track.property === property,
)!;
expect(lineValueAt(row('x'), 4)).toBe(5);
expect(lineValueAt(row('y'), 4)).toBe(0);
expect(lineValueAt(row('scale'), 5)).toBeCloseTo(2);
current(f, walk).tracks.push({ id: 'lost', target: 'gone', property: 'x', keys: [] });
expect(lineValueAt(row('x'), 4)).toBe(5);
});
});
describe('copy, paste, duplicate and frames (#102)', () => {
it('pastes relative to the frame, into the same rows, replacing a key there, and undoes in one step', () => {
const f = makeSpriteScene();