From 2e4c0a09ea8da2bc8817582f973bdb92de344c3d Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 18 Oct 2026 19:30:29 +0200 Subject: [PATCH] Open clips and frames from the asset panel by double click (#430) A double click on a clip row did not open its tab. The first click of the double click had already selected the clip for the inspector, so the second click selected the same thing again and the editor saw no change. Frames of textures and pixel art had no rows at all. - A double click now asks for the tab with openSelection, even when the selection is already right. - Textures with frames and pixel art with more than one frame get a chevron and one row per frame, like sounds and videos. Which rows an asset has is decided in one place, src/core/model/assetParts.ts, used by the panel and the inspector link. - A texture frame shows in the inspector on click and opens the texture with the frame selected on double click. - A pixel art frame selects the pixel art on click, so an open tab paints on that frame. A double click opens the tab on the frame. - A frame can be dragged onto a composition track like a video clip. The item carries the sprite of the whole image, the frame becomes the texture of its object, and it is 3 s long. The agent can do the same with add_composition_item. - A texture label that names a frame now points at the frame row. Found on the way: the e2e check from #425 only double clicked the texture label, where the first click is a ping, so the bug never showed there. Tests: - Unit: asset-parts (new, 6), composition-sprites and references extended, related tests 303/303, agent tests 21/21 - 5 mutations, all red in the matching test - e2e 69 (atx): ALL PASSED, 26 checks, 69t new - Gate (atx): CHECK EXIT 0, 325 files, 5017 tests Co-Authored-By: Claude Opus 5.5 --- src/core/commands/compositionSprites.ts | 34 +++++--- src/core/model/assetParts.ts | 28 ++++++ src/core/model/sound.ts | 8 -- src/core/properties/references.ts | 10 ++- src/editor/Editor.ts | 2 +- src/editor/actions.ts | 35 ++++++-- src/editor/agent/catalog/composition.ts | 5 +- src/editor/inspector/Inspector.ts | 10 +-- src/editor/panels/AssetPanel.ts | 85 +++++++++++-------- src/editor/pixelart/PixelArtCanvas.ts | 11 +++ src/editor/state.ts | 25 +++++- tests/core/composition-sprites.test.ts | 45 +++++++++- tests/core/references.test.ts | 13 ++- tests/editor/asset-parts.test.ts | 108 ++++++++++++++++++++++++ tools/e2e/69-composition-tab.mjs | 63 ++++++++++++++ 15 files changed, 405 insertions(+), 77 deletions(-) create mode 100644 src/core/model/assetParts.ts create mode 100644 tests/editor/asset-parts.test.ts diff --git a/src/core/commands/compositionSprites.ts b/src/core/commands/compositionSprites.ts index fd7dc714..23e583c6 100644 --- a/src/core/commands/compositionSprites.ts +++ b/src/core/commands/compositionSprites.ts @@ -9,9 +9,10 @@ import { newId } from '../ids'; import type { Asset, CompositionItem, SpriteAsset } from '../model/Asset'; import { isCompositionSprite } from '../model/composition'; import { generatesPictures } from '../model/generated'; -import { pixelArtWornId } from '../model/pixelart'; +import { findPixelFrame, pixelArtWornId } from '../model/pixelart'; import type { PropertyBag } from '../model/Properties'; import { findAsset, objectHolders, type Project } from '../model/Project'; +import { findFrame } from '../model/texture'; import { findVideo } from '../model/video'; import { spriteFromTexture } from '../properties/aspect'; import { type Removed, removeWhere, restoreAt } from '../util/list'; @@ -32,27 +33,38 @@ export function wornPicture(asset: Asset): string | null { : null; } -/** What an item dropped on a track wears: the picture its sprite is made of, and a clip of it (#425). */ +/** What an item dropped on a track wears: the picture its sprite is made of, and a part (#425, #430). */ export interface DroppedPicture { picture: string; - /** The clip of a video the item plays, as an override on its object; null for the whole picture. */ - clip: string | null; + /** + * The clip of a video the item plays or the frame it shows, as an override on its object; null for + * what its sprite wears. + */ + part: string | null; /** The name a new sprite for the picture gets. */ name: string; } /** - * What an asset or a clip of a video (#425) dragged onto a track wears. A clip shares the sprite of - * its video, so one composition keeps one sprite per video. Null for anything that is no picture. + * What an asset, a clip of a video (#425) or a frame of a picture (#430) dragged onto a track wears. A + * part shares the sprite of its asset, so one composition keeps one sprite per video or picture. Null + * for anything that is no picture. */ export function droppedPicture(project: Project, id: string): DroppedPicture | null { const asset = findAsset(project, id); if (asset) { const picture = wornPicture(asset); - return picture === null ? null : { picture, clip: null, name: asset.name }; + return picture === null ? null : { picture, part: null, name: asset.name }; } const video = findVideo(project, id); - return video?.clip ? { picture: video.asset.id, clip: video.clip.id, name: video.asset.name } : null; + if (video?.clip) return { picture: video.asset.id, part: video.clip.id, name: video.asset.name }; + const frame = findFrame(project, id); + if (frame) return { picture: frame.asset.id, part: id, name: frame.asset.name }; + const pixel = findPixelFrame(project, id); + if (!pixel) return null; + // Its first frame is what the sprite of a sequence wears already. + const picture = pixelArtWornId(pixel.asset); + return { picture, part: id === picture ? null : id, name: pixel.asset.name }; } /** The sprite the composition already keeps for this picture: one serves every item wearing it. */ @@ -74,8 +86,8 @@ function newCompositionSprite( } /** - * A texture, a drawn picture, a video or a clip of one laid on a track: an item wearing the sprite the - * composition keeps for that picture, made first when it has none yet, in one undo step. A clip is + * A texture, a drawn picture, a video or a part of one laid on a track: an item wearing the sprite the + * composition keeps for that picture, made first when it has none yet, in one undo step. A part is * the texture of the item's object. */ export function addPictureItem( @@ -90,7 +102,7 @@ export function addPictureItem( const steps = [ ...(kept ? [] : [new AddSpriteCommand(sprite)]), command, - ...(dropped.clip ? [new SetOverrideCommand(compId, objectId, 'texture', dropped.clip)] : []), + ...(dropped.part ? [new SetOverrideCommand(compId, objectId, 'texture', dropped.part)] : []), ]; return { command: steps.length === 1 ? command : oneStep('Add item', steps), objectId }; } diff --git a/src/core/model/assetParts.ts b/src/core/model/assetParts.ts new file mode 100644 index 00000000..bee6a92a --- /dev/null +++ b/src/core/model/assetParts.ts @@ -0,0 +1,28 @@ +import type { Asset } from './Asset'; +import { frameLabel } from './pixelart'; +import { findAsset, type Project } from './Project'; +import { clipsOf, hasClips } from './sound'; + +/** A named piece of an asset with an id of its own: a clip of a sound or video, a frame of a picture. */ +export interface AssetPart { + id: string; + name: string; +} + +/** + * What the asset panel lists under an asset (#425, #430): the clips of a sound or a video, the frames + * of a texture, and those of pixel art once it has more than one – the frames a texture field offers. + */ +export function partsOf(asset: Asset): AssetPart[] { + if (hasClips(asset)) return clipsOf(asset).map(({ id, name }) => ({ id, name })); + if (asset.type === 'texture') return asset.frames.map(({ id, name }) => ({ id, name })); + if (asset.type === 'pixelart' && asset.frames.length > 1) + return asset.frames.map((frame, index) => ({ id: frame.id, name: frameLabel(index) })); + return []; +} + +/** The asset `id` is a listed part of; undefined for an asset's own id or anything else. */ +export function partOwner(project: Project, id: string): Asset | undefined { + if (id === '' || findAsset(project, id)) return undefined; + return project.assets.find((asset) => partsOf(asset).some((part) => part.id === id)); +} diff --git a/src/core/model/sound.ts b/src/core/model/sound.ts index 1cc2c625..96113917 100644 --- a/src/core/model/sound.ts +++ b/src/core/model/sound.ts @@ -55,14 +55,6 @@ export function assetNamedBy(assets: readonly Asset[], id: string): Asset | unde ); } -/** The sound or video `id` is a clip of; undefined for an asset's own id or anything else. */ -export function clipOwner(project: Project, id: string): ClipOwner | undefined { - if (id === '' || findAsset(project, id)) return undefined; - return project.assets.find( - (a): a is ClipOwner => hasClips(a) && clipsOf<{ id: string }>(a).some((c) => c.id === id), - ); -} - /** * Gives the clips of a copy fresh ids, since a clip's id is named across the document, and notes * each old id with its new one in `ids` so the places that named a clip can follow. diff --git a/src/core/properties/references.ts b/src/core/properties/references.ts index 0f54cbaa..f1354234 100644 --- a/src/core/properties/references.ts +++ b/src/core/properties/references.ts @@ -1,7 +1,8 @@ import { type PropertyBag, type PropertyValue, textOf } from '../model/Properties'; import { findPixelFrame } from '../model/pixelart'; import { findAsset, type Project } from '../model/Project'; -import { clipOwner, findSound } from '../model/sound'; +import { partOwner } from '../model/assetParts'; +import { findSound } from '../model/sound'; import { findFrame } from '../model/texture'; import { findVideo } from '../model/video'; import { isKindWith } from './kinds'; @@ -28,13 +29,14 @@ export function referencedAssetId(project: Project, def: PropertyDef, value: Pro } /** - * What the label of a reference row links to: the asset it names, or the clip of a sound or a video - * itself (#425) – a clip has a row of its own in the asset panel and is picked in its asset's tab. + * What the label of a reference row links to: the asset it names, or the part of it named itself – a + * clip (#425), a frame (#430) – which has a row of its own in the asset panel and is picked in its + * asset's tab. */ export function referencedTarget(project: Project, def: PropertyDef, value: PropertyValue): string | null { const assetId = referencedAssetId(project, def, value); const id = textOf(value); - return assetId && clipOwner(project, id)?.id === assetId ? id : assetId; + return assetId && partOwner(project, id)?.id === assetId ? id : assetId; } export interface Reference { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index ec976026..3c027744 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -712,7 +712,7 @@ export class Editor implements PlayHost { }); state.on('open', () => { const s = state.selection; - if (s?.kind !== 'asset') return; + if (s?.kind !== 'asset' && s?.kind !== 'clip' && s?.kind !== 'frame') return; if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene'); else this.openDocument(`asset:${s.assetId}`); }); diff --git a/src/editor/actions.ts b/src/editor/actions.ts index c004b3b6..bbb00b0c 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -21,6 +21,8 @@ import { RemoveFrameCommand } from '../core/commands/frameCommands'; import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands'; +import type { Asset } from '../core/model/Asset'; +import { partOwner } from '../core/model/assetParts'; import type { AssetType } from '../core/model/AssetTypes'; import { curveUsages } from '../core/model/curve'; import { structTypeUsages } from '../core/model/structTypes'; @@ -32,7 +34,6 @@ import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../c import { ownerOf } from '../core/music/embedded'; import type { SceneObject } from '../core/model/SceneObject'; import { pixelArtRefIds } from '../core/model/pixelart'; -import { clipOwner } from '../core/model/sound'; import { textureRefIds } from '../core/model/texture'; import { videoRefIds } from '../core/model/video'; import { @@ -44,7 +45,7 @@ import { } from '../core/model/usages'; import { Dialog } from '../ui'; import { removeSongItems, removeTrack } from './song/songActions'; -import { type EditorState, graphSelection, objectSelection } from './state'; +import { type EditorState, graphSelection, objectSelection, type Selection } from './state'; /** * What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link @@ -55,11 +56,31 @@ export function activateAsset(state: EditorState, assetId: string): void { state.openAsset(assetId); } -/** The same for an id that may name a clip of a sound or a video (#425): its tab opens with the clip picked. */ -export function activateAssetOrClip(state: EditorState, id: string): void { - const owner = clipOwner(state.project, id); - if (owner) state.select({ kind: 'clip', assetId: owner.id, clipId: id }); - else activateAsset(state, id); +/** + * The same for an id that may name a part of an asset – a clip (#425), a frame (#430): its tab opens + * with the part picked, also when one click on its row has picked it already. + */ +export function activateAssetOrPart(state: EditorState, id: string): void { + const owner = partOwner(state.project, id); + if (!owner) { + activateAsset(state, id); + return; + } + if (owner.type === 'pixelart') state.showPixelFrame(owner.id, id); + state.openSelection(partSelection(owner, id)); +} + +/** One click on a part's row: a look at the inspector, its tab left as it is (#425). */ +export function glancePart(state: EditorState, owner: Asset, id: string): void { + if (owner.type === 'pixelart') state.showPixelFrame(owner.id, id); + state.glance(partSelection(owner, id)); +} + +/** A frame of pixel art is painted on rather than picked (#430), so it selects its asset. */ +function partSelection(owner: Asset, id: string): Selection { + if (owner.type === 'texture') return { kind: 'frame', assetId: owner.id, frameId: id }; + if (owner.type === 'pixelart') return { kind: 'asset', assetId: owner.id }; + return { kind: 'clip', assetId: owner.id, clipId: id }; } /** Groups the objects into a new container asset placed in their stead; selects the container. */ diff --git a/src/editor/agent/catalog/composition.ts b/src/editor/agent/catalog/composition.ts index 7067469a..d8230b5f 100644 --- a/src/editor/agent/catalog/composition.ts +++ b/src/editor/agent/catalog/composition.ts @@ -129,8 +129,9 @@ export const COMPOSITION_COMMANDS: readonly CatalogCommand[] = [ name: 'add_composition_item', description: 'Lays an asset on a track of a composition, from a frame. A placeable asset (a sprite, a container, a text, ...) ' + - 'becomes an object of the composition, living as long as the item; a texture, pixel art, vector art, a video or a ' + - 'clip of one gets a sprite of the composition to wear it; a sound, a sound clip, a pattern or a song becomes an ' + + 'becomes an object of the composition, living as long as the item; a texture, pixel art, vector art, a video, a ' + + 'clip of a video or a frame of a picture gets a sprite of the composition to wear it, the clip or frame as its ' + + 'texture; a sound, a sound clip, a pattern or a song becomes an ' + 'audio item. Without a length it is as long as its video or what it plays, else 3 s, and keeps following it. ' + "An item's object is moved and set with the commands of area objects, in = the composition. Items of a track may " + 'overlap. Animations go on a track with add_composition_clip.', diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 502a83bb..88eec999 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -1,7 +1,7 @@ import type { PropertyBag } from '../../core/model/Properties'; import type { FederatedPointerEvent } from 'pixi.js'; import { Label, Panel, pressCount, ScrollContainer, ui, type Widget } from '../../ui'; -import { activateAssetOrClip } from '../actions'; +import { activateAssetOrPart } from '../actions'; import { type EditorState, sameSelection, type Selection } from '../state'; import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection'; import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection'; @@ -66,12 +66,12 @@ export class Inspector extends Panel { } /** - * A link in a reference row (#221): a click pings the asset or clip (#425) in the asset panel, a - * double-click opens it. Pinging leaves the selection alone, so this panel keeps showing the object - * and both presses land on the same link. + * A link in a reference row (#221): a click pings the asset or a part of it (#425, #430) in the + * asset panel, a double-click opens it. Pinging leaves the selection alone, so this panel keeps + * showing the object and both presses land on the same link. */ private followReference(id: string, e: FederatedPointerEvent): void { - if (pressCount(e, `inspector-link:${id}`) === 2) activateAssetOrClip(this.state, id); + if (pressCount(e, `inspector-link:${id}`) === 2) activateAssetOrPart(this.state, id); else this.state.pingAsset(id); } diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 0875f4f2..f4ee22cf 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -18,7 +18,8 @@ import { generatesPictures } from '../../core/model/generated'; import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; -import { clipOwner, clipRefName, clipsOf, hasClips } from '../../core/model/sound'; +import { partOwner, partsOf } from '../../core/model/assetParts'; +import { clipRefName } from '../../core/model/sound'; import { listedAssets, ownerOf } from '../../core/music/embedded'; import { spriteFromTexture } from '../../core/properties/aspect'; import { assetDeviates } from '../../core/properties/deviation'; @@ -43,7 +44,7 @@ import { type AssetCategory, type AssetCategoryGroup, ASSET_GROUPS, groupAssets import { ASSET_ICON_SVG } from './assetIcons'; import { assetMenuCategories } from './assetMenu'; import { TOOLBAR_ICON_SVG } from './toolbarIcons'; -import { activateAssetOrClip, confirmDeleteAsset } from '../actions'; +import { activateAssetOrPart, confirmDeleteAsset, glancePart } from '../actions'; import { openReferenceItems } from '../objectMenu'; import { generalSettings, onGeneralSettings } from '../generalSettings'; import { scrollTween } from '../viewTween'; @@ -77,8 +78,16 @@ export class AssetPanel extends Panel { private readonly unfolded = new Set(); /** Categories folded away, for the session; every category starts unfolded, showing its groups. */ private readonly foldedCategories = new Set(); - /** Sounds and videos whose clips are shown under them (#425), for the session; all start folded. */ - private readonly unfoldedClips = new Set(); + /** + * Assets whose clips or frames are shown under them (#425, #430), for the session; all start + * folded. + */ + private readonly unfoldedParts = new Set(); + /** + * The frame of pixel art last picked here (#430). It is not selected – its asset is – so the list + * remembers which row to light up while that asset stays selected. + */ + private pickedPixelFrame: string | null = null; /** Ids of the rows as shown, headings included. */ private rows: string[] = []; /** @@ -114,7 +123,7 @@ export class AssetPanel extends Panel { new Menu(this.addMenuEntries(headingId), { title: ADD_MENU_TITLE }).open(x, y); this.list.onDragOut = (id) => this.startPlacementDrag(id); this.list.onToggle = (id, expand) => { - if (findAsset(state.project, id)) this.setClipsFolded(id, !expand); + if (findAsset(state.project, id)) this.setPartsFolded(id, !expand); else if (id.startsWith(CATEGORY)) this.setCategoryFolded(id.slice(CATEGORY.length) as AssetCategory, !expand); else this.setFolded(id.slice(HEADING.length) as AssetType, !expand); @@ -132,7 +141,8 @@ export class AssetPanel extends Panel { state.on('load', () => { this.unfolded.clear(); this.foldedCategories.clear(); - this.unfoldedClips.clear(); + this.unfoldedParts.clear(); + this.pickedPixelFrame = null; this.scrolledTo = null; this.refresh(); }); @@ -153,24 +163,27 @@ export class AssetPanel extends Panel { } /** - * Unfolds and scrolls to the pinged asset or clip (#425) and lets its row light up; the selection - * stays. What a song or a composition keeps to itself has no row, so its owner lights up (#355, - * #419). + * Unfolds and scrolls to the pinged asset or part (#425, #430) and lets its row light up; the + * selection stays. What a song or a composition keeps to itself has no row, so its owner lights up + * (#355, #419). */ private ping(): void { const pinged = this.state.pingedAssetId ?? ''; - const asset = findAsset(this.state.project, pinged) ?? clipOwner(this.state.project, pinged); + const asset = findAsset(this.state.project, pinged) ?? partOwner(this.state.project, pinged); if (!asset) return; const id = ownerOf(this.state.project, asset)?.id ?? pinged; this.reveal(id); this.list.ping(id); } - /** A row picked in the list: an asset, or a clip (#425), looked at in the inspector without its tab. */ + /** A row picked in the list: an asset or a part (#425, #430), looked at in the inspector, not its tab. */ private pick(id: string | null): void { - const owner = id ? clipOwner(this.state.project, id) : undefined; - if (owner && id) this.state.glance({ kind: 'clip', assetId: owner.id, clipId: id }); + const owner = id ? partOwner(this.state.project, id) : undefined; + this.pickedPixelFrame = owner?.type === 'pixelart' ? id : null; + if (owner && id) glancePart(this.state, owner, id); else this.state.select(id ? { kind: 'asset', assetId: id } : null); + // Its asset may have been selected already, so nothing else says the row changed. + this.syncSelection(); } /** The row lighting up after a ping and how brightly, or null (test hook). */ @@ -239,17 +252,17 @@ export class AssetPanel extends Panel { }); if (!expanded) continue; for (const asset of assets) { - const clips = hasClips(asset) ? clipsOf<{ id: string; name: string }>(asset) : []; - const clipsShown = this.unfoldedClips.has(asset.id); + const parts = partsOf(asset); + const partsShown = this.unfoldedParts.has(asset.id); items.push({ id: asset.id, label: asset.name, marks: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined, depth: 2, overridden: assetDeviates(project, asset), - expanded: clips.length > 0 ? clipsShown : undefined, + expanded: parts.length > 0 ? partsShown : undefined, }); - if (clipsShown) for (const clip of clips) items.push({ id: clip.id, label: clip.name, depth: 3 }); + if (partsShown) for (const part of parts) items.push({ id: part.id, label: part.name, depth: 3 }); } } } @@ -274,10 +287,10 @@ export class AssetPanel extends Panel { this.refresh(); } - /** Unlike a group, folding keeps a picked clip: it may be the one being worked on in its tab. */ - private setClipsFolded(assetId: string, fold: boolean): void { - if (fold) this.unfoldedClips.delete(assetId); - else this.unfoldedClips.add(assetId); + /** Unlike a group, folding keeps a picked part: it may be the one being worked on in its tab. */ + private setPartsFolded(assetId: string, fold: boolean): void { + if (fold) this.unfoldedParts.delete(assetId); + else this.unfoldedParts.add(assetId); this.refresh(); } @@ -299,7 +312,10 @@ export class AssetPanel extends Panel { private syncSelection(): void { const s = this.state.selection; - const id = s?.kind === 'asset' ? s.assetId : s?.kind === 'clip' ? s.clipId : null; + if (s?.kind !== 'asset' || partOwner(this.state.project, this.pickedPixelFrame ?? '')?.id !== s.assetId) + this.pickedPixelFrame = null; + const part = s?.kind === 'clip' ? s.clipId : s?.kind === 'frame' ? s.frameId : this.pickedPixelFrame; + const id = part ?? (s?.kind === 'asset' ? s.assetId : null); // A selection made anywhere else – a graph node, a picker, a fresh asset – has to be reachable // in the list: its category and group unfold, and with many assets the row is often below the fold. const type = id ? findAsset(this.state.project, id)?.type : undefined; @@ -307,8 +323,8 @@ export class AssetPanel extends Panel { this.setFolded(type, false); return; } - // Only a clip picked here unfolds its sound or video: one picked in its tab leaves the list alone. - if (s?.kind === 'clip' && !this.rows.includes(s.clipId)) { + // Only a part picked here unfolds its asset: one picked in its tab leaves the list alone. + if (part && !this.rows.includes(part)) { this.list.select(null, true); this.scrolledTo = null; return; @@ -453,11 +469,11 @@ export class AssetPanel extends Panel { } /** - * Double-click / Enter opens the asset's tab; a scene also becomes the active one, a clip (#425) is - * picked in its tab. Placing is a drag. + * Double-click / Enter opens the asset's tab; a scene also becomes the active one, a part (#425, + * #430) is picked in its tab. Placing is a drag. */ private activate(id: string): void { - activateAssetOrClip(this.state, id); + activateAssetOrPart(this.state, id); } /** @@ -470,14 +486,14 @@ export class AssetPanel extends Panel { } /** - * Unfolds the asset's group – and for a clip (#425) its sound or video – and scrolls to its row: + * Unfolds the asset's group – and for a part (#425, #430) its asset – and scrolls to its row: * before a ping, and as a user would before clicking it (test hooks). */ private reveal(id: string): void { - const owner = clipOwner(this.state.project, id); + const owner = partOwner(this.state.project, id); const type = findAsset(this.state.project, owner?.id ?? id)?.type; if (type && !this.shows(type)) this.setFolded(type, false); - if (owner && !this.unfoldedClips.has(owner.id)) this.setClipsFolded(owner.id, false); + if (owner && !this.unfoldedParts.has(owner.id)) this.setPartsFolded(owner.id, false); this.scrollTo(id); } @@ -501,13 +517,14 @@ export class AssetPanel extends Panel { * Hands the asset to the view under the pointer: a scene places it, a song lays out a pattern or a * sound (#334), a composition an animation as a clip (#417), a picture or video as an item (#419) * and a sound, pattern or song as an audio item (#418). A scene has nowhere to go. A clip of a sound - * or a video goes where its asset would, to a song or a composition (#425). + * or a video goes where its asset would, to a song or a composition (#425), a frame of a picture to + * a composition (#430). */ private startPlacementDrag(assetId: string): void { - const owner = clipOwner(this.state.project, assetId); + const owner = partOwner(this.state.project, assetId); if (owner) { - const clip = clipsOf<{ id: string; name: string }>(owner).find((c) => c.id === assetId)!; - startDrag({ kind: 'asset', id: assetId }, clipRefName(owner.name, clip.name)); + const part = partsOf(owner).find((p) => p.id === assetId)!; + startDrag({ kind: 'asset', id: assetId }, clipRefName(owner.name, part.name)); return; } const asset = findAsset(this.state.project, assetId); diff --git a/src/editor/pixelart/PixelArtCanvas.ts b/src/editor/pixelart/PixelArtCanvas.ts index 08bf75f1..9652df9a 100644 --- a/src/editor/pixelart/PixelArtCanvas.ts +++ b/src/editor/pixelart/PixelArtCanvas.ts @@ -128,6 +128,17 @@ export class PixelArtCanvas extends SheetCanvas { this.redraw(); }); state.on('project', () => this.redraw()); + state.on('pixelFrame', () => { + const shown = state.shownPixelFrame; + if (!shown) return; + if (shown.assetId === this.assetId) this.selectFrame(shown.frameId); + else + this.places.set(shown.assetId, { + layerId: '', + ...this.places.get(shown.assetId), + frameId: shown.frameId, + }); + }); state.on('load', () => { this.places.clear(); this.needsFit = true; diff --git a/src/editor/state.ts b/src/editor/state.ts index 7dc0ea2e..05c2b06d 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -242,6 +242,8 @@ export type EditorEvent = | 'open' /** An asset should light up where it is listed (`pingedAssetId`), without being selected. */ | 'ping' + /** A frame of pixel art should be painted on (`shownPixelFrame`). */ + | 'pixelFrame' | 'mode' | 'save' | 'notice' @@ -298,7 +300,9 @@ export class EditorState { * working rather than something the project remembers. */ tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' }; - /** The asset or clip (#425) the last `pingAsset` pointed at. */ + /** The frame of pixel art the last `showPixelFrame` asked for. */ + shownPixelFrame: { assetId: string; frameId: string } | null = null; + /** The asset or part of one (#425, #430) the last `pingAsset` pointed at. */ pingedAssetId: string | null = null; /** Set while `glance` selects: the selection stays where it is shown. */ glancing = false; @@ -581,10 +585,27 @@ export class EditorState { * there, and like `openValues` it works without knowing the `Editor`. */ openAsset(assetId: string): void { - this.select({ kind: 'asset', assetId }); + this.openSelection({ kind: 'asset', assetId }); + } + + /** + * The same for an asset or a part of one, a clip or a frame (#430). Asked for even when the selection + * is already this one, as after one click on its row: selecting it again would bring up nothing. + */ + openSelection(selection: Selection): void { + this.select(selection); this.emit('open'); } + /** + * Asks the Pixel art tab to paint on this frame of the asset (#430), now or once it shows the asset: + * a frame is not selected, as it has nothing of its own to show in the inspector. + */ + showPixelFrame(assetId: string, frameId: string): void { + this.shownPixelFrame = { assetId, frameId }; + this.emit('pixelFrame'); + } + /** * Asks the graph showing `assetId` to bring these nodes into its picture (#429): what the agent * just added far to one side is otherwise selected out of sight. diff --git a/tests/core/composition-sprites.test.ts b/tests/core/composition-sprites.test.ts index 31b5a24d..d71653bf 100644 --- a/tests/core/composition-sprites.test.ts +++ b/tests/core/composition-sprites.test.ts @@ -6,12 +6,14 @@ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, + CreateTextureCommand, CreateVideoCommand, DeleteAssetCommand, DuplicateAssetCommand, MakeProjectAssetCommand, } from '../../src/core/commands/assetCommands'; import { AddClipCommand } from '../../src/core/commands/clipCommands'; +import { AddFrameCommand } from '../../src/core/commands/frameCommands'; import type { Command } from '../../src/core/commands/Command'; import { pasteCompositionItems, removeFromComposition } from '../../src/core/commands/compositionCommands'; import { @@ -150,7 +152,7 @@ describe('a clip of a video laid on a track (#425)', () => { const film = withClip(); expect(droppedPicture(film.project, film.clipId)).toEqual({ picture: film.videoId, - clip: film.clipId, + part: film.clipId, name: 'Intro', }); expect(droppedLength(film.project, film.comp(), film.clipId)).toBe(120); @@ -185,6 +187,47 @@ describe('a clip of a video laid on a track (#425)', () => { }); }); +describe('a frame of a picture laid on a track (#430)', () => { + const withFrames = () => { + const film = makeFilm(); + const texture = new CreateTextureCommand('Sheet', { + file: `${'ab'.repeat(32)}.png`, + width: 64, + height: 32, + }); + const art = new CreateAssetCommand('pixelart', 'Art'); + film.run(texture); + film.run(art); + const frame = new AddFrameCommand(texture.asset.id, { x: 16, y: 0, w: 16, h: 16 }, 'Run'); + film.run(frame); + const pixel = findAssetOf(film.project, art.asset.id, 'pixelart')!; + pixel.frames.push({ id: 'second', cels: {} }); + return { ...film, textureId: texture.asset.id, frameId: frame.frameId, art: pixel }; + }; + + it('wears the sprite of its texture with the frame as the texture, 3 s long', () => { + const film = withFrames(); + expect(droppedPicture(film.project, film.frameId)).toEqual({ + picture: film.textureId, + part: film.frameId, + name: 'Sheet', + }); + expect(droppedLength(film.project, film.comp(), film.frameId)).toBe(180); + const whole = film.drop(0, film.filmId, film.textureId); + const cut = film.drop(200, film.filmId, film.frameId); + expect(film.sprites()).toHaveLength(1); + expect(film.wornBy(film.comp(), cut)).toBe(film.wornBy(film.comp(), whole)); + expect(film.comp().objects.find((o) => o.id === cut)!.overrides).toEqual({ texture: film.frameId }); + }); + + it('of pixel art wears the sprite of its first frame, and the first frame needs no override', () => { + const film = withFrames(); + const first = film.art.frames[0]!.id; + expect(droppedPicture(film.project, 'second')).toEqual({ picture: first, part: 'second', name: 'Art' }); + expect(droppedPicture(film.project, first)).toEqual({ picture: first, part: null, name: 'Art' }); + }); +}); + describe('a copied item', () => { it('pasted after a cut wears its sprite again', () => { const film = makeFilm(); diff --git a/tests/core/references.test.ts b/tests/core/references.test.ts index 5eafe8c4..295281e7 100644 --- a/tests/core/references.test.ts +++ b/tests/core/references.test.ts @@ -110,15 +110,24 @@ describe('referencedAssetId', () => { }); }); -describe('referencedTarget (#425)', () => { +describe('referencedTarget (#425, #430)', () => { it('is the clip itself for a clip of a video or a sound, the asset for anything else', () => { const f = withClips(); expect(referencedTarget(f.project, TEXTURE, f.videoClip)).toBe(f.videoClip); expect(referencedTarget(f.project, SOUND, f.soundClip)).toBe(f.soundClip); expect(referencedTarget(f.project, TEXTURE, f.videoId)).toBe(f.videoId); - expect(referencedTarget(f.project, TEXTURE, f.frameId)).toBe(f.textureId); expect(referencedTarget(f.project, SHADER, f.videoClip)).toBeNull(); }); + + it('is the frame itself for a frame of a texture, and for one of pixel art with more than one', () => { + const f = setup(); + expect(referencedTarget(f.project, TEXTURE, f.frameId)).toBe(f.frameId); + const only = f.art.frames[0]!.id; + expect(referencedTarget(f.project, TEXTURE, only)).toBe(f.art.id); + f.art.frames.push({ id: 'second', cels: {} }); + expect(referencedTarget(f.project, TEXTURE, 'second')).toBe('second'); + expect(referencedTarget(f.project, TEXTURE, only)).toBe(only); + }); }); describe('referencesOf', () => { diff --git a/tests/editor/asset-parts.test.ts b/tests/editor/asset-parts.test.ts new file mode 100644 index 00000000..4a1dad93 --- /dev/null +++ b/tests/editor/asset-parts.test.ts @@ -0,0 +1,108 @@ +/** + * The parts of an asset the asset panel lists under it (#425, #430) – clips, frames of a texture and + * of pixel art – and what a double-click on one does after the click before it has picked it. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + CreateSoundCommand, + CreateTextureCommand, +} from '../../src/core/commands/assetCommands'; +import { AddClipCommand } from '../../src/core/commands/clipCommands'; +import { AddFrameCommand } from '../../src/core/commands/frameCommands'; +import type { PixelArtAsset } from '../../src/core/model/Asset'; +import { partOwner, partsOf } from '../../src/core/model/assetParts'; +import { createEmptyProject, findAsset } from '../../src/core/model/Project'; +import { activateAssetOrPart, glancePart } from '../../src/editor/actions'; +import { EditorState } from '../../src/editor/state'; + +function setup() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const sound = new CreateSoundCommand('Kick', `${'ef'.repeat(32)}.ogg`); + const texture = new CreateTextureCommand('Sheet', { + file: `${'ab'.repeat(32)}.png`, + width: 64, + height: 32, + }); + const art = new CreateAssetCommand('pixelart', 'Art'); + for (const c of [sound, texture, art]) expect(state.execute(c)).toBe(true); + const clip = new AddClipCommand(sound.asset.id, { start: 0.1, end: 0.2 }); + const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 16, h: 16 }, 'Idle'); + for (const c of [clip, frame]) expect(state.execute(c)).toBe(true); + const events: string[] = []; + for (const event of ['selection', 'open', 'pixelFrame'] as const) state.on(event, () => events.push(event)); + const pixelArt = () => findAsset(state.project, art.asset.id) as PixelArtAsset; + return { + state, + events, + soundId: sound.asset.id, + textureId: texture.asset.id, + artId: art.asset.id, + clipId: clip.clipId, + frameId: frame.frameId, + pixelArt, + }; +} + +describe('the parts of an asset', () => { + it('are the clips of a sound, the frames of a texture, and those of pixel art from the second on', () => { + const f = setup(); + const art = f.pixelArt(); + expect(partsOf(findAsset(f.state.project, f.soundId)!)).toEqual([{ id: f.clipId, name: 'Clip 1' }]); + expect(partsOf(findAsset(f.state.project, f.textureId)!)).toEqual([{ id: f.frameId, name: 'Idle' }]); + expect(partsOf(art)).toEqual([]); + expect(partOwner(f.state.project, art.frames[0]!.id)).toBeUndefined(); + art.frames.push({ id: 'second', cels: {} }); + expect(partsOf(art)).toEqual([ + { id: art.frames[0]!.id, name: 'Frame 1' }, + { id: 'second', name: 'Frame 2' }, + ]); + }); + + it('name their asset, and an asset or a stranger names none', () => { + const f = setup(); + expect(partOwner(f.state.project, f.clipId)?.id).toBe(f.soundId); + expect(partOwner(f.state.project, f.frameId)?.id).toBe(f.textureId); + expect(partOwner(f.state.project, f.textureId)).toBeUndefined(); + expect(partOwner(f.state.project, '')).toBeUndefined(); + expect(partOwner(f.state.project, 'gone')).toBeUndefined(); + }); +}); + +describe('a double-click on a part (#430)', () => { + it('opens the tab of a clip the click before picked, which selecting it again would not', () => { + const f = setup(); + const owner = findAsset(f.state.project, f.soundId)!; + glancePart(f.state, owner, f.clipId); + expect(f.state.selection).toEqual({ kind: 'clip', assetId: f.soundId, clipId: f.clipId }); + f.events.length = 0; + activateAssetOrPart(f.state, f.clipId); + expect(f.events).toEqual(['open']); + }); + + it('picks a frame of a texture as the frame, and opens its tab', () => { + const f = setup(); + glancePart(f.state, findAsset(f.state.project, f.textureId)!, f.frameId); + expect(f.state.selection).toEqual({ kind: 'frame', assetId: f.textureId, frameId: f.frameId }); + f.events.length = 0; + activateAssetOrPart(f.state, f.frameId); + expect(f.events).toEqual(['open']); + }); + + it('opens pixel art at a frame of it: the asset is selected, the frame shown', () => { + const f = setup(); + f.pixelArt().frames.push({ id: 'second', cels: {} }); + activateAssetOrPart(f.state, 'second'); + expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.artId }); + expect(f.state.shownPixelFrame).toEqual({ assetId: f.artId, frameId: 'second' }); + expect(f.events).toEqual(['pixelFrame', 'selection', 'open']); + }); + + it('opens an asset as before', () => { + const f = setup(); + activateAssetOrPart(f.state, f.textureId); + expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.textureId }); + expect(f.events).toEqual(['selection', 'open']); + }); +}); diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs index 0d1067cf..fbd05c26 100644 --- a/tools/e2e/69-composition-tab.mjs +++ b/tools/e2e/69-composition-tab.mjs @@ -830,6 +830,69 @@ export async function compositionTab(t) { JSON.stringify({ refolded, textureLink, clipPing, clipOpened }), ); + // 69t (#430). A double-click on a clip's row opens its video, though its first press has picked the + // clip already. The frames of a texture, and of pixel art with more than one, have rows too: a + // double-click opens the texture with the frame picked, or the pixel art painting on that frame, and + // one click there switches the frame. A frame dragged onto a track is an item wearing the texture's + // sprite with the frame as its texture. + await mutate(` + const p = __gm.state.project; + p.assets.push({ id: 'ct69-sheet', type: 'texture', name: 'Sheet 69', props: {}, file: '', width: 64, height: 32, + frames: [{ id: 'ct69-f1', name: 'Run', x: 16, y: 0, w: 16, h: 16 }] }); + p.assets.push({ id: 'ct69-art', type: 'pixelart', name: 'Art 69', props: {}, width: 8, height: 8, + layers: [{ id: 'ct69-layer', name: 'Layer 1', opacity: 1 }], + frames: [{ id: 'ct69-p1', cels: {} }, { id: 'ct69-p2', cels: {} }] });`); + /** Double-clicks a part's row from the scene tab, its asset unfolded first as a user would. */ + const openRow = async (id) => { + await showTab('scene'); + await sleep(200); + const row = await json(`__gm.assetRow(${JSON.stringify(id)})`); + if (row) await t.doubleClick(row.x, row.y); + await sleep(300); + return { selection: await json('__gm.state.selection'), active: (await json('__gm.documents()')).active }; + }; + const clipRow = await openRow('ct69-vclip'); + const frameRow = await openRow('ct69-f1'); + const pixelRow = await openRow('ct69-p2'); + const paintedOn = await json('__gm.pixelArt.frameId()'); + const firstRow = await json(`__gm.assetRow('ct69-p1')`); + if (firstRow) await t.click(firstRow.x, firstRow.y); + await sleep(300); + const switched = { + frame: await json('__gm.pixelArt.frameId()'), + active: (await json('__gm.documents()')).active, + }; + await evaluate(`__gm.state.openAsset('${CUT}')`); + await sleep(300); + const frameDrop = await dragClip('ct69-f1', await json(`__gm.composition.laneAt('ct69-t1', 100)`)); + const framed = await json(`(() => { + const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); + const item = c.items.find((i) => i.start === 100); + const object = c.objects.find((o) => o.id === item?.id); + const sprite = object && __gm.state.project.assets.find((a) => a.id === object.assetId); + return { overrides: object?.overrides ?? null, texture: sprite?.props.texture ?? null }; + })()`); + check( + 'a double-click on a clip or frame row opens its asset with it picked, and a frame lands on a track', + clipRow.selection?.kind === 'clip' && + clipRow.selection.clipId === 'ct69-vclip' && + clipRow.active === `asset:${VIDEO}` && + frameRow.selection?.kind === 'frame' && + frameRow.selection.frameId === 'ct69-f1' && + frameRow.active === 'asset:ct69-sheet' && + pixelRow.selection?.kind === 'asset' && + pixelRow.selection.assetId === 'ct69-art' && + pixelRow.active === 'asset:ct69-art' && + paintedOn === 'ct69-p2' && + switched.frame === 'ct69-p1' && + switched.active === 'asset:ct69-art' && + frameDrop?.what === 'item' && + frameDrop.rejection === '' && + framed.overrides?.texture === 'ct69-f1' && + framed.texture === 'ct69-sheet', + JSON.stringify({ clipRow, frameRow, pixelRow, paintedOn, switched, frameDrop, framed }), + ); + await showTab('scene'); await mutate(` const p = __gm.state.project;