Show empty asset groups on request, and add by the heading (#361)
The asset panel only listed the categories and types that hold assets. To make the first asset of a kind you had to know its category in the `+` menu. A switch now lists all of them, and a right-click on a heading offers just what sits under it. - General settings has "Show empty asset categories and types", off by default and kept in this browser. Applying it refreshes the panel at once through a new `onGeneralSettings` listener. - `groupAssets(assets, empty)` gives every category and type when asked. The empty ones stand faint with a count of 0, and an empty type has no chevron, since there is nothing to unfold. - `ListItem.faint` draws a row's label, hint and icons at half opacity, like a disabled widget. - A right-click on a category heading offers the types of that category without a submenu, on a type heading only that type. The empty space below the rows still opens the whole `+` menu. `ListView.onListMenu` passes the heading's id for this. - Every menu of new assets says "Create asset" above its entries, so a click on a type reads as making one. `MenuOptions.title` draws that muted line. It is not an entry and matches no search. Tests: - Unit asset-groups and view-tween: 23 green, 2 new. Counter-test with the empty flag ignored: the new groups test fails. - e2e 04 (melektaus-atx): ALL PASSED, 67 checks, 4 new and 1 rewritten. Counter-test with the heading id dropped: the 3 menu checks fail. The title is checked on the `+` button and on a type heading (2 checks extended), the rerun passed with 67 checks. - Gate (melektaus-atx): CHECK EXIT 0, 272 files, 4212 tests. It ran before the menu title. Afterwards only the menu and asset-groups units, typecheck and lint ran, all clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf96442835
commit
5cb7ef7bed
11 files changed
+269
-38
No files matched your search
@@ -36,17 +36,21 @@ import {
|
||||
startDrag,
|
||||
UI_ICON_SVG,
|
||||
} from '../../ui';
|
||||
import { type AssetCategory, ASSET_GROUPS, groupAssets } from './assetGroups';
|
||||
import { type AssetCategory, type AssetCategoryGroup, ASSET_GROUPS, groupAssets } from './assetGroups';
|
||||
import { ASSET_ICON_SVG } from './assetIcons';
|
||||
import { assetMenuCategories } from './assetMenu';
|
||||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
||||
import { activateAsset, confirmDeleteAsset } from '../actions';
|
||||
import { openReferenceItems } from '../objectMenu';
|
||||
import { generalSettings, onGeneralSettings } from '../generalSettings';
|
||||
import type { EditorState } from '../state';
|
||||
import { openFontPicker } from '../font/FontPicker';
|
||||
import { openSoundPicker } from '../sound/SoundPicker';
|
||||
import { openImagePicker } from '../texture/ImagePicker';
|
||||
|
||||
/** Above every menu of new assets, so a click on a type reads as making one (#361). */
|
||||
const ADD_MENU_TITLE = 'Create asset';
|
||||
|
||||
/** 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;
|
||||
@@ -70,6 +74,7 @@ export class AssetPanel extends Panel {
|
||||
private readonly foldedCategories = new Set<AssetCategory>();
|
||||
/** Ids of the rows as shown, headings included. */
|
||||
private rows: string[] = [];
|
||||
private readonly stopListening: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
@@ -84,14 +89,15 @@ export class AssetPanel extends Panel {
|
||||
this.minWidth = 160;
|
||||
|
||||
const add = new Button('+', { variant: 'ghost', width: 28, tooltip: 'Add asset' });
|
||||
add.onClick = () => new Menu(this.addMenuEntries()).openBelow(add);
|
||||
add.onClick = () => new Menu(this.addMenuEntries(), { title: ADD_MENU_TITLE }).openBelow(add);
|
||||
this.header.add(add);
|
||||
|
||||
this.list.emptyText = 'No assets – press + or right-click';
|
||||
this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null);
|
||||
this.list.onActivate = (id) => this.activate(id);
|
||||
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.onListMenu = (x, y, headingId) =>
|
||||
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 (id.startsWith(CATEGORY))
|
||||
@@ -110,9 +116,20 @@ export class AssetPanel extends Panel {
|
||||
});
|
||||
state.on('selection', () => this.syncSelection());
|
||||
state.on('ping', () => this.ping());
|
||||
this.stopListening = onGeneralSettings(() => this.refresh());
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
override destroy(options?: Parameters<Panel['destroy']>[0]): void {
|
||||
this.stopListening();
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
/** The categories and groups as the general settings want them shown (#361). */
|
||||
private grouped(): AssetCategoryGroup[] {
|
||||
return groupAssets(listedAssets(this.state.project), generalSettings().showEmptyAssetGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. What a
|
||||
* song keeps to itself has no row, so its song lights up (#355).
|
||||
@@ -130,16 +147,22 @@ export class AssetPanel extends Panel {
|
||||
return this.list.pingState();
|
||||
}
|
||||
|
||||
/** One entry per category of the panel, each opening the types in it (`assetMenuCategories`). */
|
||||
private addMenuEntries(): MenuEntry[] {
|
||||
return assetMenuCategories().map(({ category, types }) => ({
|
||||
label: category,
|
||||
submenu: types.map((type) => ({
|
||||
label: ASSET_TYPE_LABELS[type],
|
||||
icon: ASSET_ICON_SVG[type],
|
||||
onSelect: () => this.addAsset(type),
|
||||
})),
|
||||
}));
|
||||
/**
|
||||
* One entry per category of the panel, each opening the types in it (`assetMenuCategories`). On a
|
||||
* category heading only the types of that category, on a group heading only its type (#361).
|
||||
*/
|
||||
private addMenuEntries(headingId?: string): MenuEntry[] {
|
||||
const entry = (type: AssetType): MenuEntry => ({
|
||||
label: ASSET_TYPE_LABELS[type],
|
||||
icon: ASSET_ICON_SVG[type],
|
||||
onSelect: () => this.addAsset(type),
|
||||
});
|
||||
if (headingId?.startsWith(HEADING)) return [entry(headingId.slice(HEADING.length) as AssetType)];
|
||||
const categories = assetMenuCategories();
|
||||
const category = headingId?.startsWith(CATEGORY) ? headingId.slice(CATEGORY.length) : undefined;
|
||||
const types = categories.find((c) => c.category === category)?.types;
|
||||
if (types) return types.map(entry);
|
||||
return categories.map(({ category, types }) => ({ label: category, submenu: types.map(entry) }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,18 +181,21 @@ export class AssetPanel extends Panel {
|
||||
private refresh(): void {
|
||||
const project = this.state.project;
|
||||
const items: ListItem[] = [];
|
||||
for (const { category, groups } of groupAssets(listedAssets(project))) {
|
||||
for (const { category, groups } of this.grouped()) {
|
||||
const open = !this.foldedCategories.has(category);
|
||||
const count = groups.reduce((sum, group) => sum + group.assets.length, 0);
|
||||
items.push({
|
||||
id: categoryId(category),
|
||||
label: category,
|
||||
hint: String(groups.reduce((sum, group) => sum + group.assets.length, 0)),
|
||||
hint: String(count),
|
||||
heading: true,
|
||||
expanded: open,
|
||||
faint: count === 0,
|
||||
});
|
||||
if (!open) continue;
|
||||
for (const { type, assets } of groups) {
|
||||
const expanded = this.unfolded.has(type);
|
||||
// An empty group has nothing to unfold, so it gets no chevron.
|
||||
items.push({
|
||||
id: headingId(type),
|
||||
label: ASSET_GROUPS[type].name,
|
||||
@@ -177,7 +203,8 @@ export class AssetPanel extends Panel {
|
||||
icon: ASSET_ICON_SVG[type],
|
||||
depth: 1,
|
||||
heading: true,
|
||||
expanded,
|
||||
expanded: assets.length > 0 ? expanded : undefined,
|
||||
faint: assets.length === 0,
|
||||
});
|
||||
if (!expanded) continue;
|
||||
for (const asset of assets)
|
||||
@@ -254,7 +281,7 @@ export class AssetPanel extends Panel {
|
||||
expanded: boolean;
|
||||
ids: string[];
|
||||
}[] {
|
||||
return groupAssets(listedAssets(this.state.project)).flatMap(({ category, groups }) =>
|
||||
return this.grouped().flatMap(({ category, groups }) =>
|
||||
groups.map(({ type, assets }) => ({
|
||||
category,
|
||||
type,
|
||||
@@ -267,12 +294,17 @@ export class AssetPanel extends Panel {
|
||||
|
||||
/** The categories in order, and whether each is unfolded (test hook). */
|
||||
categories(): { category: AssetCategory; expanded: boolean }[] {
|
||||
return groupAssets(listedAssets(this.state.project)).map(({ category }) => ({
|
||||
return this.grouped().map(({ category }) => ({
|
||||
category,
|
||||
expanded: !this.foldedCategories.has(category),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Opacity of a row's label, below 1 for a faint heading; null while it is not shown (test hook). */
|
||||
rowAlpha(id: string): number | null {
|
||||
return this.list.rowAlpha(id);
|
||||
}
|
||||
|
||||
private createAsset(type: AssetType, name = ASSET_TYPE_LABELS[type], props: PropertyBag = {}): void {
|
||||
const command = new CreateAssetCommand(type, nextAssetName(this.state.project, name), props);
|
||||
if (this.state.execute(command)) this.state.openAsset(command.asset.id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import type { AssetType } from '../../core/model/AssetTypes';
|
||||
import { ASSET_TYPES, 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;
|
||||
@@ -59,11 +59,13 @@ const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' })
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* group per type (#176). Only what has assets is there, unless `empty` asks for every category and
|
||||
* type (#361). 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[]): AssetCategoryGroup[] {
|
||||
export function groupAssets(assets: readonly Asset[], empty = false): AssetCategoryGroup[] {
|
||||
const byType = new Map<AssetType, Asset[]>();
|
||||
if (empty) for (const type of ASSET_TYPES) byType.set(type, []);
|
||||
for (const asset of assets) {
|
||||
const list = byType.get(asset.type);
|
||||
if (list) list.push(asset);
|
||||
|
||||
Reference in new issue
Block a user