diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 667973a9..f287c824 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -385,6 +385,8 @@ export function uiHooks({ app, state, editor }: HookContext) { assetRowColor: (id: string) => assets.rowColor(id), /** The asset panel's groups in order; a heading's row is `assetRow('group:')`. */ assetGroups: () => assets.groups(), + /** The categories above the groups; a heading's row is `assetRow('category:')`. */ + assetCategories: () => assets.categories(), assetGroupIcon: (type: AssetType) => assets.groupIcon(type), /** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */ assetsRect: () => rectOf(assets), diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 2d18a61d..3613970f 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -35,7 +35,7 @@ import { startDrag, UI_ICON_SVG, } from '../../ui'; -import { ASSET_GROUP_LABELS, groupAssets } from './assetGroups'; +import { type AssetCategory, ASSET_GROUPS, groupAssets } from './assetGroups'; import { ASSET_ICON_SVG } from './assetIcons'; import { assetMenuGroups } from './assetMenu'; import { TOOLBAR_ICON_SVG } from './toolbarIcons'; @@ -49,10 +49,13 @@ import { openImagePicker } from '../texture/ImagePicker'; /** Row ids of the group headings are this plus the type; an asset id never starts with it. */ const HEADING = 'group:'; const headingId = (type: AssetType): string => HEADING + type; +/** The same for the category headings above them. */ +const CATEGORY = 'category:'; +const categoryId = (category: AssetCategory): string => CATEGORY + category; /** - * Lists all assets of the project, grouped by type under a folding heading (#176); creates and - * deletes them. Placing is a drag into a view. + * Lists all assets of the project, grouped by category and in it by type, each under a folding + * heading (#176); creates and deletes them. Placing is a drag into a view. * * A row's name turns gold when the asset carries a property the inspector marks – the same rule, * so the list cannot promise a deviation the inspector then fails to show. @@ -62,6 +65,8 @@ export class AssetPanel extends Panel { private readonly scroll = new ScrollContainer(); /** Types whose group is unfolded, for the session; every group starts folded. */ private readonly unfolded = new Set(); + /** Categories folded away, for the session; every category starts unfolded, showing its groups. */ + private readonly foldedCategories = new Set(); /** Ids of the rows as shown, headings included. */ private rows: string[] = []; @@ -87,7 +92,11 @@ export class AssetPanel extends Panel { this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); this.list.onListMenu = (x, y) => new Menu(this.addMenuEntries()).open(x, y); this.list.onDragOut = (id) => this.startPlacementDrag(id); - this.list.onToggle = (id, expand) => this.setFolded(id.slice(HEADING.length) as AssetType, !expand); + this.list.onToggle = (id, expand) => { + if (id.startsWith(CATEGORY)) + this.setCategoryFolded(id.slice(CATEGORY.length) as AssetCategory, !expand); + else this.setFolded(id.slice(HEADING.length) as AssetType, !expand); + }; this.scroll.setContent(this.list); this.content.add(this.scroll, 1); @@ -95,6 +104,7 @@ export class AssetPanel extends Panel { // A different project brings different assets – start folded again. state.on('load', () => { this.unfolded.clear(); + this.foldedCategories.clear(); this.refresh(); }); state.on('selection', () => this.syncSelection()); @@ -143,25 +153,37 @@ export class AssetPanel extends Panel { private refresh(): void { const project = this.state.project; const items: ListItem[] = []; - for (const { type, assets } of groupAssets(project.assets)) { - const expanded = this.unfolded.has(type); + for (const { category, groups } of groupAssets(project.assets)) { + const open = !this.foldedCategories.has(category); items.push({ - id: headingId(type), - label: ASSET_GROUP_LABELS[type], - hint: String(assets.length), - icon: ASSET_ICON_SVG[type], + id: categoryId(category), + label: category, + hint: String(groups.reduce((sum, group) => sum + group.assets.length, 0)), heading: true, - expanded, + expanded: open, }); - if (!expanded) continue; - for (const asset of assets) + if (!open) continue; + for (const { type, assets } of groups) { + const expanded = this.unfolded.has(type); items.push({ - id: asset.id, - label: asset.name, - marks: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined, + id: headingId(type), + label: ASSET_GROUPS[type].name, + hint: String(assets.length), + icon: ASSET_ICON_SVG[type], depth: 1, - overridden: assetDeviates(project, asset), + heading: true, + expanded, }); + if (!expanded) continue; + for (const asset of assets) + items.push({ + id: asset.id, + label: asset.name, + marks: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined, + depth: 2, + overridden: assetDeviates(project, asset), + }); + } } this.rows = items.map((item) => item.id); this.list.setItems(items); @@ -177,17 +199,36 @@ export class AssetPanel extends Panel { if (s?.kind === 'asset' && findAsset(this.state.project, s.assetId)?.type === type) this.state.select(null); this.unfolded.delete(type); - } else this.unfolded.add(type); + } else { + this.unfolded.add(type); + this.foldedCategories.delete(ASSET_GROUPS[type].category); + } this.refresh(); } + private setCategoryFolded(category: AssetCategory, fold: boolean): void { + if (fold) { + // Like a group: folding away the selected asset lets go of it. + const s = this.state.selection; + const type = s?.kind === 'asset' ? findAsset(this.state.project, s.assetId)?.type : undefined; + if (type && ASSET_GROUPS[type].category === category) this.state.select(null); + this.foldedCategories.add(category); + } else this.foldedCategories.delete(category); + this.refresh(); + } + + /** Whether the asset's row is shown: its category and its group are both unfolded. */ + private shows(type: AssetType): boolean { + return this.unfolded.has(type) && !this.foldedCategories.has(ASSET_GROUPS[type].category); + } + private syncSelection(): void { const s = this.state.selection; const id = 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 group unfolds, and with many assets the row is often below the fold. + // 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; - if (type && !this.unfolded.has(type)) { + if (type && !this.shows(type)) { this.setFolded(type, false); return; } @@ -201,12 +242,29 @@ export class AssetPanel extends Panel { } /** The groups as shown, in order (test hook). */ - groups(): { type: AssetType; label: string; expanded: boolean; ids: string[] }[] { - return groupAssets(this.state.project.assets).map(({ type, assets }) => ({ - type, - label: ASSET_GROUP_LABELS[type], - expanded: this.unfolded.has(type), - ids: assets.map((a) => a.id), + groups(): { + category: AssetCategory; + type: AssetType; + label: string; + expanded: boolean; + ids: string[]; + }[] { + return groupAssets(this.state.project.assets).flatMap(({ category, groups }) => + groups.map(({ type, assets }) => ({ + category, + type, + label: ASSET_GROUPS[type].name, + expanded: this.unfolded.has(type), + ids: assets.map((a) => a.id), + })), + ); + } + + /** The categories in order, and whether each is unfolded (test hook). */ + categories(): { category: AssetCategory; expanded: boolean }[] { + return groupAssets(this.state.project.assets).map(({ category }) => ({ + category, + expanded: !this.foldedCategories.has(category), })); } @@ -261,7 +319,9 @@ export class AssetPanel extends Panel { */ private createPixelArt(): void { const project = this.state.project; - const existing = groupAssets(project.assets).find((group) => group.type === 'palette')?.assets[0]; + const existing = groupAssets(project.assets) + .flatMap((category) => category.groups) + .find((group) => group.type === 'palette')?.assets[0]; const palette = existing ? null : new CreateAssetCommand('palette', nextAssetName(project, ASSET_TYPE_LABELS.palette)); @@ -313,7 +373,7 @@ export class AssetPanel extends Panel { /** Unfolds the asset's group and scrolls to its row – before a ping, and as a user would before clicking it (test hooks). */ private reveal(assetId: string): void { const type = findAsset(this.state.project, assetId)?.type; - if (type && !this.unfolded.has(type)) this.setFolded(type, false); + if (type && !this.shows(type)) this.setFolded(type, false); this.scrollTo(assetId); } diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index 49a2a1f0..a8533446 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -1,45 +1,46 @@ import type { Asset } from '../../core/model/Asset'; import type { AssetType } from '../../core/model/AssetTypes'; +/** The top level of the asset panel, in the order it is shown. */ +export const ASSET_CATEGORIES = ['Basics', 'Visuals', 'Logics', 'Physics', 'Audio'] as const; +export type AssetCategory = (typeof ASSET_CATEGORIES)[number]; + /** - * Group headings of the asset panel. Plural, because a heading names what is under it – and so no - * heading reads like the `+` menu entry that creates one of its kind. + * Where a type sits in the asset panel: its group heading, under the heading of its category. + * The names read like the `+` menu entries; a test that clicks an entry by its text finds the one + * in the open menu first (`textProbe`). */ -export const ASSET_GROUP_LABELS: Record = { - scene: 'Scenes', - camera: 'Cameras', - sprite: 'Sprites', - text: 'Texts', - behavior: 'Behaviors', - variables: 'Variables', - struct: 'Structs', - collider: 'Colliders', - rigidbody: 'Rigidbodies', - joint: 'Joints', - container: 'Containers', - clip: 'Clip areas', - texture: 'Textures', - // Not "Vector art": that is the `+` menu entry, and a heading of the same words would take the - // click meant for the menu (see the test below the labels). - vectorart: 'Vector drawings', - // Not "Pixel art" either, for the same reason. - pixelart: 'Pixel drawings', - palette: 'Palettes', - animation: 'Animations', - curve: 'Curves', - tile: 'Tiles', - tilemap: 'Tilemaps', - statemachine: 'State machines', - shader: 'Shaders', - shaderfunction: 'Shader functions', - font: 'Fonts', - sound: 'Sounds', - // Not "Sound FX": that is the `+` menu entry (see Vector art). - soundfx: 'Sound effects', - instrument: 'Instruments', - pattern: 'Patterns', - song: 'Songs', - particles: 'Particles', +export const ASSET_GROUPS: Record = { + scene: { name: 'Scene', category: 'Basics' }, + camera: { name: 'Camera', category: 'Basics' }, + container: { name: 'Container', category: 'Basics' }, + curve: { name: 'Curve', category: 'Basics' }, + sprite: { name: 'Sprite', category: 'Visuals' }, + text: { name: 'Text', category: 'Visuals' }, + clip: { name: 'Clip area', category: 'Visuals' }, + texture: { name: 'Texture', category: 'Visuals' }, + vectorart: { name: 'Vector art', category: 'Visuals' }, + pixelart: { name: 'Pixel art', category: 'Visuals' }, + palette: { name: 'Palette', category: 'Visuals' }, + animation: { name: 'Animation', category: 'Visuals' }, + tile: { name: 'Tile', category: 'Visuals' }, + tilemap: { name: 'Tilemap', category: 'Visuals' }, + shader: { name: 'Shader', category: 'Visuals' }, + shaderfunction: { name: 'Shader function', category: 'Visuals' }, + font: { name: 'Font', category: 'Visuals' }, + particles: { name: 'Particle', category: 'Visuals' }, + behavior: { name: 'Behavior', category: 'Logics' }, + variables: { name: 'Variables', category: 'Logics' }, + struct: { name: 'Struct', category: 'Logics' }, + statemachine: { name: 'State machine', category: 'Logics' }, + collider: { name: 'Collider', category: 'Physics' }, + rigidbody: { name: 'Rigidbody', category: 'Physics' }, + joint: { name: 'Joint', category: 'Physics' }, + sound: { name: 'Sound', category: 'Audio' }, + soundfx: { name: 'Sound FX', category: 'Audio' }, + instrument: { name: 'Instrument', category: 'Audio' }, + pattern: { name: 'Pattern', category: 'Audio' }, + song: { name: 'Song', category: 'Audio' }, }; export interface AssetGroup { @@ -47,21 +48,31 @@ export interface AssetGroup { assets: Asset[]; } +export interface AssetCategoryGroup { + category: AssetCategory; + groups: AssetGroup[]; +} + /** Case aside and digits as numbers, so `Level 2` comes before `Level 10`. */ const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }); /** - * The asset panel's rows (#176): one group per type that has assets, groups and the assets in - * each alphabetical. Equal names keep the project's order, so a row does not jump on a rebuild. + * The asset panel's rows: one category per kind of work, in `ASSET_CATEGORIES` order, and in it one + * group per type (#176). Only what has assets is there. Groups and the assets in them are + * alphabetical; equal names keep the project's order, so a row does not jump on a rebuild. */ -export function groupAssets(assets: readonly Asset[]): AssetGroup[] { +export function groupAssets(assets: readonly Asset[]): AssetCategoryGroup[] { const byType = new Map(); for (const asset of assets) { const list = byType.get(asset.type); if (list) list.push(asset); else byType.set(asset.type, [asset]); } - return [...byType] + const groups = [...byType] .map(([type, list]) => ({ type, assets: list.sort((a, b) => collator.compare(a.name, b.name)) })) - .sort((a, b) => collator.compare(ASSET_GROUP_LABELS[a.type], ASSET_GROUP_LABELS[b.type])); + .sort((a, b) => collator.compare(ASSET_GROUPS[a.type].name, ASSET_GROUPS[b.type].name)); + return ASSET_CATEGORIES.map((category) => ({ + category, + groups: groups.filter((group) => ASSET_GROUPS[group.type].category === category), + })).filter((entry) => entry.groups.length > 0); } diff --git a/src/editor/textProbe.ts b/src/editor/textProbe.ts index cbb97d41..51d41714 100644 --- a/src/editor/textProbe.ts +++ b/src/editor/textProbe.ts @@ -1,5 +1,5 @@ import { type Application, type Container, Graphics, Rectangle, Text } from 'pixi.js'; -import { Button, Widget } from '../ui'; +import { Button, ui, Widget } from '../ui'; import { pictureText } from '../viewport/textPicture'; /** The global box of a widget: where it starts, and how large it is. */ @@ -109,17 +109,23 @@ export function textProbe(app: Application): { }); return hits; }; - /** Centres of every visible text equal to `text`, in drawing order (the topmost last). */ - const locateAll = (text: string): { x: number; y: number }[] => { + const hitsIn = (root: Container, text: string): { x: number; y: number }[] => { const hits: { x: number; y: number }[] = []; - eachText(app.stage, (shown, point) => { + eachText(root, (shown, point) => { if (shown === text) hits.push(point); }); return hits; }; + /** Centres of every visible text equal to `text`, in drawing order (the topmost last). */ + const locateAll = (text: string): { x: number; y: number }[] => hitsIn(app.stage, text); + /** + * The first such text – but one in an open popup before any beneath it, since a click below an + * open menu only closes the menu. The asset panel's headings read like the `+` menu's entries. + */ const locate = (text: string, last = false): { x: number; y: number } | null => { + const onTop = last ? undefined : hitsIn(ui().overlay, text)[0]; const hits = locateAll(text); - return (last ? hits.at(-1) : hits[0]) ?? null; + return onTop ?? (last ? hits.at(-1) : hits[0]) ?? null; }; return { eachText, locateAll, locate, locateIcons }; } diff --git a/tests/editor/asset-groups.test.ts b/tests/editor/asset-groups.test.ts index 6a1365c3..dbbc3a16 100644 --- a/tests/editor/asset-groups.test.ts +++ b/tests/editor/asset-groups.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from 'vitest'; import type { Asset } from '../../src/core/model/Asset'; -import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType } from '../../src/core/model/AssetTypes'; -import { ASSET_GROUP_LABELS, groupAssets } from '../../src/editor/panels/assetGroups'; +import { ASSET_TYPES, type AssetType } from '../../src/core/model/AssetTypes'; +import { ASSET_CATEGORIES, ASSET_GROUPS, groupAssets } from '../../src/editor/panels/assetGroups'; let serial = 0; const asset = (type: AssetType, name: string): Asset => ({ id: `a${++serial}`, type, name, props: {} }) as unknown as Asset; -const shape = (assets: Asset[]) => groupAssets(assets).map((g) => [g.type, g.assets.map((a) => a.name)]); +const groupsOf = (assets: readonly Asset[]) => groupAssets(assets).flatMap((c) => c.groups); +const shape = (assets: Asset[]) => groupsOf(assets).map((g) => [g.type, g.assets.map((a) => a.name)]); describe('groupAssets (#176)', () => { it('puts every asset into the group of its type, and no empty group', () => { @@ -18,13 +19,36 @@ describe('groupAssets (#176)', () => { ]); }); - it('sorts the groups by their heading, not by type or declaration order', () => { + it('puts the groups under their category, categories in their fixed order and none empty', () => { + const assets = [ + asset('song', 'Tune'), + asset('behavior', 'Mover'), + asset('scene', 'Level'), + asset('sprite', 'Hero'), + ]; + expect(groupAssets(assets).map((c) => [c.category, c.groups.map((g) => g.type)])).toEqual([ + ['Basics', ['scene']], + ['Visuals', ['sprite']], + ['Logics', ['behavior']], + ['Audio', ['song']], + ]); + }); + + it('sorts the groups in a category by their heading, not by type or declaration order', () => { const types = [...ASSET_TYPES].reverse(); - const headings = groupAssets(types.map((t) => asset(t, 'x'))).map((g) => ASSET_GROUP_LABELS[g.type]); - expect(headings).toEqual([...headings].sort((a, b) => a.localeCompare(b))); - expect(headings).toHaveLength(ASSET_TYPES.length); - // By heading: "State machines" sorts under S, the type key `statemachine` would not tell. - expect(headings.indexOf('Sprites')).toBeLessThan(headings.indexOf('State machines')); + const categories = groupAssets(types.map((t) => asset(t, 'x'))); + expect(categories.map((c) => c.category)).toEqual([...ASSET_CATEGORIES]); + for (const { groups } of categories) { + const headings = groups.map((g) => ASSET_GROUPS[g.type].name); + expect(headings).toEqual([...headings].sort((a, b) => a.localeCompare(b))); + } + expect(categories.flatMap((c) => c.groups)).toHaveLength(ASSET_TYPES.length); + // By heading: "State machine" sorts under S, the type key `statemachine` would not tell. + const logic = categories + .find((c) => c.category === 'Logics')! + .groups.map((g) => ASSET_GROUPS[g.type].name); + expect(logic.indexOf('Behavior')).toBeLessThan(logic.indexOf('State machine')); + expect(logic.indexOf('State machine')).toBeLessThan(logic.indexOf('Struct')); }); it('sorts the assets in a group by name, ignoring case and reading numbers as numbers', () => { @@ -35,8 +59,8 @@ describe('groupAssets (#176)', () => { it('keeps the project order among equal names', () => { const first = asset('sprite', 'Coin'); const second = asset('sprite', 'coin'); - expect(groupAssets([first, second])[0]!.assets).toEqual([first, second]); - expect(groupAssets([second, first])[0]!.assets).toEqual([second, first]); + expect(groupsOf([first, second])[0]!.assets).toEqual([first, second]); + expect(groupsOf([second, first])[0]!.assets).toEqual([second, first]); }); it('leaves the project list alone', () => { @@ -44,12 +68,4 @@ describe('groupAssets (#176)', () => { groupAssets(assets); expect(assets.map((a) => a.name)).toEqual(['B', 'A']); }); - - it('names a heading like a + menu entry only where the type is plural anyway', () => { - // A click by text takes the first hit, and the panel is drawn before any menu. - const entries = new Set(Object.values(ASSET_TYPE_LABELS)); - // Variables and Particles are already plural; nothing else may collide. - const clashes = Object.values(ASSET_GROUP_LABELS).filter((label) => entries.has(label)); - expect(clashes.sort()).toEqual(['Particles', 'Variables']); - }); }); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 6eea6d15..10133401 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -348,29 +348,39 @@ export async function assets(t) { `${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`, ); - // 4g. The list is grouped by type (#176): a heading per type, groups and the assets in them - // alphabetical, each heading with an icon of its own. A freshly opened project shows every - // group folded – reloaded here, since the modules before have unfolded groups by selecting. + // 4g. The list is grouped by category and in it by type (#176): a heading per category, under it + // a heading per type, groups and the assets in them alphabetical, each type heading with an + // icon of its own. A freshly opened project shows every category open and every group folded + // – reloaded here, since the modules before have unfolded groups by selecting. await evaluate('__gm.state.loadProject(__gm.state.project, __gm.state.revision)'); await sleep(100); const groups = await json('__gm.assetGroups()'); + const categories = await json('__gm.assetCategories()'); const nameOf = Object.fromEntries( (await json('__gm.state.project.assets.map((a) => [a.id, a.name])')).map(([id, name]) => [id, name]), ); const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }); const sorted = (list) => list.every((v, i) => i === 0 || collator.compare(list[i - 1], v) <= 0); + const ORDER = ['Basics', 'Visuals', 'Logics', 'Physics', 'Audio']; check( - 'the assets panel groups by type, groups and assets sorted by name', + 'the assets panel groups by category and type, groups and assets sorted by name', groups.length >= 4 && - sorted(groups.map((g) => g.label)) && + categories.length >= 2 && + sorted(categories.map((c) => ORDER.indexOf(c.category)).map(String)) && + categories.every((c) => sorted(groups.filter((g) => g.category === c.category).map((g) => g.label))) && groups.every((g) => sorted(g.ids.map((id) => nameOf[id]))) && groups.flatMap((g) => g.ids).length === Object.keys(nameOf).length, - JSON.stringify(groups.map((g) => [g.label, g.ids.map((id) => nameOf[id])])), + JSON.stringify({ + categories, + groups: groups.map((g) => [g.category, g.label, g.ids.map((id) => nameOf[id])]), + }), ); check( - 'a loaded project shows every group folded', - groups.every((g) => !g.expanded) && (await json("__gm.assetRowShown('crate')")) === null, - JSON.stringify(groups.map((g) => [g.label, g.expanded])), + 'a loaded project shows every category open and every group folded', + categories.every((c) => c.expanded) && + groups.every((g) => !g.expanded) && + (await json("__gm.assetRowShown('crate')")) === null, + JSON.stringify({ categories, groups: groups.map((g) => [g.label, g.expanded]) }), ); // Read in the drawn picture: every icon inks its 16 units in the muted colour, and no two alike. // Every pixel against the row's own background (`+`), since a 1.5 unit outline on half pixels is @@ -411,7 +421,7 @@ export async function assets(t) { Boolean(unfolded.heading) && unfolded.heading.y < unfolded.crate.y && unfolded.selected === 'cam' && - (await has('Sprites')), + (await has('Sprite')), JSON.stringify(unfolded), ); await shot('15c-asset-groups'); @@ -442,6 +452,29 @@ export async function assets(t) { released.expanded === false && released.crate === null && released.selected === undefined, JSON.stringify(released), ); + // A category folds the same way: its type headings go, and selecting an asset in it opens it again. + const visual = await json("__gm.assetRowShown('category:Visuals')"); + await click(visual.x, visual.y); + const visualFolded = { + category: (await json('__gm.assetCategories()')).find((c) => c.category === 'Visuals'), + sprites: await json("__gm.assetRowShown('group:sprite')"), + }; + await select({ kind: 'asset', assetId: 'crate' }); + const visualAgain = { + category: (await json('__gm.assetCategories()')).find((c) => c.category === 'Visuals'), + crate: await json("__gm.assetRowShown('crate')"), + }; + check( + 'a click on a category heading folds its groups away, selecting an asset in it unfolds it', + visualFolded.category?.expanded === false && + visualFolded.sprites === null && + visualAgain.category?.expanded === true && + Boolean(visualAgain.crate), + JSON.stringify({ visualFolded, visualAgain }), + ); + await select(null); + const spritesAgain = await json("__gm.assetRow('group:sprite')"); + await click(spritesAgain.x, spritesAgain.y); // 4h. The same icons elsewhere: in front of every type in the `+` menu, and at the right edge of // a scene object's row, where its type used to be spelled out. @@ -518,12 +551,16 @@ async function addMenuOnRightClick(t) { ); await shot('11c-add-menu-on-right-click'); const before = (await json('__gm.state.project.assets')).length; + // The heading of the behaviors reads like the entry and is drawn first: the entry still wins. + const behaviorWords = (await json("__gm.locateAll('Behavior')")).length; await clickText('Behavior'); const fromMenu = await json('__gm.state.project.assets.at(-1)'); check( - 'an entry of that menu creates the asset like the + button does', - (await json('__gm.state.project.assets')).length === before + 1 && fromMenu.type === 'behavior', - JSON.stringify(fromMenu && { type: fromMenu.type, name: fromMenu.name }), + 'an entry of that menu creates the asset like the + button does, even under a heading of the same word', + (await json('__gm.state.project.assets')).length === before + 1 && + fromMenu.type === 'behavior' && + behaviorWords >= 2, + JSON.stringify({ fromMenu: fromMenu && { type: fromMenu.type, name: fromMenu.name }, behaviorWords }), ); await undo(); await showTab('scene');