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

+56
View File
@@ -422,6 +422,62 @@ export async function animation(t) {
JSON.stringify({ emptied: emptied.tracks, undone: undone.tracks }),
);
// 25i. The header (#408): rows grouped under their object in the order of the hierarchy, each
// object's rows in the order of its properties, the value at the playhead in the row, and
// a value typed there keys it at the playhead.
await mutate(`
__gm.state.project.assets.find((a) => a.id === 'an-walk').tracks = [
{ id: 'an-r1', target: 'an-arm', property: 'color', keys: [] },
{ id: 'an-r2', target: '', property: 'enabled', keys: [] },
{ id: 'an-r3', target: 'an-arm', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 10, value: 100 }] },
];
`);
await evaluate('__gm.animation.scrub(4)');
await sleep(150);
const lines = await json('__gm.animation.lines()');
const shape = lines.map((l) => (l.kind === 'group' ? l.text : ` ${l.text}=${JSON.stringify(l.value)}`));
check(
'the header groups the rows under Dancer and Arm, X before Color, each with its value at frame 4',
JSON.stringify(shape) ===
JSON.stringify(['Dancer', ' Enabled=true', 'Arm', ' X=40', ` Color=${0xff0000}`]) &&
lines[1].indent > lines[0].indent,
JSON.stringify(lines),
);
await shot('20-grouped-header');
const xField = await json("__gm.animation.valueAt('an-r3')");
await click(xField.x, xField.y);
await typeText('55');
await press('Enter');
await sleep(150);
const typed = (await walk()).tracks.find((r) => r.id === 'an-r3').keys;
const typedPick = await json('__gm.state.selectedKeys');
check(
'a value typed into the row keys it at the playhead and picks that key',
JSON.stringify(typed) ===
JSON.stringify([
{ frame: 0, value: 0 },
{ frame: 4, value: 55 },
{ frame: 10, value: 100 },
]) && JSON.stringify(typedPick) === JSON.stringify([{ trackId: 'an-r3', frame: 4 }]),
JSON.stringify({ typed, typedPick }),
);
// Pressing the ruler while playing pauses – and the button says Play again.
const playButton = await json('__gm.animation.playAt()');
await click(playButton.x, playButton.y);
const whilePlaying = (await json('__gm.animation.playAt()')).text;
const ruler = await json('__gm.animation.rulerAt(2)');
await click(ruler.x, ruler.y);
const scrubbed = {
text: (await json('__gm.animation.playAt()')).text,
...(await json('__gm.animation.view()')),
};
check(
'scrubbing on the ruler while playing pauses, and the button turns back to Play',
whilePlaying === 'Pause' && scrubbed.text === 'Play' && scrubbed.playing === false,
JSON.stringify({ whilePlaying, scrubbed }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id));