Group the asset panel by category, then by type
The asset panel listed one heading per type, 30 of them side by side. Now the types sit under five categories, and each type heading reads like its entry in the + menu. - ASSET_GROUPS replaces ASSET_GROUP_LABELS and gives every type a name and a category: Basics, Visuals, Logics, Physics or Audio. - The categories keep that fixed order. The groups in a category and the assets in a group stay sorted by name. - A category heading folds like a group heading. A project opens with every category open and every group folded, as before. - Selecting an asset unfolds its category and its group. Folding the category of the selected asset lets go of the selection. - The headings are singular now (Scene, Sprite, Sound FX). The old plural names only kept them apart from the + menu entries. - locate looks in the popup layer first. A click by text meant for a menu entry hit the heading of the same name, since the panel is drawn first. Below an open menu a click only closes the menu anyway. - New test hook __gm.assetCategories(). Tests: - Unit asset-groups: 6 green, counter-check (empty categories kept) 1 red - e2e 04: ALL PASSED, 63 checks (1 new). Counter-check with locate taking the first hit again: 2 red, the click on Behavior created no asset - Gate: CHECK EXIT 0, 261 files, 4040 tests - The category names got their final plural form after these runs. The unit test ran again on them (6 green), e2e 04 did not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8154569405
commit
5e09ba9a50
6 files changed
+239
-107
No files matched your search
@@ -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:<type>')`. */
|
||||
assetGroups: () => assets.groups(),
|
||||
/** The categories above the groups; a heading's row is `assetRow('category:<name>')`. */
|
||||
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),
|
||||
|
||||
@@ -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<AssetType>();
|
||||
/** Categories folded away, for the session; every category starts unfolded, showing its groups. */
|
||||
private readonly foldedCategories = new Set<AssetCategory>();
|
||||
/** 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AssetType, string> = {
|
||||
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<AssetType, { name: string; category: AssetCategory }> = {
|
||||
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<AssetType, Asset[]>();
|
||||
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);
|
||||
}
|
||||
+11
-5
@@ -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 };
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
+50
-13
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user