diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 17ac360f..84a068bc 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -428,11 +428,12 @@ export function placedRoot(root: Asset): SceneObject { }; } -/** One object a row may aim at below the root, under the id the row stores. */ +/** One object a row may aim at below the root, under the id the row stores; `parent` is the one above it, '' at the top. */ export interface AnimationTarget { id: string; object: SceneObject; depth: number; + parent: string; } /** @@ -447,12 +448,18 @@ export function animationTargets(project: Project, root: Asset | undefined): Ani id: object.id, object, depth, + parent: depth > 0 ? object.parentId : '', })); if (root?.type !== 'container') return []; const prefix = instanceChildId(PLACED_ROOT_ID, ''); return treeOrder({ objects: resolveObjects(project, { objects: [placedRoot(root)] }) }) .filter(({ object }) => object.id.startsWith(prefix)) - .map(({ object, depth }) => ({ id: object.id.slice(prefix.length), object, depth: depth - 1 })); + .map(({ object, depth }) => ({ + id: object.id.slice(prefix.length), + object, + depth: depth - 1, + parent: object.parentId.startsWith(prefix) ? object.parentId.slice(prefix.length) : '', + })); } /** @@ -579,12 +586,13 @@ export function trackLabel(project: Project, animation: AnimationAsset, track: A /** * 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. + * named by its property alone. `depth` is the object's in the hierarchy, `above` the objects it sits + * under, the nearest last – the root not among them: its head stands level with its children's (#414). + * 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: 'group'; target: string; name: string; depth: number; above: readonly string[] } | { kind: 'row'; track: AnimationTrack; @@ -600,10 +608,21 @@ export type TimelineLine = * 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([['', { at: 0, depth: 0 }]]); + const placed = new Map([ + ['', { at: 0, depth: 0, parent: '' }], + ]); animationTargets(project, findAsset(project, animation.root)).forEach((t, i) => - placed.set(t.id, { at: i + 1, depth: t.depth }), + placed.set(t.id, { at: i + 1, depth: t.depth, parent: t.parent }), ); + const aboveOf = (target: string): string[] => { + const above: string[] = []; + let at = placed.get(target)?.parent ?? ''; + while (at !== '' && !above.includes(at)) { + above.unshift(at); + at = placed.get(at)?.parent ?? ''; + } + return above; + }; 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[] = []; @@ -616,7 +635,7 @@ export function timelineLines(project: Project, animation: AnimationAsset): Time : aimed.object ? objectName(aimed.object, aimed.asset) : aimed.asset.name; - lines.push({ kind: 'group', target, name, depth }); + lines.push({ kind: 'group', target, name, depth, above: aboveOf(target) }); const order = (track: AnimationTrack) => { const i = defs.findIndex((d) => d.key === track.property); return i < 0 ? defs.length : i; diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 6dd894ef..d64d29db 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -67,8 +67,9 @@ import { frameCentreX, frameToX, HEADER_WIDTH, + isSummary, KEY_SIZE, - keyAt, + keysAt, keysWithin, type Lane, maxScrollY, @@ -81,9 +82,12 @@ import { rulerStep, scrollByThumb, scrollThumb, + type SummaryLane, + summaryFrames, type TimelineView, zoomCells, } from './timelineView'; +import { foldLines, type ShownLine, unfoldedFor } from './timelineFold'; /** Pixels the pointer must travel before a press becomes a drag rather than a click. */ const DRAG_THRESHOLD = 3; @@ -94,6 +98,8 @@ const FIELD_WIDTH = 104; const FIELD_INSET = 4; /** How far from the right edge a press takes the scrollbar rather than the cells under it. */ const SCROLLBAR_REACH = 10; +/** Room in front of an object's name for the triangle that folds its group (#414). */ +const FOLD_SLOT = 14; /** A row's value field, and what it was built for: a row aimed elsewhere needs another widget. */ interface ValueField { @@ -145,7 +151,14 @@ export class TimelineCanvas extends Widget { /** By row id, and by `group:` for an object's head. */ private readonly labels = new Map(); private readonly fields = new Map(); - private linesFor: { asset: AnimationAsset | null; project: object; lines: TimelineLine[] } | null = null; + private linesFor: { + asset: AnimationAsset | null; + project: object; + all: TimelineLine[]; + lines: ShownLine[]; + } | null = null; + /** The folded heads by animation (#414): a way of looking, kept for the session and never an undo step. */ + private readonly folds = new Map>(); private readonly rulerTexts = new TextPool(this.rulerLayer); private readonly hint = crispText({ text: '', style: textStyle('muted') }); /** Right of the header column. */ @@ -230,6 +243,7 @@ export class TimelineCanvas extends Widget { }); state.on('load', () => { this.linesFor = null; + this.folds.clear(); this.view = { ...DEFAULT_VIEW }; this.redraw(); }); @@ -267,15 +281,39 @@ export class TimelineCanvas extends Widget { this.view = clampScroll({ ...this.view, scrollY: Math.round(y) }, this.lines().length, this.h); } - /** A row picked outside the view – by a click elsewhere, or a row just added – is brought into it. */ + /** + * A row picked outside the view – by a click elsewhere, or a row just added – is brought into it. A + * row picked alone in a folded group opens it; keys picked through a head's diamond leave it folded. + */ private revealPicked(): void { const picked = this.state.selectedAnimationId === this.assetId ? (this.state.selectedTrackId ?? '') : ''; if (picked === this.revealed) return; this.revealed = picked; + const target = this.asset()?.tracks.find((t) => t.id === picked)?.target; + if (target !== undefined && this.rowOf(picked) < 0 && this.state.selectedKeys.length === 0) + this.setFolded(unfoldedFor(this.allLines(), this.foldedHere(), target)); const row = picked ? this.rowOf(picked) : -1; if (row >= 0) this.view = revealRow(this.view, row, this.lines().length, this.h); } + private foldedHere(): ReadonlySet { + return this.folds.get(this.assetId) ?? new Set(); + } + + private setFolded(folded: ReadonlySet): void { + if (folded.size > 0) this.folds.set(this.assetId, folded); + else this.folds.delete(this.assetId); + this.linesFor = null; + } + + /** Folds or opens the group of the object `target` names ('' the root's); its children's fold with it. */ + toggleFold(target: string): void { + const folded = new Set(this.foldedHere()); + if (!folded.delete(target)) folded.add(target); + this.setFolded(folded); + this.redraw(); + } + get isPlaying(): boolean { return this.playingNow; } @@ -450,9 +488,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, 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])); + ? keysAt(this.view, this.lanes(), { x: local.x - HEADER_WIDTH, y: local.y }) + : []; + if (!hit.every((k) => hasKey(this.state.selectedKeys, k))) + this.state.select(trackSelection(asset.id, hit)); 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)); @@ -518,6 +557,26 @@ export class TimelineCanvas extends Widget { return { x: origin.x + HEADER_WIDTH / 2, y: origin.y + rowCentreY(row, this.view.scrollY) }; } + /** Where the fold triangle of an object's head sits on screen ('' the root's). */ + foldGlobal(target: string): Vec2 | null { + const row = this.headOf(target); + const line = this.lines()[row]; + if (!line) return null; + const origin = this.getGlobalPosition(); + return { x: origin.x + this.indentOf(line) + 5, y: origin.y + rowCentreY(row, this.view.scrollY) }; + } + + /** Where a head's diamond on `frame` sits on screen. */ + summaryGlobal(target: string, frame: number): Vec2 | null { + const row = this.headOf(target); + if (row < 0) return null; + const origin = this.getGlobalPosition(); + return { + x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), + y: origin.y + rowCentreY(row, this.view.scrollY), + }; + } + /** The scrollbar's thumb on screen while the rows do not all fit, its height and the scroll's reach. */ thumbGlobal(): (Vec2 & { height: number; max: number }) | null { const thumb = scrollThumb(this.view, this.lines().length, this.h); @@ -555,15 +614,30 @@ export class TimelineCanvas extends Widget { /** The rows as their full labels (`object · property`), top to bottom. */ rowsDrawn(): string[] { const asset = this.asset(); - return asset ? this.lanes().flatMap((t) => (t ? [trackLabel(this.state.project, asset, t)] : [])) : []; + return asset + ? this.lines().flatMap((l) => + l.kind === 'row' ? [trackLabel(this.state.project, asset, l.track)] : [], + ) + : []; } - /** 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 }[] { + /** + * The header column as drawn (#408): objects' heads – folded or not, with the frames of their + * diamonds (#414) – and rows, with the value a row's field shows. + */ + linesDrawn(): { + kind: string; + text: string; + indent: number; + value?: PropertyValue; + trackId?: string; + folded?: boolean; + summary?: number[]; + }[] { 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; + if (line.kind === 'group') return { ...base, folded: line.folded, summary: summaryFrames(line) }; const shown = this.fields.get(line.track.id)?.shown; return { ...base, @@ -592,24 +666,41 @@ 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[] { + /** The header's lines as shown, built once per change of the project or a fold rather than per frame of play. */ + private lines(): ShownLine[] { 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) : [] }; + if (this.linesFor?.asset !== asset || this.linesFor.project !== project) { + const all = asset ? timelineLines(project, asset) : []; + this.linesFor = { asset, project, all, lines: foldLines(all, this.foldedHere()) }; + } return this.linesFor.lines; } + /** Every line, folded or not. */ + private allLines(): TimelineLine[] { + this.lines(); + return this.linesFor?.all ?? []; + } + private lanes(): Lane[] { - return this.lines().map((line) => (line.kind === 'row' ? line.track : null)); + return this.lines().map((line) => (line.kind === 'row' ? line.track : line)); } /** 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, this.view.scrollY); - return (row >= 0 ? lanes[row] : null) ?? undefined; + const line = this.lineAt(y); + return line?.kind === 'row' ? line.track : undefined; + } + + private lineAt(y: number): ShownLine | undefined { + const lines = this.lines(); + const row = rowAt(y, lines.length, this.view.scrollY); + return row >= 0 ? lines[row] : undefined; + } + + private headOf(target: string): number { + return this.lines().findIndex((line) => line.kind === 'group' && line.target === target); } private rowOf(trackId: string): number { @@ -657,8 +748,9 @@ export class TimelineCanvas extends Widget { e.stopPropagation(); if (local.x < HEADER_WIDTH) { - const track = this.laneAt(local.y); - if (track) this.state.select(trackSelection(asset.id, [], track.id)); + const line = this.lineAt(local.y); + if (line?.kind === 'group') this.toggleFold(line.target); + else if (line) this.state.select(trackSelection(asset.id, [], line.track.id)); return; } const cell = { x: local.x - HEADER_WIDTH, y: local.y }; @@ -669,11 +761,12 @@ export class TimelineCanvas extends Widget { return; } const picked = this.state.selectedKeys; - const hit = keyAt(this.view, this.lanes(), cell); - if (hit) { + const hit = keysAt(this.view, this.lanes(), cell); + if (hit.length > 0) { // 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]; - if (!hasKey(picked, hit)) this.state.select(trackSelection(asset.id, keys, hit.trackId)); + const taken = hit.every((k) => hasKey(picked, k)); + const keys = taken ? picked : hit; + if (!taken) this.state.select(trackSelection(asset.id, keys)); this.dragging = { keys: [...keys], pointer: point, @@ -854,15 +947,19 @@ export class TimelineCanvas extends Widget { const count = frameCount(asset); const x0 = HEADER_WIDTH + frameToX(this.view, 0); const x1 = HEADER_WIDTH + frameToX(this.view, count); + const lines = this.lines(); lanes.forEach((track, row) => { const y = rowTop(row, this.view.scrollY); if (!this.inView(y)) return; - // An object's head: a band across both halves, no cells – nothing is keyed on it. - if (!track) { + // An object's head: a band across both halves with the triangle that folds it, no cells of its own. + const line = lines[row]; + if (line?.kind === 'group') { this.rowsBg.rect(0, y, this.w, ROW_HEIGHT).fill(c.panelHeader); this.rowsBg.rect(0, y + ROW_HEIGHT - 1, this.w, 1).fill({ color: c.border, alpha: 0.6 }); + drawFoldTriangle(this.rowsBg, this.indentOf(line) + 4, y + ROW_HEIGHT / 2, line.folded); return; } + if (!track || isSummary(track)) return; if (track.id === selectedRow) this.rowsBg.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({ @@ -912,34 +1009,54 @@ export class TimelineCanvas extends Widget { 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; - lanes.forEach((track, row) => { - if (!track || !this.inView(rowTop(row, this.view.scrollY))) return; + const seen = (frame: number) => { + const x = HEADER_WIDTH + frameCentreX(this.view, frame); + return x >= HEADER_WIDTH - half && x <= this.w + half ? x : null; + }; + lanes.forEach((lane, row) => { + if (!lane || !this.inView(rowTop(row, this.view.scrollY))) return; const y = rowCentreY(row, this.view.scrollY); - for (const key of track.keys) { - const ref = { trackId: track.id, frame: key.frame }; - const selected = hasKey(picked, ref); - const dragged = drag !== null && drag.keys.some((k) => sameKey(k, ref)); - // A duplicate leaves the original where it is, drawn unpicked, and the copy travels picked. - const frames = !dragged - ? [key.frame] - : drag.copy - ? [key.frame, key.frame + drag.delta] - : [key.frame + drag.delta]; - frames.forEach((frame, i) => { - const x = HEADER_WIDTH + frameCentreX(this.view, frame); - if (x < HEADER_WIDTH - half || x > this.w + half) return; - const lit = selected && !(dragged && drag.copy && i === 0); - drawKeyShape(this.keyMarks, interpolationOf(key), x, y, half) - .fill(lit ? c.accent : c.text) + if (isSummary(lane)) { + for (const [frame, lit] of this.summaryShown(lane)) { + const x = seen(frame); + if (x === null) continue; + drawKeyShape(this.keyMarks, 'linear', x, y, half - 1) + .fill(lit ? c.accent : c.textMuted) .stroke({ width: 1, color: lit ? c.accentText : c.viewport }); - }); + } + return; } + for (const key of lane.keys) + for (const shown of this.keyShown({ trackId: lane.id, frame: key.frame })) { + const x = seen(shown.frame); + if (x === null) continue; + drawKeyShape(this.keyMarks, interpolationOf(key), x, y, half) + .fill(shown.lit ? c.accent : c.text) + .stroke({ width: 1, color: shown.lit ? c.accentText : c.viewport }); + } }); } + /** Where a key is drawn and whether lit: a duplicate leaves the original where it is, unpicked, and the copy travels picked. */ + private keyShown(ref: KeyRef): { frame: number; lit: boolean }[] { + const selected = hasKey(this.state.selectedKeys, ref); + const drag = this.dragging?.moved ? this.dragging : null; + if (!drag?.keys.some((k) => sameKey(k, ref))) return [{ frame: ref.frame, lit: selected }]; + const moved = { frame: ref.frame + drag.delta, lit: selected }; + return drag.copy ? [{ frame: ref.frame, lit: false }, moved] : [moved]; + } + + /** A head's diamonds as drawn (#414), by frame: lit when every key of its group there is. */ + private summaryShown(lane: SummaryLane): Map { + const shown = new Map(); + for (const track of lane.tracks) + for (const key of track.keys) + for (const { frame, lit } of this.keyShown({ trackId: track.id, frame: key.frame })) + shown.set(frame, (shown.get(frame) ?? true) && lit); + return shown; + } + private drawPlayhead(): void { const x = HEADER_WIDTH + frameCentreX(this.view, this.playhead); if (x < HEADER_WIDTH || x > this.w) return; @@ -956,6 +1073,10 @@ export class TimelineCanvas extends Widget { return line.kind === 'group' ? `group:${line.target}` : line.track.id; } + private indentOf(line: TimelineLine): number { + return LABEL_INSET + Math.min(line.depth, 6) * Theme.treeIndent; + } + /** 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(); @@ -968,8 +1089,9 @@ export class TimelineCanvas extends Widget { this.labels.set(id, label); this.labelLayer.addChild(label); } - const indent = LABEL_INSET + Math.min(line.depth, 6) * Theme.treeIndent; - const x = line.kind === 'group' ? indent : indent + Theme.treeIndent; + const indent = this.indentOf(line); + // A row stands a step in from its object's name, as a leaf does under its parent in the hierarchy. + const x = indent + FOLD_SLOT + (line.kind === 'group' ? 0 : Theme.treeIndent); const style = line.kind === 'group' ? line.name === '(missing)' @@ -1091,6 +1213,21 @@ function drawKeyShape( return g.closePath(); } +/** Points down while the group is open, right once folded – like the hierarchy's chevrons. */ +function drawFoldTriangle(g: Graphics, x: number, y: number, folded: boolean): void { + const color = Theme.colors.textMuted; + if (folded) + g.moveTo(x - 1, y - 4) + .lineTo(x + 4, y) + .lineTo(x - 1, y + 4) + .fill(color); + else + g.moveTo(x - 3, y - 2) + .lineTo(x + 5, y - 2) + .lineTo(x + 1, y + 3) + .fill(color); +} + 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; diff --git a/src/editor/animation/timelineFold.ts b/src/editor/animation/timelineFold.ts new file mode 100644 index 00000000..9245d298 --- /dev/null +++ b/src/editor/animation/timelineFold.ts @@ -0,0 +1,47 @@ +import type { TimelineLine } from '../../core/model/animation'; +import type { AnimationTrack } from '../../core/model/Asset'; + +/** A line as the timeline shows it (#414): an object's head knows whether it is folded and which rows it sums up. */ +export type ShownLine = + | (TimelineLine & { kind: 'row' }) + | (TimelineLine & { kind: 'group'; folded: boolean; tracks: readonly AnimationTrack[] }); + +/** + * The lines left with the heads `folded` names folded: a folded head hides its rows and the groups of + * the objects below it. Every head carries the rows of its group and of the groups below it – what its + * diamonds sum up, folded or not. + */ +export function foldLines(lines: readonly TimelineLine[], folded: ReadonlySet): ShownLine[] { + const above = aboveOf(lines); + const under = (target: string, head: string) => target === head || (above.get(target) ?? []).includes(head); + const out: ShownLine[] = []; + for (const line of lines) { + const target = line.kind === 'group' ? line.target : line.track.target; + const hidden = + (above.get(target) ?? []).some((t) => folded.has(t)) || (line.kind === 'row' && folded.has(target)); + if (hidden) continue; + if (line.kind === 'row') { + out.push(line); + continue; + } + const tracks = lines.flatMap((l) => (l.kind === 'row' && under(l.track.target, target) ? [l.track] : [])); + out.push({ ...line, folded: folded.has(target), tracks }); + } + return out; +} + +/** The heads still folded once the rows of `target` are to be seen: its own and those of the objects above it open. */ +export function unfoldedFor( + lines: readonly TimelineLine[], + folded: ReadonlySet, + target: string, +): Set { + const open = new Set([target, ...(aboveOf(lines).get(target) ?? [])]); + return new Set([...folded].filter((t) => !open.has(t))); +} + +function aboveOf(lines: readonly TimelineLine[]): Map { + const above = new Map(); + for (const line of lines) if (line.kind === 'group') above.set(line.target, line.above); + return above; +} diff --git a/src/editor/animation/timelineView.ts b/src/editor/animation/timelineView.ts index f94922d7..25cb3a9e 100644 --- a/src/editor/animation/timelineView.ts +++ b/src/editor/animation/timelineView.ts @@ -1,4 +1,4 @@ -import type { KeyRef } from '../../core/model/animation'; +import { hasKey, type KeyRef } from '../../core/model/animation'; import type { AnimationTrack } from '../../core/model/Asset'; import type { Vec2 } from '../../core/model/Properties'; import { clamp } from '../../ui'; @@ -17,8 +17,30 @@ export interface TimelineView { scrollY: 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; +/** An object's head (#414): the rows of its group, its children's included, whose keys it sums up frame by frame. */ +export interface SummaryLane { + target: string; + tracks: readonly AnimationTrack[]; +} + +/** A line of the timeline as the geometry sees it: a row, an object's head (#408), or `null` for nothing to hit. */ +export type Lane = AnimationTrack | SummaryLane | null; + +export function isSummary(lane: Lane | undefined): lane is SummaryLane { + return lane != null && 'tracks' in lane; +} + +/** The frames a head's diamonds stand on: every frame any of its rows has a key on, in order. */ +export function summaryFrames(lane: SummaryLane): number[] { + return [...new Set(lane.tracks.flatMap((t) => t.keys.map((k) => k.frame)))].sort((a, b) => a - b); +} + +/** The keys one of a head's diamonds stands for. */ +export function summaryKeys(lane: SummaryLane, frame: number): KeyRef[] { + return lane.tracks.flatMap((t) => + t.keys.some((k) => k.frame === frame) ? [{ trackId: t.id, frame }] : [], + ); +} /** Width of the row header column, in pixels. */ export const HEADER_WIDTH = 240; @@ -142,35 +164,37 @@ 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. + * The keys 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. A + * row's diamond is one key, a head's every key of its group on that frame (#414). */ -export function keyAt(view: TimelineView, tracks: readonly Lane[], point: Vec2): KeyRef | null { - const row = rowAt(point.y, tracks.length, view.scrollY); - const track = row < 0 ? null : tracks[row]; - if (!track) return null; +export function keysAt(view: TimelineView, lanes: readonly Lane[], point: Vec2): KeyRef[] { + const row = rowAt(point.y, lanes.length, view.scrollY); + const lane = row < 0 ? null : lanes[row]; + if (!lane) return []; 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) - return { trackId: track.id, frame: key.frame }; - } - return null; + const frames = isSummary(lane) ? summaryFrames(lane) : lane.keys.map((k) => k.frame); + const frame = frames.find((f) => Math.abs(frameCentreX(view, f) - point.x) <= reach); + if (frame === undefined) return []; + return isSummary(lane) ? summaryKeys(lane, frame) : [{ trackId: lane.id, frame }]; } -/** The keys whose diamond centre lies inside the band spanned between two points. */ -export function keysWithin(view: TimelineView, tracks: readonly Lane[], a: Vec2, b: Vec2): KeyRef[] { +/** The keys whose diamond centre lies inside the band spanned between two points – a head's standing for its group's. */ +export function keysWithin(view: TimelineView, lanes: 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); const bottom = Math.max(a.y, b.y); const out: KeyRef[] = []; - tracks.forEach((track, row) => { + lanes.forEach((lane, row) => { const y = rowCentreY(row, view.scrollY); - 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 }); - } + if (!lane || y < top || y > bottom) return; + for (const track of isSummary(lane) ? lane.tracks : [lane]) + for (const key of track.keys) { + const x = frameCentreX(view, key.frame); + const ref = { trackId: track.id, frame: key.frame }; + if (x >= left && x <= right && !hasKey(out, ref)) out.push(ref); + } }); return out; } diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index addab57d..ee3a049a 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -46,6 +46,10 @@ export function documentHooks({ state, editor }: HookContext) { /** 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), + /** An object's fold triangle and the diamond its head shows on a frame (#414). */ + foldAt: (target: string) => editor.animationEditor.canvas.foldGlobal(target), + summaryAt: (target: string, frame: number) => + editor.animationEditor.canvas.summaryGlobal(target, frame), view: () => editor.animationEditor.canvas.viewInfo(), /** The scrollbar's thumb beside the rows (#413), or null while they all fit. */ thumb: () => editor.animationEditor.canvas.thumbGlobal(), diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index 69447070..b00759cd 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -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(); diff --git a/tests/editor/timeline-view.test.ts b/tests/editor/timeline-view.test.ts index 448a47ce..bcc3452b 100644 --- a/tests/editor/timeline-view.test.ts +++ b/tests/editor/timeline-view.test.ts @@ -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 }, diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index 018d1b39..20455ad1 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -545,6 +545,83 @@ export async function animation(t) { JSON.stringify({ thumb, toTop }), ); + // 25k. Folding a group (#414): the Arm with a Hand under it. Folded, the Arm's rows and the Hand's + // group are gone and the head shows a diamond on every keyed frame; dragging the one on 6 to 8 + // moves both keys there in one undo step, and Delete on the one on 4 takes the Hand's key. + // Open again, the head keeps its diamonds. + await mutate(` + const p = __gm.state.project; + const box = p.assets.find((a) => a.id === 'an-box'); + const arm = box.objects[0]; + box.objects = [arm, { ...structuredClone(arm), id: 'an-hand', name: 'Hand', parentId: arm.id }]; + p.assets.find((a) => a.id === 'an-walk').tracks = [ + { id: 'an-k1', target: arm.id, property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 6, value: 60 }] }, + { id: 'an-k2', target: arm.id, property: 'y', keys: [{ frame: 2, value: 0 }, { frame: 6, value: 10 }] }, + { id: 'an-k3', target: 'an-hand', property: 'rotation', keys: [{ frame: 4, value: 90 }] }, + ]; + `); + await sleep(150); + const tracksNow = async () => + JSON.stringify((await walk()).tracks.map((r) => [r.id, r.keys.map((k) => k.frame)])); + const tracksBefore = await tracksNow(); + const fold = await json("__gm.animation.foldAt('an-arm')"); + await click(fold.x, fold.y); + const folded = await json('__gm.animation.lines()'); + check( + 'a click on the triangle folds the Arm: its rows and the Hand go, the head shows diamonds on 0, 2, 4 and 6', + folded.length === 1 && + folded[0].text === 'Arm' && + folded[0].folded === true && + JSON.stringify(folded[0].summary) === '[0,2,4,6]', + JSON.stringify(folded), + ); + await shot('22-folded-group'); + const six = await json("__gm.animation.summaryAt('an-arm', 6)"); + const eight = await json("__gm.animation.summaryAt('an-arm', 8)"); + await drag(six.x, six.y, eight.x, eight.y); + const movedTo = await tracksNow(); + await undo(); + const movedBack = await tracksNow(); + check( + "dragging the diamond on 6 to 8 moves both of the Arm's keys there, the Hand's stays, one undo takes it back", + movedTo === + JSON.stringify([ + ['an-k1', [0, 8]], + ['an-k2', [2, 8]], + ['an-k3', [4]], + ]) && movedBack === tracksBefore, + JSON.stringify({ movedTo, movedBack }), + ); + const four = await json("__gm.animation.summaryAt('an-arm', 4)"); + await click(four.x, four.y); + const pickedFour = await json('__gm.state.selectedKeys'); + await press('Delete'); + const foldedDelete = await tracksNow(); + await undo(); + check( + "the diamond on 4 picks the folded Hand's key, Delete removes it, undo brings it back, the group stays folded", + JSON.stringify(pickedFour) === JSON.stringify([{ trackId: 'an-k3', frame: 4 }]) && + foldedDelete === + JSON.stringify([ + ['an-k1', [0, 6]], + ['an-k2', [2, 6]], + ['an-k3', []], + ]) && + (await tracksNow()) === tracksBefore && + (await json('__gm.animation.lines()')).length === 1, + JSON.stringify({ pickedFour, foldedDelete }), + ); + await click(fold.x, fold.y); + const opened = await json('__gm.animation.lines()'); + check( + 'a second click opens it again, and the open head still shows its diamonds', + JSON.stringify(opened.map((l) => l.text)) === JSON.stringify(['Arm', 'X', 'Y', 'Hand', 'Rotation']) && + opened[0].folded === false && + JSON.stringify(opened[0].summary) === '[0,2,4,6]' && + JSON.stringify(opened[3].summary) === '[4]', + JSON.stringify(opened), + ); + await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id));