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
+266
-35
No files matched your search
@@ -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 { type GeneralSettings, ZOOM_TRANSITION_MAX_MS, zoomTransitionMs } from './generalSettings';
|
||||||
import { FormDialog } from './FormDialog';
|
import { FormDialog } from './FormDialog';
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ import { FormDialog } from './FormDialog';
|
|||||||
export class GeneralSettingsDialog extends FormDialog {
|
export class GeneralSettingsDialog extends FormDialog {
|
||||||
private zoomMs: number;
|
private zoomMs: number;
|
||||||
private readonly zoomField: NumberField;
|
private readonly zoomField: NumberField;
|
||||||
|
private showEmpty: boolean;
|
||||||
|
private readonly emptyBox: Checkbox;
|
||||||
|
|
||||||
constructor(settings: GeneralSettings, onApply: (settings: GeneralSettings) => void) {
|
constructor(settings: GeneralSettings, onApply: (settings: GeneralSettings) => void) {
|
||||||
super(340, 'General settings');
|
super(340, 'General settings');
|
||||||
@@ -30,12 +32,23 @@ export class GeneralSettingsDialog extends FormDialog {
|
|||||||
this.content.add(
|
this.content.add(
|
||||||
new Label('How long a zoom or Fit takes to settle. 0 jumps at once.', { style: 'muted' }),
|
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). */
|
/** The settings as the fields stand (test hook). */
|
||||||
values(): GeneralSettings {
|
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. */
|
/** The zoom field itself, so a check can type into it. */
|
||||||
|
|||||||
@@ -274,6 +274,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
tooltip: () => tooltips.shown,
|
tooltip: () => tooltips.shown,
|
||||||
/** Entries of the open menu in drawn order, `null` for a separator line (#43). */
|
/** Entries of the open menu in drawn order, `null` for a separator line (#43). */
|
||||||
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
|
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. */
|
/** Per entry of `menuRows`, whether an icon stands in front of it. */
|
||||||
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
|
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
|
||||||
/** Per entry of `menuRows`, whether it opens a submenu. With one open, `menuRows` is that submenu. */
|
/** 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();
|
const dialog = editor.generalSettingsDialog();
|
||||||
return dialog ? centreOf(dialog.zoomControl) : null;
|
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
|
* 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.
|
* 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. */
|
/** The asset row lighting up after a ping (#221) and how brightly, or null once it has faded. */
|
||||||
assetPing: () => assets.pingState(),
|
assetPing: () => assets.pingState(),
|
||||||
assetRowColor: (id: string) => assets.rowColor(id),
|
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>')`. */
|
/** The asset panel's groups in order; a heading's row is `assetRow('group:<type>')`. */
|
||||||
assetGroups: () => assets.groups(),
|
assetGroups: () => assets.groups(),
|
||||||
/** The categories above the groups; a heading's row is `assetRow('category:<name>')`. */
|
/** The categories above the groups; a heading's row is `assetRow('category:<name>')`. */
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ const STORAGE_KEY = 'game-maker.general-settings';
|
|||||||
export interface GeneralSettings {
|
export interface GeneralSettings {
|
||||||
/** How long a zoom takes to reach its new step, in ms; 0 jumps at once. */
|
/** How long a zoom takes to reach its new step, in ms; 0 jumps at once. */
|
||||||
zoomTransitionMs: number;
|
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;
|
export const ZOOM_TRANSITION_MAX_MS = 1000;
|
||||||
|
|
||||||
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200 };
|
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200, showEmptyAssetGroups: false };
|
||||||
|
|
||||||
let settings: GeneralSettings | null = null;
|
let settings: GeneralSettings | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
function storage(): Pick<Storage, 'getItem' | 'setItem'> | null {
|
function storage(): Pick<Storage, 'getItem' | 'setItem'> | null {
|
||||||
try {
|
try {
|
||||||
@@ -37,7 +40,10 @@ function read(): GeneralSettings {
|
|||||||
try {
|
try {
|
||||||
const stored: unknown = JSON.parse(storage()?.getItem(STORAGE_KEY) ?? '{}');
|
const stored: unknown = JSON.parse(storage()?.getItem(STORAGE_KEY) ?? '{}');
|
||||||
const raw = typeof stored === 'object' && stored !== null ? (stored as Record<string, unknown>) : {};
|
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 {
|
} catch {
|
||||||
return { ...DEFAULTS };
|
return { ...DEFAULTS };
|
||||||
}
|
}
|
||||||
@@ -56,6 +62,13 @@ export function keepGeneralSettings(change: Partial<GeneralSettings>): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// Kept for this page only.
|
// 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). */
|
/** Forgets what was read, so the next question asks the browser again (tests). */
|
||||||
|
|||||||
@@ -36,17 +36,21 @@ import {
|
|||||||
startDrag,
|
startDrag,
|
||||||
UI_ICON_SVG,
|
UI_ICON_SVG,
|
||||||
} from '../../ui';
|
} 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 { ASSET_ICON_SVG } from './assetIcons';
|
||||||
import { assetMenuCategories } from './assetMenu';
|
import { assetMenuCategories } from './assetMenu';
|
||||||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
||||||
import { activateAsset, confirmDeleteAsset } from '../actions';
|
import { activateAsset, confirmDeleteAsset } from '../actions';
|
||||||
import { openReferenceItems } from '../objectMenu';
|
import { openReferenceItems } from '../objectMenu';
|
||||||
|
import { generalSettings, onGeneralSettings } from '../generalSettings';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { openFontPicker } from '../font/FontPicker';
|
import { openFontPicker } from '../font/FontPicker';
|
||||||
import { openSoundPicker } from '../sound/SoundPicker';
|
import { openSoundPicker } from '../sound/SoundPicker';
|
||||||
import { openImagePicker } from '../texture/ImagePicker';
|
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. */
|
/** Row ids of the group headings are this plus the type; an asset id never starts with it. */
|
||||||
const HEADING = 'group:';
|
const HEADING = 'group:';
|
||||||
const headingId = (type: AssetType): string => HEADING + type;
|
const headingId = (type: AssetType): string => HEADING + type;
|
||||||
@@ -70,6 +74,7 @@ export class AssetPanel extends Panel {
|
|||||||
private readonly foldedCategories = new Set<AssetCategory>();
|
private readonly foldedCategories = new Set<AssetCategory>();
|
||||||
/** Ids of the rows as shown, headings included. */
|
/** Ids of the rows as shown, headings included. */
|
||||||
private rows: string[] = [];
|
private rows: string[] = [];
|
||||||
|
private readonly stopListening: () => void;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly state: EditorState,
|
private readonly state: EditorState,
|
||||||
@@ -84,14 +89,15 @@ export class AssetPanel extends Panel {
|
|||||||
this.minWidth = 160;
|
this.minWidth = 160;
|
||||||
|
|
||||||
const add = new Button('+', { variant: 'ghost', width: 28, tooltip: 'Add asset' });
|
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.header.add(add);
|
||||||
|
|
||||||
this.list.emptyText = 'No assets – press + or right-click';
|
this.list.emptyText = 'No assets – press + or right-click';
|
||||||
this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null);
|
this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null);
|
||||||
this.list.onActivate = (id) => this.activate(id);
|
this.list.onActivate = (id) => this.activate(id);
|
||||||
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
|
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.onDragOut = (id) => this.startPlacementDrag(id);
|
||||||
this.list.onToggle = (id, expand) => {
|
this.list.onToggle = (id, expand) => {
|
||||||
if (id.startsWith(CATEGORY))
|
if (id.startsWith(CATEGORY))
|
||||||
@@ -110,9 +116,20 @@ export class AssetPanel extends Panel {
|
|||||||
});
|
});
|
||||||
state.on('selection', () => this.syncSelection());
|
state.on('selection', () => this.syncSelection());
|
||||||
state.on('ping', () => this.ping());
|
state.on('ping', () => this.ping());
|
||||||
|
this.stopListening = onGeneralSettings(() => this.refresh());
|
||||||
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
|
* 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).
|
* 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();
|
return this.list.pingState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One entry per category of the panel, each opening the types in it (`assetMenuCategories`). */
|
/**
|
||||||
private addMenuEntries(): MenuEntry[] {
|
* One entry per category of the panel, each opening the types in it (`assetMenuCategories`). On a
|
||||||
return assetMenuCategories().map(({ category, types }) => ({
|
* category heading only the types of that category, on a group heading only its type (#361).
|
||||||
label: category,
|
*/
|
||||||
submenu: types.map((type) => ({
|
private addMenuEntries(headingId?: string): MenuEntry[] {
|
||||||
|
const entry = (type: AssetType): MenuEntry => ({
|
||||||
label: ASSET_TYPE_LABELS[type],
|
label: ASSET_TYPE_LABELS[type],
|
||||||
icon: ASSET_ICON_SVG[type],
|
icon: ASSET_ICON_SVG[type],
|
||||||
onSelect: () => this.addAsset(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 {
|
private refresh(): void {
|
||||||
const project = this.state.project;
|
const project = this.state.project;
|
||||||
const items: ListItem[] = [];
|
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 open = !this.foldedCategories.has(category);
|
||||||
|
const count = groups.reduce((sum, group) => sum + group.assets.length, 0);
|
||||||
items.push({
|
items.push({
|
||||||
id: categoryId(category),
|
id: categoryId(category),
|
||||||
label: category,
|
label: category,
|
||||||
hint: String(groups.reduce((sum, group) => sum + group.assets.length, 0)),
|
hint: String(count),
|
||||||
heading: true,
|
heading: true,
|
||||||
expanded: open,
|
expanded: open,
|
||||||
|
faint: count === 0,
|
||||||
});
|
});
|
||||||
if (!open) continue;
|
if (!open) continue;
|
||||||
for (const { type, assets } of groups) {
|
for (const { type, assets } of groups) {
|
||||||
const expanded = this.unfolded.has(type);
|
const expanded = this.unfolded.has(type);
|
||||||
|
// An empty group has nothing to unfold, so it gets no chevron.
|
||||||
items.push({
|
items.push({
|
||||||
id: headingId(type),
|
id: headingId(type),
|
||||||
label: ASSET_GROUPS[type].name,
|
label: ASSET_GROUPS[type].name,
|
||||||
@@ -177,7 +203,8 @@ export class AssetPanel extends Panel {
|
|||||||
icon: ASSET_ICON_SVG[type],
|
icon: ASSET_ICON_SVG[type],
|
||||||
depth: 1,
|
depth: 1,
|
||||||
heading: true,
|
heading: true,
|
||||||
expanded,
|
expanded: assets.length > 0 ? expanded : undefined,
|
||||||
|
faint: assets.length === 0,
|
||||||
});
|
});
|
||||||
if (!expanded) continue;
|
if (!expanded) continue;
|
||||||
for (const asset of assets)
|
for (const asset of assets)
|
||||||
@@ -254,7 +281,7 @@ export class AssetPanel extends Panel {
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
ids: string[];
|
ids: string[];
|
||||||
}[] {
|
}[] {
|
||||||
return groupAssets(listedAssets(this.state.project)).flatMap(({ category, groups }) =>
|
return this.grouped().flatMap(({ category, groups }) =>
|
||||||
groups.map(({ type, assets }) => ({
|
groups.map(({ type, assets }) => ({
|
||||||
category,
|
category,
|
||||||
type,
|
type,
|
||||||
@@ -267,12 +294,17 @@ export class AssetPanel extends Panel {
|
|||||||
|
|
||||||
/** The categories in order, and whether each is unfolded (test hook). */
|
/** The categories in order, and whether each is unfolded (test hook). */
|
||||||
categories(): { category: AssetCategory; expanded: boolean }[] {
|
categories(): { category: AssetCategory; expanded: boolean }[] {
|
||||||
return groupAssets(listedAssets(this.state.project)).map(({ category }) => ({
|
return this.grouped().map(({ category }) => ({
|
||||||
category,
|
category,
|
||||||
expanded: !this.foldedCategories.has(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 {
|
private createAsset(type: AssetType, name = ASSET_TYPE_LABELS[type], props: PropertyBag = {}): void {
|
||||||
const command = new CreateAssetCommand(type, nextAssetName(this.state.project, name), props);
|
const command = new CreateAssetCommand(type, nextAssetName(this.state.project, name), props);
|
||||||
if (this.state.execute(command)) this.state.openAsset(command.asset.id);
|
if (this.state.execute(command)) this.state.openAsset(command.asset.id);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Asset } from '../../core/model/Asset';
|
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. */
|
/** 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 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
|
* 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
|
* group per type (#176). Only what has assets is there, unless `empty` asks for every category and
|
||||||
* alphabetical; equal names keep the project's order, so a row does not jump on a rebuild.
|
* 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[]>();
|
const byType = new Map<AssetType, Asset[]>();
|
||||||
|
if (empty) for (const type of ASSET_TYPES) byType.set(type, []);
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const list = byType.get(asset.type);
|
const list = byType.get(asset.type);
|
||||||
if (list) list.push(asset);
|
if (list) list.push(asset);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { crisp, crispText } from '../pixelGrid';
|
|||||||
const CHEVRON_SLOT = 14;
|
const CHEVRON_SLOT = 14;
|
||||||
/** Units an icon file spans around its origin. */
|
/** Units an icon file spans around its origin. */
|
||||||
const ICON_SIZE = 16;
|
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. */
|
/** One row of a `ListView`. Purely visual: pointer handling and selection state live in the list. */
|
||||||
export class ListRow extends Widget {
|
export class ListRow extends Widget {
|
||||||
@@ -43,9 +45,16 @@ export class ListRow extends Widget {
|
|||||||
this.marks.push(mark);
|
this.marks.push(mark);
|
||||||
this.addChild(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();
|
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. */
|
/** `listFocused` picks the selection color: strong while the list has focus, muted otherwise. */
|
||||||
setSelected(selected: boolean, listFocused: boolean): void {
|
setSelected(selected: boolean, listFocused: boolean): void {
|
||||||
if (selected === this.selected && listFocused === this.listFocused) return;
|
if (selected === this.selected && listFocused === this.listFocused) return;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface ListItem {
|
|||||||
* and ↑/↓ pass over it. Give it `expanded` for the chevron.
|
* and ↑/↓ pass over it. Give it `expanded` for the chevron.
|
||||||
*/
|
*/
|
||||||
heading?: boolean;
|
heading?: boolean;
|
||||||
|
/** Draws the row pale – there, but holding nothing yet. */
|
||||||
|
faint?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pixels of travel before a press turns into a drag. */
|
/** 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 &&
|
a.icon === b.icon &&
|
||||||
Boolean(a.iconAtEnd) === Boolean(b.iconAtEnd) &&
|
Boolean(a.iconAtEnd) === Boolean(b.iconAtEnd) &&
|
||||||
Boolean(a.heading) === Boolean(b.heading) &&
|
Boolean(a.heading) === Boolean(b.heading) &&
|
||||||
|
Boolean(a.faint) === Boolean(b.faint) &&
|
||||||
marks.length === others.length &&
|
marks.length === others.length &&
|
||||||
marks.every((mark, i) => mark === others[i])
|
marks.every((mark, i) => mark === others[i])
|
||||||
);
|
);
|
||||||
@@ -118,8 +121,8 @@ export class ListView extends Widget {
|
|||||||
onReselect?: (id: string) => void;
|
onReselect?: (id: string) => void;
|
||||||
/** Returning the opened menu keeps its row marked for as long as it is up. */
|
/** Returning the opened menu keeps its row marked for as long as it is up. */
|
||||||
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
|
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. */
|
/** A right-click on the empty space below the rows, or on a heading – then with its id. */
|
||||||
onListMenu?: (globalX: number, globalY: number) => void;
|
onListMenu?: (globalX: number, globalY: number, headingId?: string) => void;
|
||||||
/** Called when a dragged row is released somewhere that could take it. */
|
/** Called when a dragged row is released somewhere that could take it. */
|
||||||
onDrop?: (draggedId: string, spot: DropSpot) => void;
|
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;
|
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). */
|
/** Global centre of a row; null when no such row is shown (test hook). */
|
||||||
rowGlobal(id: string): { x: number; y: number } | null {
|
rowGlobal(id: string): { x: number; y: number } | null {
|
||||||
const row = this.rows.find((r) => r.item.id === id);
|
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 };
|
const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey };
|
||||||
if (item.heading) {
|
if (item.heading) {
|
||||||
if (e.button === 0) this.onToggle?.(item.id, !item.expanded);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// The chevron only folds the row – it never selects, so folding keeps the selection put.
|
// The chevron only folds the row – it never selects, so folding keeps the selection put.
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export type MenuEntry = MenuItem | MenuSeparator | MenuSubmenu;
|
|||||||
export interface MenuOptions {
|
export interface MenuOptions {
|
||||||
/** Adds a search box above the entries that filters as you type; Enter picks the first match. */
|
/** Adds a search box above the entries that filters as you type; Enter picks the first match. */
|
||||||
searchable?: boolean;
|
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. */
|
/** The menu this one is a submenu of; set by `Menu` itself when a submenu entry opens. */
|
||||||
under?: Menu;
|
under?: Menu;
|
||||||
}
|
}
|
||||||
@@ -79,6 +81,7 @@ export class Menu extends Popup {
|
|||||||
private readonly scroll = new ScrollContainer();
|
private readonly scroll = new ScrollContainer();
|
||||||
private readonly empty = new Label('No match', { style: 'muted', padding: Theme.padding });
|
private readonly empty = new Label('No match', { style: 'muted', padding: Theme.padding });
|
||||||
private readonly search: TextField | null;
|
private readonly search: TextField | null;
|
||||||
|
private readonly title: Label | null;
|
||||||
private readonly under: Menu | null;
|
private readonly under: Menu | null;
|
||||||
/** The submenu standing open beside this menu, and the entry it belongs to. */
|
/** The submenu standing open beside this menu, and the entry it belongs to. */
|
||||||
private child: { menu: Menu; row: SubmenuRow } | null = null;
|
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 });
|
super({ padding: PADDING, gap: 4, backdrop: !options.under });
|
||||||
this.under = options.under ?? null;
|
this.under = options.under ?? null;
|
||||||
this.minWidth = 160;
|
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
|
this.search = options.searchable
|
||||||
? new TextField('', {
|
? new TextField('', {
|
||||||
placeholder: 'Search…',
|
placeholder: 'Search…',
|
||||||
@@ -215,6 +220,11 @@ export class Menu extends Popup {
|
|||||||
super.close();
|
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). */
|
/** The entries as they stand, in the order they are drawn; `null` is a separator (test hook). */
|
||||||
rowsDrawn(): (string | null)[] {
|
rowsDrawn(): (string | null)[] {
|
||||||
return this.list.children
|
return this.list.children
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ describe('groupAssets (#176)', () => {
|
|||||||
expect(groupsOf([second, first])[0]!.assets).toEqual([second, first]);
|
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', () => {
|
it('leaves the project list alone', () => {
|
||||||
const assets = [asset('sprite', 'B'), asset('sprite', 'A')];
|
const assets = [asset('sprite', 'B'), asset('sprite', 'A')];
|
||||||
groupAssets(assets);
|
groupAssets(assets);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
generalSettings,
|
generalSettings,
|
||||||
keepGeneralSettings,
|
keepGeneralSettings,
|
||||||
|
onGeneralSettings,
|
||||||
resetGeneralSettings,
|
resetGeneralSettings,
|
||||||
zoomTransitionMs,
|
zoomTransitionMs,
|
||||||
} from '../../src/editor/generalSettings';
|
} from '../../src/editor/generalSettings';
|
||||||
@@ -215,9 +216,23 @@ describe('the general settings', () => {
|
|||||||
expect(generalSettings().zoomTransitionMs).toBe(350);
|
expect(generalSettings().zoomTransitionMs).toBe(350);
|
||||||
expect(JSON.parse(store.items.get('game-maker.general-settings') ?? '')).toEqual({
|
expect(JSON.parse(store.items.get('game-maker.general-settings') ?? '')).toEqual({
|
||||||
zoomTransitionMs: 350,
|
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', () => {
|
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(5000), zoomTransitionMs(-3), zoomTransitionMs(12.6)]).toEqual([1000, 0, 13]);
|
||||||
expect(zoomTransitionMs('fast')).toBe(200);
|
expect(zoomTransitionMs('fast')).toBe(200);
|
||||||
|
|||||||
+116
-8
@@ -56,6 +56,7 @@ export async function assets(t) {
|
|||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
|
|
||||||
await addMenuOnRightClick(t);
|
await addMenuOnRightClick(t);
|
||||||
|
await emptyGroupsOnSwitch(t);
|
||||||
|
|
||||||
// 4b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
|
// 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.
|
// 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 rows = await json('__gm.menuRows()');
|
||||||
const opens = await json('__gm.menuSubmenus()');
|
const opens = await json('__gm.menuSubmenus()');
|
||||||
const menuBox = await json('__gm.overlayRect()');
|
const menuBox = await json('__gm.overlayRect()');
|
||||||
|
const title = await json('__gm.menuTitle()');
|
||||||
check(
|
check(
|
||||||
'the + menu lists the categories of the asset panel, each opening a submenu',
|
'the + menu lists the categories of the asset panel, each opening a submenu, under "Create asset"',
|
||||||
String(rows) === String(CATEGORIES) && opens.every(Boolean),
|
String(rows) === String(CATEGORIES) && opens.every(Boolean) && title === 'Create asset',
|
||||||
JSON.stringify({ rows, opens }),
|
JSON.stringify({ rows, opens, title }),
|
||||||
);
|
);
|
||||||
const offered = {};
|
const offered = {};
|
||||||
for (const category of CATEGORIES) {
|
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 –
|
* 4a-ii. Where no menu of its own opens, a right-click brings up the + menu (#244): on the empty
|
||||||
* a right-click brings up the + menu (#244). The heading stays folded as it was.
|
* 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) {
|
async function addMenuOnRightClick(t) {
|
||||||
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
||||||
@@ -535,10 +538,33 @@ async function addMenuOnRightClick(t) {
|
|||||||
const foldedBefore = await sceneFolded();
|
const foldedBefore = await sceneFolded();
|
||||||
await click(heading.x, heading.y, 'right');
|
await click(heading.x, heading.y, 'right');
|
||||||
const onHeading = await json('__gm.menuRows()');
|
const onHeading = await json('__gm.menuRows()');
|
||||||
|
const headingTitle = await json('__gm.menuTitle()');
|
||||||
check(
|
check(
|
||||||
'a right-click on a group heading opens the + menu and leaves the group as it was',
|
'a right-click on a group heading offers only its type, under "Create asset", and leaves the group as it was',
|
||||||
addRows(onHeading) && (await sceneFolded()) === foldedBefore,
|
String(onHeading) === 'Scene' &&
|
||||||
JSON.stringify({ onHeading, foldedBefore }),
|
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');
|
await press('Escape');
|
||||||
const groups = await json('__gm.assetGroups()');
|
const groups = await json('__gm.assetGroups()');
|
||||||
@@ -573,3 +599,85 @@ async function addMenuOnRightClick(t) {
|
|||||||
await undo();
|
await undo();
|
||||||
await showTab('scene');
|
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) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user