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

+16 -3
View File
@@ -1,4 +1,4 @@
import { Label, NumberField } from '../ui';
import { Checkbox, Label, NumberField } from '../ui';
import { type GeneralSettings, ZOOM_TRANSITION_MAX_MS, zoomTransitionMs } from './generalSettings';
import { FormDialog } from './FormDialog';
@@ -9,6 +9,8 @@ import { FormDialog } from './FormDialog';
export class GeneralSettingsDialog extends FormDialog {
private zoomMs: number;
private readonly zoomField: NumberField;
private showEmpty: boolean;
private readonly emptyBox: Checkbox;
constructor(settings: GeneralSettings, onApply: (settings: GeneralSettings) => void) {
super(340, 'General settings');
@@ -30,12 +32,23 @@ export class GeneralSettingsDialog extends FormDialog {
this.content.add(
new Label('How long a zoom or Fit takes to settle. 0 jumps at once.', { style: 'muted' }),
);
this.addButtons('Apply', 'primary', () => onApply({ zoomTransitionMs: zoomTransitionMs(this.zoomMs) }));
this.showEmpty = settings.showEmptyAssetGroups;
this.emptyBox = new Checkbox(this.showEmpty);
this.emptyBox.onChange = (value) => {
this.showEmpty = value;
};
this.content.add(this.formRow(this.emptyBox, new Label('Show empty asset categories and types')));
this.addButtons('Apply', 'primary', () => onApply(this.values()));
}
/** The settings as the fields stand (test hook). */
values(): GeneralSettings {
return { zoomTransitionMs: this.zoomMs };
return { zoomTransitionMs: zoomTransitionMs(this.zoomMs), showEmptyAssetGroups: this.showEmpty };
}
/** The switch for the empty asset groups, so a check can click it. */
get emptyGroupsControl(): Checkbox {
return this.emptyBox;
}
/** The zoom field itself, so a check can type into it. */
+9
View File
@@ -274,6 +274,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
tooltip: () => tooltips.shown,
/** Entries of the open menu in drawn order, `null` for a separator line (#43). */
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
/** The muted title above the entries of the open menu, `null` without one (#361). */
menuTitle: () => currentMenu()?.titleDrawn() ?? null,
/** Per entry of `menuRows`, whether an icon stands in front of it. */
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
/** Per entry of `menuRows`, whether it opens a submenu. With one open, `menuRows` is that submenu. */
@@ -358,6 +360,11 @@ export function uiHooks({ app, state, editor }: HookContext) {
const dialog = editor.generalSettingsDialog();
return dialog ? centreOf(dialog.zoomControl) : null;
},
/** Where to click the switch for empty asset groups inside that dialog (#361). */
generalSettingsEmptyGroupsAt: () => {
const dialog = editor.generalSettingsDialog();
return dialog ? centreOf(dialog.emptyGroupsControl) : null;
},
/**
* The zoom transition in ms as kept in this browser; with `ms` it is set first. The harness sets
* 0 on every load, so a check reading the zoom after the wheel finds the step and not the way.
@@ -391,6 +398,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
/** The asset row lighting up after a ping (#221) and how brightly, or null once it has faded. */
assetPing: () => assets.pingState(),
assetRowColor: (id: string) => assets.rowColor(id),
/** Opacity of a shown row's label, below 1 for an empty category or group (#361). */
assetRowAlpha: (id: string) => assets.rowAlpha(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>')`. */
+15 -2
View File
@@ -10,13 +10,16 @@ const STORAGE_KEY = 'game-maker.general-settings';
export interface GeneralSettings {
/** How long a zoom takes to reach its new step, in ms; 0 jumps at once. */
zoomTransitionMs: number;
/** Whether the asset panel lists every category and type, faint where it holds no asset (#361). */
showEmptyAssetGroups: boolean;
}
export const ZOOM_TRANSITION_MAX_MS = 1000;
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200 };
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200, showEmptyAssetGroups: false };
let settings: GeneralSettings | null = null;
const listeners = new Set<() => void>();
function storage(): Pick<Storage, 'getItem' | 'setItem'> | null {
try {
@@ -37,7 +40,10 @@ function read(): GeneralSettings {
try {
const stored: unknown = JSON.parse(storage()?.getItem(STORAGE_KEY) ?? '{}');
const raw = typeof stored === 'object' && stored !== null ? (stored as Record<string, unknown>) : {};
return { zoomTransitionMs: zoomTransitionMs(raw.zoomTransitionMs) };
return {
zoomTransitionMs: zoomTransitionMs(raw.zoomTransitionMs),
showEmptyAssetGroups: raw.showEmptyAssetGroups === true,
};
} catch {
return { ...DEFAULTS };
}
@@ -56,6 +62,13 @@ export function keepGeneralSettings(change: Partial<GeneralSettings>): void {
} catch {
// Kept for this page only.
}
for (const listener of listeners) listener();
}
/** Calls `listener` after every change kept; the returned function stops that. */
export function onGeneralSettings(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Forgets what was read, so the next question asks the browser again (tests). */
+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);
+9
View File
@@ -11,6 +11,8 @@ import { crisp, crispText } from '../pixelGrid';
const CHEVRON_SLOT = 14;
/** Units an icon file spans around its origin. */
const ICON_SIZE = 16;
/** Opacity of what a faint row shows, like a disabled widget. */
const FAINT_ALPHA = 0.5;
/** One row of a `ListView`. Purely visual: pointer handling and selection state live in the list. */
export class ListRow extends Widget {
@@ -43,9 +45,16 @@ export class ListRow extends Widget {
this.marks.push(mark);
this.addChild(mark);
}
if (item.faint)
for (const part of [this.labelText, this.hint, this.icon, ...this.marks]) part.alpha = FAINT_ALPHA;
this.trackHover();
}
/** Opacity of the label, its hint and icons (test hook). */
get labelAlpha(): number {
return this.labelText.alpha;
}
/** `listFocused` picks the selection color: strong while the list has focus, muted otherwise. */
setSelected(selected: boolean, listFocused: boolean): void {
if (selected === this.selected && listFocused === this.listFocused) return;
+11 -3
View File
@@ -29,6 +29,8 @@ export interface ListItem {
* and ↑/↓ pass over it. Give it `expanded` for the chevron.
*/
heading?: boolean;
/** Draws the row pale – there, but holding nothing yet. */
faint?: boolean;
}
/** Pixels of travel before a press turns into a drag. */
@@ -64,6 +66,7 @@ export function sameListItem(a: ListItem, b: ListItem): boolean {
a.icon === b.icon &&
Boolean(a.iconAtEnd) === Boolean(b.iconAtEnd) &&
Boolean(a.heading) === Boolean(b.heading) &&
Boolean(a.faint) === Boolean(b.faint) &&
marks.length === others.length &&
marks.every((mark, i) => mark === others[i])
);
@@ -118,8 +121,8 @@ export class ListView extends Widget {
onReselect?: (id: string) => void;
/** Returning the opened menu keeps its row marked for as long as it is up. */
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
/** A right-click on the empty space below the rows or on a heading, which have no menu of their own. */
onListMenu?: (globalX: number, globalY: number) => void;
/** A right-click on the empty space below the rows, or on a heading – then with its id. */
onListMenu?: (globalX: number, globalY: number, headingId?: string) => void;
/** Called when a dragged row is released somewhere that could take it. */
onDrop?: (draggedId: string, spot: DropSpot) => void;
/**
@@ -259,6 +262,11 @@ export class ListView extends Widget {
return this.rows.find((r) => r.item.id === id)?.labelColor ?? null;
}
/** Opacity of a row's label – below 1 for a faint row; null when no such row is shown (test hook). */
rowAlpha(id: string): number | null {
return this.rows.find((r) => r.item.id === id)?.labelAlpha ?? null;
}
/** Global centre of a row; null when no such row is shown (test hook). */
rowGlobal(id: string): { x: number; y: number } | null {
const row = this.rows.find((r) => r.item.id === id);
@@ -335,7 +343,7 @@ export class ListView extends Widget {
const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey };
if (item.heading) {
if (e.button === 0) this.onToggle?.(item.id, !item.expanded);
else if (e.button === 2) this.onListMenu?.(e.global.x, e.global.y);
else if (e.button === 2) this.onListMenu?.(e.global.x, e.global.y, item.id);
return;
}
// The chevron only folds the row – it never selects, so folding keeps the selection put.
+10
View File
@@ -49,6 +49,8 @@ export type MenuEntry = MenuItem | MenuSeparator | MenuSubmenu;
export interface MenuOptions {
/** Adds a search box above the entries that filters as you type; Enter picks the first match. */
searchable?: boolean;
/** A muted line above the entries that says what picking one does; it is not an entry. */
title?: string;
/** The menu this one is a submenu of; set by `Menu` itself when a submenu entry opens. */
under?: Menu;
}
@@ -79,6 +81,7 @@ export class Menu extends Popup {
private readonly scroll = new ScrollContainer();
private readonly empty = new Label('No match', { style: 'muted', padding: Theme.padding });
private readonly search: TextField | null;
private readonly title: Label | null;
private readonly under: Menu | null;
/** The submenu standing open beside this menu, and the entry it belongs to. */
private child: { menu: Menu; row: SubmenuRow } | null = null;
@@ -88,6 +91,8 @@ export class Menu extends Popup {
super({ padding: PADDING, gap: 4, backdrop: !options.under });
this.under = options.under ?? null;
this.minWidth = 160;
this.title = options.title ? new Label(options.title, { style: 'muted', padding: Theme.padding }) : null;
if (this.title) this.content.add(this.title);
this.search = options.searchable
? new TextField('', {
placeholder: 'Search…',
@@ -215,6 +220,11 @@ export class Menu extends Popup {
super.close();
}
/** The muted line above the entries, or null without one (test hook). */
titleDrawn(): string | null {
return this.title?.text ?? null;
}
/** The entries as they stand, in the order they are drawn; `null` is a separator (test hook). */
rowsDrawn(): (string | null)[] {
return this.list.children
+12
View File
@@ -63,6 +63,18 @@ describe('groupAssets (#176)', () => {
expect(groupsOf([second, first])[0]!.assets).toEqual([second, first]);
});
it('lists every category and type when asked to, the empty ones with no assets (#361)', () => {
const categories = groupAssets([asset('sprite', 'Hero')], true);
expect(categories.map((c) => c.category)).toEqual([...ASSET_CATEGORIES]);
const groups = categories.flatMap((c) => c.groups);
expect(groups.map((g) => g.type).sort()).toEqual([...ASSET_TYPES].sort());
expect(groups.filter((g) => g.assets.length > 0).map((g) => g.type)).toEqual(['sprite']);
for (const { category, groups } of categories)
for (const { type } of groups) expect(ASSET_GROUPS[type].category).toBe(category);
expect(groupAssets([], true).flatMap((c) => c.groups)).toHaveLength(ASSET_TYPES.length);
expect(groupAssets([])).toEqual([]);
});
it('leaves the project list alone', () => {
const assets = [asset('sprite', 'B'), asset('sprite', 'A')];
groupAssets(assets);
+15
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
generalSettings,
keepGeneralSettings,
onGeneralSettings,
resetGeneralSettings,
zoomTransitionMs,
} from '../../src/editor/generalSettings';
@@ -215,9 +216,23 @@ describe('the general settings', () => {
expect(generalSettings().zoomTransitionMs).toBe(350);
expect(JSON.parse(store.items.get('game-maker.general-settings') ?? '')).toEqual({
zoomTransitionMs: 350,
showEmptyAssetGroups: false,
});
});
it('hides empty asset groups until switched on, keeps the switch and tells listeners (#361)', () => {
vi.stubGlobal('window', { localStorage: memoryStore() });
expect(generalSettings().showEmptyAssetGroups).toBe(false);
let told = 0;
const stop = onGeneralSettings(() => (told += 1));
keepGeneralSettings({ showEmptyAssetGroups: true });
resetGeneralSettings();
expect(generalSettings()).toEqual({ zoomTransitionMs: 200, showEmptyAssetGroups: true });
stop();
keepGeneralSettings({ showEmptyAssetGroups: false });
expect(told).toBe(1);
});
it('holds the transition to whole ms from 0 to 1000', () => {
expect([zoomTransitionMs(5000), zoomTransitionMs(-3), zoomTransitionMs(12.6)]).toEqual([1000, 0, 13]);
expect(zoomTransitionMs('fast')).toBe(200);
+116 -8
View File
@@ -56,6 +56,7 @@ export async function assets(t) {
await showTab('scene');
await addMenuOnRightClick(t);
await emptyGroupsOnSwitch(t);
// 4b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
// with `drag()`, because the preview only exists while the button is still down.
@@ -108,10 +109,11 @@ export async function assets(t) {
const rows = await json('__gm.menuRows()');
const opens = await json('__gm.menuSubmenus()');
const menuBox = await json('__gm.overlayRect()');
const title = await json('__gm.menuTitle()');
check(
'the + menu lists the categories of the asset panel, each opening a submenu',
String(rows) === String(CATEGORIES) && opens.every(Boolean),
JSON.stringify({ rows, opens }),
'the + menu lists the categories of the asset panel, each opening a submenu, under "Create asset"',
String(rows) === String(CATEGORIES) && opens.every(Boolean) && title === 'Create asset',
JSON.stringify({ rows, opens, title }),
);
const offered = {};
for (const category of CATEGORIES) {
@@ -523,8 +525,9 @@ export async function assets(t) {
}
/**
* 4a-ii. Where no menu of its own opens – on a group heading or on the empty space below the rows –
* a right-click brings up the + menu (#244). The heading stays folded as it was.
* 4a-ii. Where no menu of its own opens, a right-click brings up the + menu (#244): on the empty
* space below the rows all of it, on a category heading the types of that category, on a group
* heading only its type (#361). The heading stays folded as it was.
*/
async function addMenuOnRightClick(t) {
const { check, json, shot, click, clickText, press, undo, showTab } = t;
@@ -535,10 +538,33 @@ async function addMenuOnRightClick(t) {
const foldedBefore = await sceneFolded();
await click(heading.x, heading.y, 'right');
const onHeading = await json('__gm.menuRows()');
const headingTitle = await json('__gm.menuTitle()');
check(
'a right-click on a group heading opens the + menu and leaves the group as it was',
addRows(onHeading) && (await sceneFolded()) === foldedBefore,
JSON.stringify({ onHeading, foldedBefore }),
'a right-click on a group heading offers only its type, under "Create asset", and leaves the group as it was',
String(onHeading) === 'Scene' &&
headingTitle === 'Create asset' &&
(await sceneFolded()) === foldedBefore,
JSON.stringify({ onHeading, headingTitle, foldedBefore }),
);
await press('Escape');
const basics = await json("__gm.assetRowShown('category:Basics')");
await click(basics.x, basics.y, 'right');
const onCategory = {
rows: await json('__gm.menuRows()'),
submenus: await json('__gm.menuSubmenus()'),
};
onCategory.filedUnder = await json(
`${JSON.stringify(onCategory.rows)}.map((label) => __gm.addMenuCategory(label))`,
);
check(
'a right-click on a category heading offers the types of that category, sorted, without submenus',
onCategory.rows.length >= 4 &&
onCategory.rows.includes('Scene') &&
onCategory.filedUnder.every((c) => c === 'Basics') &&
String(onCategory.rows) === String([...onCategory.rows].sort()) &&
onCategory.submenus.every((open) => !open) &&
(await json('__gm.assetCategories()')).find((c) => c.category === 'Basics').expanded,
JSON.stringify(onCategory),
);
await press('Escape');
const groups = await json('__gm.assetGroups()');
@@ -573,3 +599,85 @@ async function addMenuOnRightClick(t) {
await undo();
await showTab('scene');
}
/**
* 4a-iii. A switch in "General settings…" lists every category and type, the empty ones faint
* (#361); off again, only what holds assets is left. A right-click on an empty type's heading
* creates the first asset of it.
*/
async function emptyGroupsOnSwitch(t) {
const { check, json, shot, click, clickText, clickToolbar, sleep, undo, showTab } = t;
const flip = async () => {
await clickToolbar('menu');
await clickText('General settings…');
const box = await json('__gm.generalSettingsEmptyGroupsAt()');
await click(box.x, box.y);
await clickText('Apply');
await sleep(100);
};
const shape = (groups) => JSON.stringify(groups.map((g) => [g.type, g.ids]));
const before = await json('__gm.assetGroups()');
await flip();
const all = await json('__gm.assetGroups()');
const categories = await json('__gm.assetCategories()');
// One that an entry creates at once – a texture, a font or a sound opens a picker instead.
const empty = all.find(
(g) => g.ids.length === 0 && !['texture', 'font', 'sound', 'pixelart'].includes(g.type),
);
const full = all.find((g) => g.ids.length > 0);
const emptyCategory = CATEGORIES.find((c) =>
all.filter((g) => g.category === c).every((g) => g.ids.length === 0),
);
const seen = {
stored: await json(`JSON.parse(localStorage.getItem('game-maker.general-settings') ?? 'null')`),
emptyAlpha: await json(`__gm.assetRowAlpha('group:${empty?.type}')`),
fullAlpha: await json(`__gm.assetRowAlpha('group:${full?.type}')`),
categoryAlpha: emptyCategory ? await json(`__gm.assetRowAlpha('category:${emptyCategory}')`) : null,
filledCategoryAlpha: await json(`__gm.assetRowAlpha('category:${full?.category}')`),
};
check(
'the switch lists every category and type, the empty ones faint, the rest as before',
all.length === ASSET_TYPE_COUNT &&
String(categories.map((c) => c.category)) === String(CATEGORIES) &&
before.every(
(g) => JSON.stringify(all.find((a) => a.type === g.type)?.ids) === JSON.stringify(g.ids),
) &&
seen.stored?.showEmptyAssetGroups === true &&
Boolean(empty) &&
seen.emptyAlpha === 0.5 &&
seen.fullAlpha === 1 &&
seen.filledCategoryAlpha === 1 &&
(!emptyCategory || seen.categoryAlpha === 0.5),
JSON.stringify({ ...seen, empty: empty?.type, emptyCategory, all: all.length }),
);
await shot('11d-empty-asset-groups');
// Scrolled into view first: with every type listed the panel is longer than it is high.
const heading = await json(`__gm.assetRow('group:${empty.type}')`);
await click(heading.x, heading.y, 'right');
const offered = await json('__gm.menuRows()');
await clickText(offered[0]);
const made = await json('__gm.state.project.assets.at(-1)');
const after = {
offered,
made: made && { type: made.type, name: made.name },
alpha: await json(`__gm.assetRowAlpha('group:${empty.type}')`),
};
check(
'a right-click on an empty type offers just that type, and its first asset makes the heading solid',
offered.length === 1 && made?.type === empty.type && after.alpha === 1,
JSON.stringify(after),
);
await undo();
await showTab('scene');
await flip();
const off = await json('__gm.assetGroups()');
check(
'switched off again, only the types with assets are listed',
shape(off) === shape(before) &&
(await json(`JSON.parse(localStorage.getItem('game-maker.general-settings'))`)).showEmptyAssetGroups ===
false,
JSON.stringify({ before: shape(before), off: shape(off) }),
);
}