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

+1 -1
View File
@@ -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, 2004 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2007 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
+71
View File
@@ -577,6 +577,77 @@ export function trackLabel(project: Project, animation: AnimationAsset, track: A
return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`;
}
/**
* A line of the timeline's header column (#408): the head of an object's group, or a row under it,
* named by its property alone. `depth` is the object's in the hierarchy. A row carries its property
* (none once that is gone) and `held`, what it shows before it has keys: it writes nothing then, so
* that is what the object holds – or no offset at all for a relative row.
*/
export type TimelineLine =
| { kind: 'group'; target: string; name: string; depth: number }
| {
kind: 'row';
track: AnimationTrack;
label: string;
def: PropertyDef | undefined;
held: PropertyValue;
depth: number;
};
/**
* The rows grouped by the object they aim at, in the order the objects stand in the hierarchy – the
* root first – and under each object in the order it lists its properties. Rows aiming at something
* gone come last, as they were added. Only the view is sorted: the asset keeps its own order.
*/
export function timelineLines(project: Project, animation: AnimationAsset): TimelineLine[] {
const placed = new Map<string, { at: number; depth: number }>([['', { at: 0, depth: 0 }]]);
animationTargets(project, findAsset(project, animation.root)).forEach((t, i) =>
placed.set(t.id, { at: i + 1, depth: t.depth }),
);
const targets = [...new Set(animation.tracks.map((t) => t.target))];
const rank = (target: string) => placed.get(target)?.at ?? placed.size + targets.indexOf(target);
const lines: TimelineLine[] = [];
for (const target of targets.sort((a, b) => rank(a) - rank(b))) {
const aimed = aimedAt(project, animation, target);
const defs = aimed ? animatableDefsOf(project, aimed.asset, aimed.bag) : [];
const depth = placed.get(target)?.depth ?? 0;
const name = !aimed
? '(missing)'
: aimed.object
? objectName(aimed.object, aimed.asset)
: aimed.asset.name;
lines.push({ kind: 'group', target, name, depth });
const order = (track: AnimationTrack) => {
const i = defs.findIndex((d) => d.key === track.property);
return i < 0 ? defs.length : i;
};
const rows = animation.tracks.filter((t) => t.target === target).sort((a, b) => order(a) - order(b));
for (const track of rows) {
const def = defs.find((d) => d.key === track.property);
const label = def ? def.label : `${track.property} (missing)`;
const held = !aimed || !def ? 0 : track.relative ? 0 : heldBy(aimed, def);
lines.push({ kind: 'row', track, label, def, held, depth });
}
}
return lines;
}
function heldBy(aimed: Aimed, def: PropertyDef): PropertyValue {
if (isPositionRow(aimed.asset.type, def.key)) return aimed.object?.position[def.key] ?? 0;
return aimed.bag[def.key] ?? def.default;
}
/** What a row's header shows at `frame` (#408): the row's value there, else what it holds without keys. */
export function lineValueAt(
line: TimelineLine & { kind: 'row' },
frame: number,
reverse = false,
curves: CurveLookup = NO_CURVES,
): PropertyValue | undefined {
if (!line.def) return undefined;
return trackValueAt(line.track.keys, line.def.kind, frame, reverse, curves) ?? line.held;
}
/** Whether a value names something in the project – what deleting that something has to count. */
export function referencesAny(
track: AnimationTrack,
+3 -6
View File
@@ -36,6 +36,8 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
reverse: timeline.playheadReverse,
}));
timeline.onPlayhead = () => this.preview.redraw();
// Scrubbing on the ruler and the end of a `once` pass stop play, too.
timeline.onPlaying = () => this.syncPlayButton();
this.mount(timeline, [this.rootPicker, this.addRowButton, this.playButton]);
}
@@ -67,11 +69,7 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
/** Only called while the tab is visible. */
tick(dt: number): void {
const drawn = this.preview.redrawCount;
if (this.canvas.isPlaying) {
this.canvas.tick(dt);
// A finished `once` pass pauses the canvas; the button has to say so.
this.syncPlayButton();
}
if (this.canvas.isPlaying) this.canvas.tick(dt);
// A video moves by itself, and a driven one (#404) has to be told every frame that the
// playhead stands; the playhead redraws only when it moves.
this.preview.animate(drawn);
@@ -117,7 +115,6 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
private togglePlay(): void {
if (this.canvas.isPlaying) this.canvas.pause();
else this.canvas.play();
this.syncPlayButton();
}
/** What may be animated: a sprite, text, container or scene – never another animation. */
+217 -51
View File
@@ -24,14 +24,18 @@ import {
hasKey,
interpolationOf,
type KeyRef,
lineValueAt,
rowVideoAt,
sameKey,
type TimelineLine,
timelineLines,
trackDef,
trackLabel,
trackValueAt,
} from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { curveLookup } from '../../core/model/curve';
import type { PropertyValue, Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { type FrameValue, realTimeEnd, videoLengthOf } from '../../core/model/video';
import { VIDEO_TIME_KEY } from '../../core/properties/videos';
@@ -43,11 +47,13 @@ import {
type Size,
Theme,
textStyle,
truncate,
ui,
Widget,
zoomFactor,
crispText,
} from '../../ui';
import { createField, type Field } from '../inspector/fieldFactory';
import { type EditorState, trackSelection } from '../state';
import { ViewTween } from '../viewTween';
import { FramesDialog, openFramesDialog } from './FramesDialog';
@@ -62,6 +68,7 @@ import {
KEY_SIZE,
keyAt,
keysWithin,
type Lane,
ROW_HEIGHT,
rowAt,
rowCentreY,
@@ -76,6 +83,16 @@ import {
const DRAG_THRESHOLD = 3;
/** Inset of a row's label from the header's left edge. */
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;
/** A row's value field, and what it was built for: a row aimed elsewhere needs another widget. */
interface ValueField {
field: Field;
builtFor: string;
shown: string;
}
/**
* Keys being dragged: what was picked, and how far the pointer has taken them, in whole frames.
@@ -108,8 +125,12 @@ export class TimelineCanvas extends Widget {
private readonly cells = new Graphics();
private readonly overlay = new Graphics();
private readonly labelLayer = new Container();
private readonly fieldLayer = new Container();
private readonly rulerLayer = new Container();
/** By row id, and by `group:<target>` for an object's head. */
private readonly labels = new Map<string, Text>();
private readonly fields = new Map<string, ValueField>();
private linesFor: { asset: AnimationAsset | null; project: object; lines: TimelineLine[] } | null = null;
private readonly rulerTexts: Text[] = [];
private readonly hint = crispText({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics();
@@ -136,10 +157,21 @@ export class TimelineCanvas extends Widget {
private scrubbing = false;
/** Told whenever the playhead moves – the preview above draws the frame it stands on. */
onPlayhead: () => void = () => undefined;
/** Told whenever play starts or stops, whoever stopped it – the header's button says which. */
onPlaying: () => void = () => undefined;
constructor(private readonly state: EditorState) {
super();
this.addChild(this.bg, this.cells, this.overlay, this.labelLayer, this.rulerLayer, this.hint, this.clip);
this.addChild(
this.bg,
this.cells,
this.overlay,
this.labelLayer,
this.fieldLayer,
this.rulerLayer,
this.hint,
this.clip,
);
this.cells.mask = this.clip;
this.overlay.mask = this.clip;
this.rulerLayer.mask = this.clip;
@@ -152,9 +184,13 @@ export class TimelineCanvas extends Widget {
this.on('pointerupoutside', () => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.redraw());
state.on('project', () => {
this.linesFor = null;
this.redraw();
});
state.on('selection', () => this.redraw());
state.on('load', () => {
this.linesFor = null;
this.view = { ...DEFAULT_VIEW };
this.redraw();
});
@@ -198,19 +234,25 @@ export class TimelineCanvas extends Widget {
const count = frameCount(asset);
if (count > 0 && this.playhead >= count) this.playhead = 0;
this.time = this.timeOf(asset, this.playhead);
this.playingNow = true;
this.setPlaying(true);
this.redraw();
this.onPlayhead();
}
pause(): void {
this.playingNow = false;
this.setPlaying(false);
this.redraw();
}
private setPlaying(playing: boolean): void {
if (this.playingNow === playing) return;
this.playingNow = playing;
this.onPlaying();
}
/** Pauses and puts the playhead back to frame 0. */
stop(): void {
this.playingNow = false;
this.setPlaying(false);
this.playhead = 0;
this.playheadReverse = false;
this.time = 0;
@@ -225,7 +267,7 @@ export class TimelineCanvas extends Widget {
const at = animationPlayheadAt(asset, this.time);
this.playhead = at.frame;
this.playheadReverse = at.reverse;
if (at.finished) this.playingNow = false;
if (at.finished) this.setPlaying(false);
this.redraw();
this.onPlayhead();
}
@@ -355,11 +397,10 @@ export class TimelineCanvas extends Widget {
private openMenu(asset: AnimationAsset, local: Vec2, global: Vec2): void {
const hit =
local.x >= HEADER_WIDTH && local.y >= RULER_HEIGHT
? keyAt(this.view, asset.tracks, { x: local.x - HEADER_WIDTH, y: local.y })
? keyAt(this.view, this.lanes(), { x: local.x - HEADER_WIDTH, y: local.y })
: null;
if (hit && !hasKey(this.state.selectedKeys, hit)) this.state.select(trackSelection(asset.id, [hit]));
const row = local.y >= RULER_HEIGHT ? rowAt(local.y, asset.tracks.length) : -1;
const track = row >= 0 ? asset.tracks[row] : undefined;
const track = this.laneAt(local.y);
if (track && local.x < HEADER_WIDTH && this.state.selectedTrackId !== track.id)
this.state.select(trackSelection(asset.id, [], track.id));
const picked = this.state.selectedKeys.length;
@@ -442,10 +483,34 @@ export class TimelineCanvas extends Widget {
return out;
}
/** The row labels as drawn, top to bottom. */
/** The rows as their full labels (`object · property`), top to bottom. */
rowsDrawn(): string[] {
const asset = this.asset();
return asset ? asset.tracks.map((t) => trackLabel(this.state.project, asset, t)) : [];
return asset ? this.lanes().flatMap((t) => (t ? [trackLabel(this.state.project, asset, t)] : [])) : [];
}
/** The header column as drawn (#408): objects' heads and rows, with the value a row's field shows. */
linesDrawn(): { kind: string; text: string; indent: number; value?: PropertyValue; trackId?: string }[] {
return this.lines().map((line) => {
const label = this.labels.get(this.labelKey(line));
const base = { kind: line.kind, text: label?.text ?? '', indent: label?.x ?? 0 };
if (line.kind === 'group') return base;
const shown = this.fields.get(line.track.id)?.shown;
return {
...base,
trackId: line.track.id,
value: shown === undefined ? undefined : (JSON.parse(shown) as PropertyValue),
};
});
}
/** Where a row's value field sits on screen, or null for a row without one. */
valueGlobal(trackId: string): (Vec2 & { kind: string }) | null {
const entry = this.fields.get(trackId);
if (!entry?.field.widget.visible) return null;
const widget = entry.field.widget;
const g = widget.getGlobalPosition();
return { x: g.x + widget.w / 2, y: g.y + widget.h / 2, kind: entry.builtFor.split('|')[2] ?? '' };
}
viewInfo(): TimelineView & { playhead: number; playing: boolean } {
@@ -458,13 +523,34 @@ export class TimelineCanvas extends Widget {
return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'animation') ?? null) : null;
}
/** The header's lines, built once per change of the project rather than per frame of play. */
private lines(): TimelineLine[] {
const asset = this.asset();
const project = this.state.project;
if (this.linesFor?.asset !== asset || this.linesFor.project !== project)
this.linesFor = { asset, project, lines: asset ? timelineLines(project, asset) : [] };
return this.linesFor.lines;
}
private lanes(): Lane[] {
return this.lines().map((line) => (line.kind === 'row' ? line.track : null));
}
/** 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);
return (row >= 0 ? lanes[row] : null) ?? undefined;
}
private rowOf(trackId: string): number {
return this.asset()?.tracks.findIndex((t) => t.id === trackId) ?? -1;
return this.lines().findIndex((line) => line.kind === 'row' && line.track.id === trackId);
}
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
if (!asset) return;
// A value field (#408) takes its own presses, and keeps the focus they give it.
if (!asset || isWithin(e.target, this.fieldLayer)) return;
const local = this.toLocal(e.global);
const point = { x: local.x, y: local.y };
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
@@ -483,8 +569,7 @@ export class TimelineCanvas extends Widget {
e.stopPropagation();
if (local.x < HEADER_WIDTH) {
const row = rowAt(local.y, asset.tracks.length);
const track = row >= 0 ? asset.tracks[row] : undefined;
const track = this.laneAt(local.y);
if (track) this.state.select(trackSelection(asset.id, [], track.id));
return;
}
@@ -496,7 +581,7 @@ export class TimelineCanvas extends Widget {
return;
}
const picked = this.state.selectedKeys;
const hit = keyAt(this.view, asset.tracks, cell);
const hit = keyAt(this.view, this.lanes(), cell);
if (hit) {
// A picked key is taken as it is, so a drag moves the whole pick; an unpicked one replaces it.
const keys = hasKey(picked, hit) ? picked : [hit];
@@ -510,8 +595,7 @@ export class TimelineCanvas extends Widget {
};
return;
}
const row = rowAt(cell.y, asset.tracks.length);
const track = row >= 0 ? asset.tracks[row] : undefined;
const track = this.laneAt(cell.y);
if (track && pressCount(e, this) === 2) {
this.setKey(asset, track, frameAt(this.view, cell.x));
return;
@@ -587,7 +671,7 @@ export class TimelineCanvas extends Widget {
}
const a = { x: band.start.x - HEADER_WIDTH, y: band.start.y };
const b = { x: band.current.x - HEADER_WIDTH, y: band.current.y };
const caught = keysWithin(this.view, asset.tracks, a, b);
const caught = keysWithin(this.view, this.lanes(), a, b);
this.state.select(trackSelection(asset.id, caught) ?? { kind: 'asset', assetId: asset.id });
this.redraw();
}
@@ -619,7 +703,8 @@ export class TimelineCanvas extends Widget {
this.cells.clear();
this.overlay.clear();
const asset = this.asset();
const tracks = asset?.tracks ?? [];
const lines = this.lines();
const lanes = this.lanes();
const selectedRow = this.state.selectedTrackId ?? '';
// Header column and ruler chrome.
@@ -629,25 +714,32 @@ export class TimelineCanvas extends Widget {
this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border);
if (asset) {
this.drawRows(asset, tracks, selectedRow);
this.drawRows(asset, lanes, selectedRow);
this.drawRuler(asset);
this.drawKeys(tracks);
this.drawKeys(lanes);
this.drawPlayhead();
}
if (this.band?.active) drawBand(this.overlay, this.band.start, this.band.current);
this.syncLabels(asset, tracks, selectedRow);
this.syncLabels(lines, selectedRow);
this.syncFields(lines);
this.syncHint(asset);
}
private drawRows(asset: AnimationAsset, tracks: readonly AnimationTrack[], selectedRow: string): void {
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);
tracks.forEach((track, row) => {
lanes.forEach((track, row) => {
const y = rowTop(row);
const selected = track.id === selectedRow;
if (selected) this.bg.rect(0, y, HEADER_WIDTH, ROW_HEIGHT).fill({ color: c.selection, alpha: 0.6 });
// 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 });
return;
}
if (track.id === selectedRow)
this.bg.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,
@@ -660,17 +752,14 @@ export class TimelineCanvas extends Widget {
this.cells
.rect(HEADER_WIDTH, y + ROW_HEIGHT - 1, this.w - HEADER_WIDTH, 1)
.fill({ color: c.border, alpha: 0.6 });
});
// Cell borders only when they have room to be seen.
if (this.view.cellWidth >= 8 && tracks.length > 0) {
const bottom = rowTop(tracks.length);
const first = frameAt(this.view, 0);
for (let frame = first; ; frame++) {
// Cell borders only when they have room to be seen.
if (this.view.cellWidth < 8) return;
for (let frame = frameAt(this.view, 0); ; frame++) {
const x = HEADER_WIDTH + frameToX(this.view, frame);
if (x > this.w) break;
this.cells.rect(x, RULER_HEIGHT, 1, bottom - RULER_HEIGHT).fill({ color: c.border, alpha: 0.35 });
this.cells.rect(x, y, 1, ROW_HEIGHT).fill({ color: c.border, alpha: 0.35 });
}
}
});
}
private drawRuler(asset: AnimationAsset): void {
@@ -703,12 +792,13 @@ export class TimelineCanvas extends Widget {
for (let i = used; i < this.rulerTexts.length; i++) this.rulerTexts[i]!.visible = false;
}
private drawKeys(tracks: readonly AnimationTrack[]): void {
private drawKeys(lanes: readonly Lane[]): void {
const c = Theme.colors;
const picked = this.state.selectedKeys;
const drag = this.dragging?.moved ? this.dragging : null;
const half = KEY_SIZE / 2;
tracks.forEach((track, row) => {
lanes.forEach((track, row) => {
if (!track) return;
const y = rowCentreY(row);
for (const key of track.keys) {
const ref = { trackId: track.id, frame: key.frame };
@@ -744,25 +834,40 @@ export class TimelineCanvas extends Widget {
.fill(Theme.colors.hot);
}
private syncLabels(
asset: AnimationAsset | null,
tracks: readonly AnimationTrack[],
selectedRow: string,
): void {
private labelKey(line: TimelineLine): string {
return line.kind === 'group' ? `group:${line.target}` : line.track.id;
}
/** An object's name at its depth in the hierarchy; under it its rows, a step further in. */
private syncLabels(lines: readonly TimelineLine[], selectedRow: string): void {
const alive = new Set<string>();
tracks.forEach((track, row) => {
alive.add(track.id);
let label = this.labels.get(track.id);
lines.forEach((line, row) => {
const id = this.labelKey(line);
alive.add(id);
let label = this.labels.get(id);
if (!label) {
label = crispText({ style: textStyle('body') });
this.labels.set(track.id, label);
this.labels.set(id, label);
this.labelLayer.addChild(label);
}
const missing = !asset || !trackDef(this.state.project, asset, track);
const text = asset ? trackLabel(this.state.project, asset, track) : '';
const indent = LABEL_INSET + Math.min(line.depth, 6) * Theme.treeIndent;
const x = line.kind === 'group' ? indent : indent + Theme.treeIndent;
const style =
line.kind === 'group'
? line.name === '(missing)'
? 'warning'
: 'bodyBold'
: !line.def
? 'warning'
: line.track.id === selectedRow
? 'accent'
: 'body';
label.style = textStyle(style);
const room =
(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.style = textStyle(missing ? 'warning' : track.id === selectedRow ? 'accent' : 'body');
label.position.set(LABEL_INSET, rowTop(row) + (ROW_HEIGHT - label.height) / 2);
label.position.set(x, rowTop(row) + (ROW_HEIGHT - label.height) / 2);
label.visible = rowTop(row) + ROW_HEIGHT <= this.h;
});
for (const [id, label] of this.labels) {
@@ -772,6 +877,62 @@ export class TimelineCanvas extends Widget {
}
}
/**
* A row's value at the playhead in the widget the inspector edits its kind with (#408). A change
* keys it there. A field being typed into is left alone, and play leaves it be until it stops.
*/
private syncFields(lines: readonly TimelineLine[]): void {
const alive = new Set<string>();
const curves = curveLookup(this.state.project);
lines.forEach((line, row) => {
if (line.kind !== 'row' || !line.def) return;
const id = line.track.id;
alive.add(id);
const value = lineValueAt(line, this.playhead, this.playheadReverse, curves) ?? line.def.default;
const builtFor = `${line.track.target}|${line.track.property}|${line.def.kind}`;
let entry = this.fields.get(id);
if (entry && entry.builtFor !== builtFor) {
entry.field.widget.destroy({ children: true });
entry = undefined;
}
if (!entry) {
const field = createField({ project: () => this.state.project }, { ...line.def }, value, {
onInput: () => undefined,
onChange: (v) => this.keyAtPlayhead(id, v),
});
entry = { field, builtFor, shown: '' };
this.fields.set(id, entry);
this.fieldLayer.addChild(field.widget);
}
const shown = JSON.stringify(value);
if (entry.shown !== shown && !entry.field.widget.focused) {
entry.field.setValue(value);
entry.shown = shown;
}
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);
if (widget.w !== w || widget.h !== h) widget.resize(w, h);
widget.visible = rowTop(row) + ROW_HEIGHT <= this.h;
});
for (const [id, entry] of this.fields) {
if (alive.has(id)) continue;
entry.field.widget.destroy({ children: true });
this.fields.delete(id);
}
}
/** A value typed into a row's header: the key at the playhead, picked so the inspector shows it. */
private keyAtPlayhead(trackId: string, value: PropertyValue): void {
const asset = this.asset();
if (!asset) return;
this.pause();
const frame = this.playheadFrame;
if (this.state.execute(new SetAnimationKeyCommand(asset.id, trackId, frame, value)))
this.state.select(trackSelection(asset.id, [{ trackId, frame }]));
}
private syncHint(asset: AnimationAsset | null): void {
const text = !asset
? 'No animation selected – pick one in the Assets panel or create one.'
@@ -809,3 +970,8 @@ function drawKeyShape(
else g.lineTo(x - half, y).lineTo(x, y - half);
return g.closePath();
}
function isWithin(target: unknown, root: Container): boolean {
for (let node = target as Container | null; node; node = node.parent) if (node === root) return true;
return false;
}
+9 -11
View File
@@ -15,8 +15,11 @@ export interface TimelineView {
cellWidth: number;
}
/** A line of the timeline as the geometry sees it: a row, or `null` for an object's head (#408). */
export type Lane = AnimationTrack | null;
/** Width of the row header column, in pixels. */
export const HEADER_WIDTH = 220;
export const HEADER_WIDTH = 240;
/** Height of the frame ruler above the rows. */
export const RULER_HEIGHT = 22;
export const ROW_HEIGHT = 26;
@@ -93,10 +96,10 @@ export function rulerStep(view: TimelineView): number {
* The key under a point, tested against the diamond drawn at the cell's centre rather than the
* 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 AnimationTrack[], point: Vec2): KeyRef | null {
export function keyAt(view: TimelineView, tracks: readonly Lane[], point: Vec2): KeyRef | null {
const row = rowAt(point.y, tracks.length);
if (row < 0) return null;
const track = tracks[row]!;
const track = row < 0 ? null : tracks[row];
if (!track) return null;
const reach = Math.max(KEY_SIZE / 2, view.cellWidth / 2);
for (const key of track.keys) {
if (Math.abs(frameCentreX(view, key.frame) - point.x) <= reach)
@@ -106,12 +109,7 @@ export function keyAt(view: TimelineView, tracks: readonly AnimationTrack[], poi
}
/** The keys whose diamond centre lies inside the band spanned between two points. */
export function keysWithin(
view: TimelineView,
tracks: readonly AnimationTrack[],
a: Vec2,
b: Vec2,
): KeyRef[] {
export function keysWithin(view: TimelineView, tracks: readonly Lane[], a: Vec2, b: Vec2): KeyRef[] {
const left = Math.min(a.x, b.x);
const right = Math.max(a.x, b.x);
const top = Math.min(a.y, b.y);
@@ -119,7 +117,7 @@ export function keysWithin(
const out: KeyRef[] = [];
tracks.forEach((track, row) => {
const y = rowCentreY(row);
if (y < top || y > bottom) return;
if (!track || y < top || y > bottom) return;
for (const key of track.keys) {
const x = frameCentreX(view, key.frame);
if (x >= left && x <= right) out.push({ trackId: track.id, frame: key.frame });
+3
View File
@@ -43,6 +43,9 @@ export function documentHooks({ state, editor }: HookContext) {
rulerAt: (frame: number) => editor.animationEditor.canvas.rulerGlobal(frame),
keys: () => editor.animationEditor.canvas.keysDrawn(),
rows: () => editor.animationEditor.canvas.rowsDrawn(),
/** The header column (#408): objects' heads and rows, and where a row's value field sits. */
lines: () => editor.animationEditor.canvas.linesDrawn(),
valueAt: (trackId: string) => editor.animationEditor.canvas.valueGlobal(trackId),
view: () => editor.animationEditor.canvas.viewInfo(),
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. */
+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();
+13
View File
@@ -14,6 +14,7 @@ import {
ROW_HEIGHT,
rowAt,
rowCentreY,
rowTop,
RULER_HEIGHT,
rulerStep,
type TimelineView,
@@ -125,6 +126,18 @@ describe('picking keys', () => {
expect(keysWithin(view, TRACKS, a, b).map((k) => k.trackId)).toEqual(['a', 'a']);
});
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');
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('a move never takes a key before frame 0', () => {
const keys = [
{ trackId: 'a', frame: 2 },
+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));