Fold object groups in the timeline, with summary keys (#414)
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 20m7s

Long animations with many objects fill the timeline with rows. A
group per object can now fold away, and its header row still shows
where the group has keys, so they stay reachable while folded.

- A click on an object header or its triangle folds the group. The
  groups of the objects below it fold along.
- Which groups fold along follows the real parent in the hierarchy,
  not the indent. The root shows at the level of its children, so
  folding it hides only its own rows.
- Every header shows a summary diamond on each frame where a row of
  the group or of a group below has a key. Click selects all those
  keys, drag moves them, Ctrl+drag duplicates, a band catches them
  and Delete removes them, each through the existing commands.
- The fold state is a view of the session per animation. It is no
  undo step and is not saved.
- Selecting a row without keys inside a folded group unfolds it, so
  a fresh row from + Row does not stay hidden.
- The fold rules live in the new timelineFold.ts. keyAt became
  keysAt, which returns every key behind a summary diamond.

Found on the way: the triangle moved the object name to x 22 while
its rows stayed at x 20, left of the name (#408 check in e2e 25).
Rows now sit one step behind the name, as in the hierarchy.

Tests:
- Unit animation + timeline-view 114/114, 6 new
- 6 counter-checks, all red in the matching test
- e2e 25 (atx) ALL PASSED, 41 checks, 4 new (25k)
- Gate (atx) CHECK EXIT 0, 324 files, 5008 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 19:01:30 +02:00
1 parent 19902a9f02
commit 37779a2070
8 files changed
+518 -98

No files matched your search

+103
View File
@@ -57,6 +57,8 @@ import {
trackValueAt,
} from '../../src/core/model/animation';
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
import { foldLines, type ShownLine, unfoldedFor } from '../../src/editor/animation/timelineFold';
import { summaryFrames, summaryKeys } from '../../src/editor/animation/timelineView';
import { previewHolder } from '../../src/core/model/animationPreview';
import { graphLookup } from '../../src/core/model/graphLookup';
import type {
@@ -515,6 +517,107 @@ describe("the timeline's header (#408)", () => {
});
});
describe("folding the timeline's groups (#414)", () => {
/** `A` at the top with `B` under it; `D` at the top, not animated, with `C` under it. */
function scene() {
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 0, y: 0 }, a);
const d = place(f, f.spriteId, { x: 0, y: 0 });
const c = place(f, f.spriteId, { x: 0, y: 0 }, d);
for (const [id, name] of [
[a, 'A'],
[b, 'B'],
[c, 'C'],
[d, 'D'],
] as const)
f.sceneOf().objects.find((o) => o.id === id)!.name = name;
const walk = addAnimation(f, f.sceneId);
const ax = addRow(
f,
walk,
'x',
[
[0, 0],
[6, 60],
],
a,
);
const ay = addRow(
f,
walk,
'y',
[
[2, 0],
[6, 10],
],
a,
);
const br = addRow(f, walk, 'rotation', [[4, 90]], b);
const cx = addRow(f, walk, 'x', [[6, 1]], c);
return { f, walk, a, b, c, d, ax, ay, br, cx };
}
const names = (lines: ShownLine[]) =>
lines.map((l) => (l.kind === 'group' ? `${l.name}${l.folded ? '+' : ''}` : ` ${l.track.property}`));
const head = (lines: ShownLine[], name: string) => {
const line = lines.find((l) => l.kind === 'group' && l.name === name);
if (line?.kind !== 'group') throw new Error(`no head ${name}`);
return line;
};
it('knows the objects a head sits under, by the hierarchy rather than the depth it is drawn at', () => {
const { f, walk, a, d } = scene();
const lines = timelineLines(f.project, current(f, walk));
const above = (name: string) => lines.find((l) => l.kind === 'group' && l.name === name);
expect(above('B')).toMatchObject({ above: [a], depth: 1 });
expect(above('C')).toMatchObject({ above: [d], depth: 1 });
expect(above('A')).toMatchObject({ above: [] });
});
it("folding a head hides its rows and its children's groups, not a deeper group of another object", () => {
const { f, walk, a } = scene();
const lines = timelineLines(f.project, current(f, walk));
expect(names(foldLines(lines, new Set()))).toEqual(['A', ' x', ' y', 'B', ' rotation', 'C', ' x']);
expect(names(foldLines(lines, new Set([a])))).toEqual(['A+', 'C', ' x']);
});
it("a head's diamonds are the frames any row of its group or below it has a key on", () => {
const { f, walk, a, ax, ay, br } = scene();
const shown = foldLines(timelineLines(f.project, current(f, walk)), new Set([a]));
const lane = head(shown, 'A');
expect(summaryFrames(lane)).toEqual([0, 2, 4, 6]);
expect(summaryKeys(lane, 6)).toEqual([
{ trackId: ax, frame: 6 },
{ trackId: ay, frame: 6 },
]);
expect(summaryKeys(lane, 4)).toEqual([{ trackId: br, frame: 4 }]);
// Open, the head sums up the same keys.
expect(
summaryFrames(head(foldLines(timelineLines(f.project, current(f, walk)), new Set()), 'A')),
).toEqual([0, 2, 4, 6]);
});
it("moving a head's diamond moves every key of its group on that frame, and one undo takes it back", () => {
const { f, walk, a, ax, ay, cx } = scene();
const lane = head(foldLines(timelineLines(f.project, current(f, walk)), new Set([a])), 'A');
const before = JSON.stringify(current(f, walk).tracks);
f.stack.execute(new MoveAnimationKeysCommand(walk.id, summaryKeys(lane, 6), 2));
expect(rowOf(current(f, walk), ax).keys).toEqual(keys([0, 0], [8, 60]));
expect(rowOf(current(f, walk), ay).keys).toEqual(keys([2, 0], [8, 10]));
// C's key on frame 6 belongs to another group and stays.
expect(rowOf(current(f, walk), cx).keys).toEqual(keys([6, 1]));
f.stack.undo();
expect(JSON.stringify(current(f, walk).tracks)).toBe(before);
});
it('a row to be seen opens its own head and those above it, and leaves the others folded', () => {
const { f, walk, a, b, c } = scene();
const lines = timelineLines(f.project, current(f, walk));
expect([...unfoldedFor(lines, new Set([a, b, c]), b)]).toEqual([c]);
});
});
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();
+27 -18
View File
@@ -8,7 +8,7 @@ import {
frameAt,
frameCentreX,
frameToX,
keyAt,
keysAt,
keysWithin,
MAX_CELL_WIDTH,
maxScrollY,
@@ -99,19 +99,17 @@ describe('picking keys', () => {
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({
trackId: 'a',
frame: 4,
});
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(1) })).toEqual({
trackId: 'b',
frame: 2,
});
expect(keysAt(view, TRACKS, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual([
{ trackId: 'a', frame: 4 },
]);
expect(keysAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(1) })).toEqual([
{ trackId: 'b', frame: 2 },
]);
});
it('misses an empty cell and the ruler', () => {
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toBeNull();
expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: RULER_HEIGHT / 2 })).toBeNull();
expect(keysAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toEqual([]);
expect(keysAt(view, TRACKS, { x: frameCentreX(view, 4), y: RULER_HEIGHT / 2 })).toEqual([]);
});
it('a band catches every key whose diamond centre it spans, across rows', () => {
@@ -133,19 +131,18 @@ describe('picking keys', () => {
it("skips an object's head between the rows (#408)", () => {
const lanes = [null, TRACKS[0]!, null, TRACKS[1]!];
expect(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toBeNull();
expect(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(1) })).toEqual({
trackId: 'a',
frame: 4,
});
expect(keyAt(view, lanes, { x: frameCentreX(view, 2), y: rowCentreY(3) })?.trackId).toBe('b');
expect(keysAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual([]);
expect(keysAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(1) })).toEqual([
{ trackId: 'a', frame: 4 },
]);
expect(keysAt(view, lanes, { x: frameCentreX(view, 2), y: rowCentreY(3) })[0]?.trackId).toBe('b');
const all = keysWithin(view, lanes, { x: 0, y: RULER_HEIGHT }, { x: frameToX(view, 10), y: rowTop(4) });
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(keysAt(scrolled, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })[0]?.trackId).toBe('b');
expect(rowAt(RULER_HEIGHT - 1, 2, ROW_HEIGHT)).toBe(-1);
const band = keysWithin(
scrolled,
@@ -156,6 +153,18 @@ describe('picking keys', () => {
expect(band).toEqual([{ trackId: 'b', frame: 2 }]);
});
it("a head's diamond stands for every key of its group on that frame, and a band counts each key once (#414)", () => {
const both = [{ ...TRACKS[0]!, keys: [...TRACKS[0]!.keys, { frame: 2, value: 20 }] }, TRACKS[1]!];
const lanes = [{ target: '', tracks: both }, ...both];
expect(keysAt(view, lanes, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toEqual([
{ trackId: 'a', frame: 2 },
{ trackId: 'b', frame: 2 },
]);
expect(keysAt(view, lanes, { x: frameCentreX(view, 3), y: rowCentreY(0) })).toEqual([]);
const band = keysWithin(view, lanes, { x: 0, y: RULER_HEIGHT }, { x: frameToX(view, 10), y: rowTop(3) });
expect(band).toHaveLength(4);
});
it('a move never takes a key before frame 0', () => {
const keys = [
{ trackId: 'a', frame: 2 },