Show empty asset groups on request, and add by the heading (#361)
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Successful in 15m1s

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:
adminandClaude Opus 5.5 committed 2026-10-09 06:51:59 +02:00
1 parent cf96442835
commit 5cb7ef7bed
11 files changed
+269 -38

No files matched your search

+50 -18
View File
@@ -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);
+6 -4
View File
@@ -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);