From 5cb7ef7bed635bfaaa7cf28c658b01b71dacef90 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 9 Oct 2026 06:51:59 +0200 Subject: [PATCH] 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 --- src/editor/GeneralSettingsDialog.ts | 19 ++++- src/editor/debug/uiHooks.ts | 9 ++ src/editor/generalSettings.ts | 17 +++- src/editor/panels/AssetPanel.ts | 68 +++++++++++---- src/editor/panels/assetGroups.ts | 10 ++- src/ui/widgets/ListRow.ts | 9 ++ src/ui/widgets/ListView.ts | 14 +++- src/ui/widgets/Menu.ts | 10 +++ tests/editor/asset-groups.test.ts | 12 +++ tests/editor/view-tween.test.ts | 15 ++++ tools/e2e/04-assets.mjs | 124 ++++++++++++++++++++++++++-- 11 files changed, 269 insertions(+), 38 deletions(-) diff --git a/src/editor/GeneralSettingsDialog.ts b/src/editor/GeneralSettingsDialog.ts index 5157ea54..7e528990 100644 --- a/src/editor/GeneralSettingsDialog.ts +++ b/src/editor/GeneralSettingsDialog.ts @@ -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. */ diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 94d5629b..04e987f9 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -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:')`. */ assetGroups: () => assets.groups(), /** The categories above the groups; a heading's row is `assetRow('category:')`. */ diff --git a/src/editor/generalSettings.ts b/src/editor/generalSettings.ts index 20526d40..a13ff9f7 100644 --- a/src/editor/generalSettings.ts +++ b/src/editor/generalSettings.ts @@ -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 | 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) : {}; - 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): 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). */ diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 36eb9cd2..64bf7ca7 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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(); /** 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[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); diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index d5904b0a..6c175fb5 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -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(); + 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); diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts index abf809af..1cbd5cfd 100644 --- a/src/ui/widgets/ListRow.ts +++ b/src/ui/widgets/ListRow.ts @@ -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; diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 75ba8652..6353faa7 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -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. diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts index d65647d0..61a1f1fc 100644 --- a/src/ui/widgets/Menu.ts +++ b/src/ui/widgets/Menu.ts @@ -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 diff --git a/tests/editor/asset-groups.test.ts b/tests/editor/asset-groups.test.ts index dbbc3a16..805d1526 100644 --- a/tests/editor/asset-groups.test.ts +++ b/tests/editor/asset-groups.test.ts @@ -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); diff --git a/tests/editor/view-tween.test.ts b/tests/editor/view-tween.test.ts index 7ab71532..e2612f57 100644 --- a/tests/editor/view-tween.test.ts +++ b/tests/editor/view-tween.test.ts @@ -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); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index c56fff38..c84c9c33 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -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) }), + ); +}