Pick a new asset from category submenus in the + menu
game-maker / gate (push) Successful in 3m48s
game-maker / suite (push) Successful in 15m0s

The + menu was one long list in two groups, placeable types and
library ones. That split is no longer wanted. The menu now lists the
categories of the asset panel, and each opens its types beside it, so
a new asset is filed where the panel shows it.

- Menu learns submenus: an entry with submenu instead of onSelect
  opens a second Menu right of the first, or left of it where the
  canvas ends. Its first entry lines up with the row that opened it.
- A submenu opens on hover or click. Resting on another entry swaps
  or closes it after 150 ms, so a pointer cutting across the rows on
  its way into a submenu does not lose it.
- The row of an open submenu stays lit, and a triangle pointing right
  marks every such row.
- A submenu has no backdrop and no Escape of its own. Popup takes a
  backdrop option for that. The first menu's backdrop and Escape
  close the whole stack, and picking an entry does the same.
- assetMenuCategories replaces assetMenuGroups and takes categories
  and their order from ASSET_GROUPS. The types are sorted by label.
- New test hooks __gm.menuSubmenus() and __gm.addMenuCategory().
  pickAssetType opens the category first, so the modules that pick a
  type through it need no change of their own. Modules 04, 10, 47,
  48, 53 and 54 open the category themselves where they read the menu.

Tests:
- Unit asset-menu: 4 green
- e2e 04: ALL PASSED, 63 checks. New: categories in panel order, each
  type once and sorted, submenu placement, hover swaps the submenu,
  picking closes menu and submenu
- Gate: CHECK EXIT 0, 261 files, 4040 tests
- The other modules that use the + menu did not run here

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 18:17:21 +02:00
1 parent 5e09ba9a50
commit a848cf8469
16 files changed
+320 -131

No files matched your search

+8 -1
View File
@@ -1,4 +1,4 @@
import type { AssetType } from '../../core/model/AssetTypes'; import { ASSET_TYPE_LABELS, type AssetType } from '../../core/model/AssetTypes';
import { import {
Button, Button,
Grip, Grip,
@@ -15,6 +15,7 @@ import {
import { Theme } from '../../ui/Theme'; import { Theme } from '../../ui/Theme';
import { currentFontPicker } from '../font/FontPicker'; import { currentFontPicker } from '../font/FontPicker';
import { generalSettings, keepGeneralSettings } from '../generalSettings'; import { generalSettings, keepGeneralSettings } from '../generalSettings';
import { assetMenuCategories } from '../panels/assetMenu';
import { currentGroupDialog } from '../panels/GroupNameDialog'; import { currentGroupDialog } from '../panels/GroupNameDialog';
import { currentSoundPicker } from '../sound/SoundPicker'; import { currentSoundPicker } from '../sound/SoundPicker';
import { rectOf, textProbe } from '../textProbe'; import { rectOf, textProbe } from '../textProbe';
@@ -275,6 +276,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
menuRows: () => currentMenu()?.rowsDrawn() ?? null, menuRows: () => currentMenu()?.rowsDrawn() ?? 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. */
menuSubmenus: () => currentMenu()?.submenusDrawn() ?? null,
/** Per entry of `menuRows`, its tick box: `true`/`false`, `null` for an entry without one. */ /** Per entry of `menuRows`, its tick box: `true`/`false`, `null` for an entry without one. */
menuTicks: () => currentMenu()?.ticksDrawn() ?? null, menuTicks: () => currentMenu()?.ticksDrawn() ?? null,
/** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */ /** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */
@@ -387,6 +390,10 @@ export function uiHooks({ app, state, editor }: HookContext) {
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>')`. */
assetCategories: () => assets.categories(), assetCategories: () => assets.categories(),
/** The `+` menu entry whose submenu holds the type with this menu label, or null. */
addMenuCategory: (label: string) =>
assetMenuCategories().find(({ types }) => types.some((type) => ASSET_TYPE_LABELS[type] === label))
?.category ?? null,
assetGroupIcon: (type: AssetType) => assets.groupIcon(type), assetGroupIcon: (type: AssetType) => assets.groupIcon(type),
/** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */ /** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */
assetsRect: () => rectOf(assets), assetsRect: () => rectOf(assets),
+10 -10
View File
@@ -37,7 +37,7 @@ import {
} from '../../ui'; } from '../../ui';
import { type AssetCategory, ASSET_GROUPS, groupAssets } from './assetGroups'; import { type AssetCategory, ASSET_GROUPS, groupAssets } from './assetGroups';
import { ASSET_ICON_SVG } from './assetIcons'; import { ASSET_ICON_SVG } from './assetIcons';
import { assetMenuGroups } 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';
@@ -125,16 +125,16 @@ export class AssetPanel extends Panel {
return this.list.pingState(); return this.list.pingState();
} }
/** One entry per asset type, the groups of `assetMenuGroups` kept apart by a line. */ /** One entry per category of the panel, each opening the types in it (`assetMenuCategories`). */
private addMenuEntries(): MenuEntry[] { private addMenuEntries(): MenuEntry[] {
const entry = (type: AssetType): MenuEntry => ({ return assetMenuCategories().map(({ category, types }) => ({
label: ASSET_TYPE_LABELS[type], label: category,
icon: ASSET_ICON_SVG[type], submenu: types.map((type) => ({
onSelect: () => this.addAsset(type), label: ASSET_TYPE_LABELS[type],
}); icon: ASSET_ICON_SVG[type],
return assetMenuGroups().flatMap((group, index) => onSelect: () => this.addAsset(type),
index === 0 ? group.map(entry) : [{ separator: true }, ...group.map(entry)], })),
); }));
} }
/** /**
+10 -9
View File
@@ -1,17 +1,18 @@
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, isPlaceableType } from '../../core/model/AssetTypes'; import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType } from '../../core/model/AssetTypes';
import { type AssetCategory, ASSET_CATEGORIES, ASSET_GROUPS } from './assetGroups';
/** /**
* The `+` menu in two alphabetical groups: what goes into a scene, then what only lives in the * The `+` menu: one entry per category of the asset panel, in the panel's order, each opening the
* library. At fifteen types declaration order is nothing anyone can search by eye, and the group * types of that category sorted by label. The menu and the panel file a type under the same
* answers the question that comes right after "which type?" – whether the thing will be placed. * category, so what you made is found where you would look for it.
* *
* Ordered here and not in `ASSET_TYPES`: that array is also the serialisation and validation order, * Ordered here and not in `ASSET_TYPES`: that array is also the serialisation and validation order,
* and at least one check reads a list derived from it. * and at least one check reads a list derived from it.
*/ */
export function assetMenuGroups(): AssetType[][] { export function assetMenuCategories(): { category: AssetCategory; types: AssetType[] }[] {
const byLabel = (a: AssetType, b: AssetType) => ASSET_TYPE_LABELS[a].localeCompare(ASSET_TYPE_LABELS[b]); const byLabel = (a: AssetType, b: AssetType) => ASSET_TYPE_LABELS[a].localeCompare(ASSET_TYPE_LABELS[b]);
return [ return ASSET_CATEGORIES.map((category) => ({
ASSET_TYPES.filter(isPlaceableType).sort(byLabel), category,
ASSET_TYPES.filter((type) => !isPlaceableType(type)).sort(byLabel), types: ASSET_TYPES.filter((type) => ASSET_GROUPS[type].category === category).sort(byLabel),
]; })).filter((entry) => entry.types.length > 0);
} }
+1
View File
@@ -76,6 +76,7 @@ export {
type MenuItem, type MenuItem,
type MenuOptions, type MenuOptions,
type MenuSeparator, type MenuSeparator,
type MenuSubmenu,
} from './widgets/Menu'; } from './widgets/Menu';
export { Separator } from './widgets/Separator'; export { Separator } from './widgets/Separator';
export { searchRank } from './search'; export { searchRank } from './search';
+163 -6
View File
@@ -1,10 +1,12 @@
import { Graphics } from 'pixi.js';
import { VStack } from '../layout/Stack'; import { VStack } from '../layout/Stack';
import { UI_ICON_SVG } from '../icons'; import { UI_ICON_SVG } from '../icons';
import { searchRank } from '../search'; import { searchRank } from '../search';
import { Theme } from '../Theme'; import { Theme } from '../Theme';
import { ui } from '../UIContext'; import { ui } from '../UIContext';
import type { Widget } from '../Widget'; import type { Size, Widget } from '../Widget';
import { Button } from './Button'; import { Button, type ButtonOptions } from './Button';
import { CHEVRON_SLOT } from './Dropdown';
import { Label } from './Label'; import { Label } from './Label';
import { Popup } from './Popup'; import { Popup } from './Popup';
import { ScrollContainer } from './ScrollContainer'; import { ScrollContainer } from './ScrollContainer';
@@ -31,13 +33,36 @@ export interface MenuSeparator {
separator: true; separator: true;
} }
export type MenuEntry = MenuItem | MenuSeparator; /**
* An entry that opens more entries beside the menu, on hover or on a click. A search matches it by
* its own label only, not by what it holds.
*/
export interface MenuSubmenu {
label: string;
submenu: MenuEntry[];
icon?: string;
disabled?: boolean;
}
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;
/** The menu this one is a submenu of; set by `Menu` itself when a submenu entry opens. */
under?: Menu;
} }
/** The padding of a menu around its entries; a submenu opens this far above its row to line up. */
const PADDING = 4;
/** How far a submenu reaches back over the menu it hangs off, so the two read as one. */
const SUBMENU_OVERLAP = 2;
/**
* How long the pointer has to rest on another entry before an open submenu swaps or closes. A
* pointer on its way into a submenu cuts across the rows below its own, and they must not take it.
*/
const SUBMENU_DELAY_MS = 150;
interface Row { interface Row {
widget: Widget; widget: Widget;
/** Null for a separator: it is decoration between groups, not an entry. */ /** Null for a separator: it is decoration between groups, not an entry. */
@@ -54,9 +79,14 @@ 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 under: Menu | null;
/** The submenu standing open beside this menu, and the entry it belongs to. */
private child: { menu: Menu; row: SubmenuRow } | null = null;
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
constructor(items: MenuEntry[], options: MenuOptions = {}) { constructor(items: MenuEntry[], options: MenuOptions = {}) {
super({ padding: 4, gap: 4 }); super({ padding: PADDING, gap: 4, backdrop: !options.under });
this.under = options.under ?? null;
this.minWidth = 160; this.minWidth = 160;
this.search = options.searchable this.search = options.searchable
? new TextField('', { ? new TextField('', {
@@ -74,6 +104,16 @@ export class Menu extends Popup {
this.rows.push({ widget: new Separator(), button: null, label: '' }); this.rows.push({ widget: new Separator(), button: null, label: '' });
continue; continue;
} }
if ('submenu' in item) {
const row = new SubmenuRow(item.label, { variant: 'ghost', align: 'left', icon: item.icon });
row.setEnabled(!item.disabled);
const open = () => this.openSubmenu(row, item.submenu);
row.onClick = open;
row.on('pointerover', () => (this.child ? this.later(open) : open()));
row.on('pointerout', () => this.cancelHover());
this.rows.push({ widget: row, button: row, label: item.label.toLowerCase() });
continue;
}
const { checked } = item; const { checked } = item;
// A destructive entry says so in red text only: a filled red row shouted louder than the // A destructive entry says so in red text only: a filled red row shouted louder than the
// menu around it and read like the one under the pointer. // menu around it and read like the one under the pointer.
@@ -89,7 +129,12 @@ export class Menu extends Popup {
item.onSelect(); item.onSelect();
this.retick(); this.retick();
} }
: this.closeThen(item.onSelect); : this.pickThen(item.onSelect);
// Resting on a plain entry puts an open submenu away.
button.on('pointerover', () => {
if (this.child) this.later(() => this.closeSubmenu());
});
button.on('pointerout', () => this.cancelHover());
this.rows.push({ widget: button, button, label: item.label.toLowerCase(), ticked: checked }); this.rows.push({ widget: button, button, label: item.label.toLowerCase(), ticked: checked });
} }
this.scroll.setContent(this.list); this.scroll.setContent(this.list);
@@ -97,6 +142,59 @@ export class Menu extends Popup {
this.filter(); this.filter();
} }
/** Closes the whole stack of menus, the first one included, before the action runs. */
private pickThen(action: () => void): () => void {
return () => {
this.root().close();
action();
};
}
/** The first menu of a stack of submenus; a menu on its own is its own root. */
private root(): Menu {
return this.under ? this.under.root() : this;
}
/**
* Opens `entries` beside `row`, the first entry level with it: right of this menu, or left of it
* where the canvas ends. Another submenu standing open closes first.
*/
private openSubmenu(row: SubmenuRow, entries: MenuEntry[]): void {
this.cancelHover();
if (this.child?.row === row || !this.isOpen) return;
this.closeSubmenu();
const menu = new Menu(entries, { under: this });
const self = this.getGlobalPosition();
const at = row.getGlobalPosition();
const { w } = menu.measure();
const right = self.x + this.w - SUBMENU_OVERLAP;
const x = right + w <= ui().app.screen.width ? right : self.x - w + SUBMENU_OVERLAP;
menu.onClose = () => {
row.setHeld(false);
if (this.child?.menu === menu) this.child = null;
};
this.child = { menu, row };
row.setHeld(true);
menu.open(x, at.y - PADDING);
}
private closeSubmenu(): void {
this.child?.menu.close();
}
private later(action: () => void): void {
this.cancelHover();
this.hoverTimer = setTimeout(() => {
this.hoverTimer = null;
if (this.isOpen) action();
}, SUBMENU_DELAY_MS);
}
private cancelHover(): void {
if (this.hoverTimer !== null) clearTimeout(this.hoverTimer);
this.hoverTimer = null;
}
/** Re-reads every tick box: one entry may switch off another, so all of them are asked. */ /** Re-reads every tick box: one entry may switch off another, so all of them are asked. */
private retick(): void { private retick(): void {
for (const row of this.rows) if (row.button && row.ticked) row.button.setIcon(tickIcon(row.ticked())); for (const row of this.rows) if (row.button && row.ticked) row.button.setIcon(tickIcon(row.ticked()));
@@ -109,8 +207,11 @@ export class Menu extends Popup {
if (this.search) ui().focus.focus(this.search); if (this.search) ui().focus.focus(this.search);
} }
/** Closes a submenu standing open first; the menu it hangs off becomes the current one again. */
override close(): void { override close(): void {
if (current === this) current = null; this.cancelHover();
this.closeSubmenu();
if (current === this) current = this.under?.isOpen ? this.under : null;
super.close(); super.close();
} }
@@ -153,6 +254,13 @@ export class Menu extends Popup {
.map((child) => (child instanceof Button ? child.hasIcon : null)); .map((child) => (child instanceof Button ? child.hasIcon : null));
} }
/** Per entry of `rowsDrawn`, whether it opens a submenu; `null` for a separator (test hook). */
submenusDrawn(): (boolean | null)[] {
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => (child instanceof Button ? child.opensMenu : null));
}
private filter(): void { private filter(): void {
const query = this.search?.value.trim().toLowerCase() ?? ''; const query = this.search?.value.trim().toLowerCase() ?? '';
const shown = query ? this.ranked(query) : groupsOf(this.rows); const shown = query ? this.ranked(query) : groupsOf(this.rows);
@@ -194,6 +302,55 @@ export class Menu extends Popup {
} }
} }
/** An entry that opens a submenu: a triangle pointing right at its end says so. */
class SubmenuRow extends Button {
private readonly arrow = new Graphics();
/** On while its submenu stands open, so the row stays lit as the pointer moves over into it. */
private held = false;
constructor(label: string, options: ButtonOptions) {
super(label, options);
this.addChild(this.arrow);
}
override get opensMenu(): boolean {
return true;
}
setHeld(held: boolean): void {
if (held === this.held) return;
this.held = held;
this.invalidate();
}
override measure(): Size {
const size = super.measure();
return { w: size.w + CHEVRON_SLOT, h: size.h };
}
protected override trailingRoom(): number {
return CHEVRON_SLOT;
}
protected override drawBackground(): void {
super.drawBackground();
if (this.held && !this.hovered)
this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(Theme.colors.controlHover);
}
override redraw(): void {
super.redraw();
const x = this.w - CHEVRON_SLOT / 2 - Theme.padding / 2;
const y = this.h / 2;
this.arrow
.clear()
.moveTo(x - 2, y - 4)
.lineTo(x + 3, y)
.lineTo(x - 2, y + 4)
.fill(Theme.colors.textMuted);
}
}
function tickIcon(ticked: boolean): string { function tickIcon(ticked: boolean): string {
return ticked ? UI_ICON_SVG.ticked : UI_ICON_SVG.unticked; return ticked ? UI_ICON_SVG.ticked : UI_ICON_SVG.unticked;
} }
+20 -9
View File
@@ -11,6 +11,11 @@ export interface PopupOptions {
gap?: number; gap?: number;
/** Modal popups dim the screen and ignore click-away; only Escape or an action closes them. */ /** Modal popups dim the screen and ignore click-away; only Escape or an action closes them. */
modal?: boolean; modal?: boolean;
/**
* False for a popup that hangs off another one, a submenu: a backdrop of its own would cover the
* popup below it, and that one's backdrop and Escape close both anyway.
*/
backdrop?: boolean;
} }
/** /**
@@ -49,6 +54,7 @@ export class Popup extends Widget {
readonly content: VStack; readonly content: VStack;
onClose?: () => void; onClose?: () => void;
private readonly modal: boolean; private readonly modal: boolean;
private readonly hasBackdrop: boolean;
/** Where `open()` was asked to put the top-left corner, before clamping to the screen. */ /** Where `open()` was asked to put the top-left corner, before clamping to the screen. */
private openX = 0; private openX = 0;
private openY = 0; private openY = 0;
@@ -56,6 +62,7 @@ export class Popup extends Widget {
constructor(options: PopupOptions = {}) { constructor(options: PopupOptions = {}) {
super(); super();
this.modal = options.modal ?? false; this.modal = options.modal ?? false;
this.hasBackdrop = options.backdrop ?? true;
this.content = new VStack({ padding: options.padding ?? Theme.padding, gap: options.gap ?? Theme.gap }); this.content = new VStack({ padding: options.padding ?? Theme.padding, gap: options.gap ?? Theme.gap });
this.addChild(this.bg, this.content); this.addChild(this.bg, this.content);
this.backdrop.eventMode = 'static'; this.backdrop.eventMode = 'static';
@@ -66,7 +73,7 @@ export class Popup extends Widget {
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation()); this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
} }
private get isOpen(): boolean { get isOpen(): boolean {
return this.parent !== null; return this.parent !== null;
} }
@@ -74,17 +81,20 @@ export class Popup extends Widget {
open(globalX: number, globalY: number): void { open(globalX: number, globalY: number): void {
const { overlay, app, focus } = ui(); const { overlay, app, focus } = ui();
const screen = app.screen; const screen = app.screen;
this.backdrop if (this.hasBackdrop) {
.clear() this.backdrop
.rect(0, 0, screen.width, screen.height) .clear()
.fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 }); .rect(0, 0, screen.width, screen.height)
this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height); .fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 });
overlay.addChild(this.backdrop, this); this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height);
overlay.addChild(this.backdrop);
window.addEventListener('keydown', this.onEscape, { capture: true });
}
overlay.addChild(this);
this.openX = globalX; this.openX = globalX;
this.openY = globalY; this.openY = globalY;
this.place(); this.place();
focus.blur(); focus.blur();
window.addEventListener('keydown', this.onEscape, { capture: true });
} }
/** Re-measures an open popup after its content changed size. No-op while closed. */ /** Re-measures an open popup after its content changed size. No-op while closed. */
@@ -118,7 +128,8 @@ export class Popup extends Widget {
close(): void { close(): void {
if (!this.isOpen) return; if (!this.isOpen) return;
window.removeEventListener('keydown', this.onEscape, { capture: true }); window.removeEventListener('keydown', this.onEscape, { capture: true });
ui().overlay.removeChild(this.backdrop, this); if (this.backdrop.parent) ui().overlay.removeChild(this.backdrop);
ui().overlay.removeChild(this);
// A field inside the popup would keep the keyboard bridge's DOM focus after the popup is gone, // A field inside the popup would keep the keyboard bridge's DOM focus after the popup is gone,
// swallowing every later Delete or Ctrl+Z. Blurring here mirrors the blur in `open()`. // swallowing every later Delete or Ctrl+Z. Blurring here mirrors the blur in `open()`.
ui().focus.blur(); ui().focus.blur();
+15 -20
View File
@@ -1,36 +1,31 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType } from '../../src/core/model/AssetTypes';
ASSET_TYPE_LABELS, import { ASSET_CATEGORIES, ASSET_GROUPS } from '../../src/editor/panels/assetGroups';
ASSET_TYPES, import { assetMenuCategories } from '../../src/editor/panels/assetMenu';
type AssetType,
isPlaceableType,
} from '../../src/core/model/AssetTypes';
import { assetMenuGroups } from '../../src/editor/panels/assetMenu';
const labels = (types: AssetType[]) => types.map((type) => ASSET_TYPE_LABELS[type]); const labels = (types: AssetType[]) => types.map((type) => ASSET_TYPE_LABELS[type]);
describe('assetMenuGroups', () => { describe('assetMenuCategories', () => {
it('offers every type exactly once', () => { it('offers every type exactly once', () => {
const flat = assetMenuGroups().flat(); const flat = assetMenuCategories().flatMap((entry) => entry.types);
expect([...flat].sort()).toEqual([...ASSET_TYPES].sort()); expect([...flat].sort()).toEqual([...ASSET_TYPES].sort());
}); });
it('splits into placeable first, library second (#43)', () => { it('files every type under the category the asset panel shows it in, in the panel order', () => {
const [placeable, library] = assetMenuGroups(); const menu = assetMenuCategories();
expect(placeable!.every(isPlaceableType)).toBe(true); expect(menu.map((entry) => entry.category)).toEqual([...ASSET_CATEGORIES]);
expect(library!.some(isPlaceableType)).toBe(false); for (const { category, types } of menu)
expect(types.every((type) => ASSET_GROUPS[type].category === category)).toBe(true);
}); });
it('sorts each group by its label, not by declaration order', () => { it('sorts each category by label, not by declaration order', () => {
for (const group of assetMenuGroups()) { for (const { types } of assetMenuCategories()) expect(labels(types)).toEqual([...labels(types)].sort());
expect(labels(group)).toEqual([...labels(group)].sort()); // Declaration order has Camera after Scene; by label it comes first.
} expect(assetMenuCategories()[0]!.types.slice(0, 2)).toEqual(['camera', 'container']);
// Declaration order starts with Scene, which is not placeable and no longer leads the menu.
expect(assetMenuGroups()[0]![0]).not.toBe('scene');
}); });
it('leaves ASSET_TYPES alone – it is also the serialisation order', () => { it('leaves ASSET_TYPES alone – it is also the serialisation order', () => {
assetMenuGroups(); assetMenuCategories();
expect(ASSET_TYPES[0]).toBe('scene'); expect(ASSET_TYPES[0]).toBe('scene');
}); });
}); });
+1 -1
View File
@@ -127,7 +127,7 @@ describe('the rule of a press in a point editor', () => {
expect(labels).toEqual(['Delete point', '-', 'Straight', 'Corner', 'Smooth', 'Symmetric']); expect(labels).toEqual(['Delete point', '-', 'Straight', 'Corner', 'Smooth', 'Symmetric']);
expect(typed[0]).toMatchObject({ disabled: false }); expect(typed[0]).toMatchObject({ disabled: false });
const smooth = typed[4]!; const smooth = typed[4]!;
if (!('separator' in smooth)) smooth.onSelect(); if ('onSelect' in smooth) smooth.onSelect();
expect(kinds).toEqual(['smooth']); expect(kinds).toEqual(['smooth']);
}); });
}); });
+72 -64
View File
@@ -1,8 +1,10 @@
import { colourDistance } from '../lib/harness.mjs'; import { colourDistance } from '../lib/harness.mjs';
import { inView } from './lib/scaffold.mjs'; import { pickAssetType } from './lib/scaffold.mjs';
/** One per entry of `ASSET_TYPES` – a new asset type counts here too. */ /** One per entry of `ASSET_TYPES` – a new asset type counts here too. */
const ASSET_TYPE_COUNT = 30; const ASSET_TYPE_COUNT = 30;
/** The categories of the asset panel and the `+` menu, in their order (`ASSET_CATEGORIES`). */
const CATEGORIES = ['Basics', 'Visuals', 'Logics', 'Physics', 'Audio'];
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */ /** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
export async function assets(t) { export async function assets(t) {
@@ -100,68 +102,72 @@ export async function assets(t) {
await redo(); await redo();
check('redo puts it back', (await objects()).length === 8); check('redo puts it back', (await objects()).length === 8);
// 4c. Create a new asset through the + menu. The menu is two alphabetical groups (#43) – what // 4c. Create a new asset through the + menu. The menu lists the categories of the asset panel, in
// can be placed in a scene, a line, then what only lives in the library. Its types do not // the panel's order; each opens the types filed under it, sorted by label, beside the menu.
// all fit under the button on a canvas 800 high: there it scrolls.
await clickText('+'); await clickText('+');
const rows = await json('__gm.menuRows()'); const rows = await json('__gm.menuRows()');
const cut = rows.indexOf(null); const opens = await json('__gm.menuSubmenus()');
const placeable = rows.slice(0, cut);
const library = rows.slice(cut + 1);
check(
'the + menu is split by exactly one line',
rows.filter((r) => r === null).length === 1 && cut > 0,
JSON.stringify(rows),
);
check(
'every asset type is offered once, each group sorted by label',
placeable.length + library.length === ASSET_TYPE_COUNT &&
String(placeable) === String([...placeable].sort()) &&
String(library) === String([...library].sort()),
JSON.stringify({ placeable, library }),
);
check(
'placeable types come first, the library ones after the line',
placeable.includes('Sprite') &&
!placeable.includes('Scene') &&
library.includes('Scene') &&
library.includes('Texture'),
JSON.stringify({ placeable, library }),
);
// The height cap is the canvas, not a constant: fifteen entries used to run under the mask edge,
// where `locate` still finds them but a click lands on nothing (that is what took step 30 down).
// What does not fit is scrolled to, never drawn past the box.
await shot('tmp-plus-menu');
const menuBox = await json('__gm.overlayRect()'); const menuBox = await json('__gm.overlayRect()');
const lastRow = await inView( check(
t, 'the + menu lists the categories of the asset panel, each opening a submenu',
'__gm.overlayRect()', String(rows) === String(CATEGORIES) && opens.every(Boolean),
`__gm.locate(${JSON.stringify(library.at(-1))}, true)`, JSON.stringify({ rows, opens }),
); );
const offered = {};
for (const category of CATEGORIES) {
await clickText(category);
offered[category] = await json('__gm.menuRows()');
}
const flat = Object.values(offered).flat();
check(
'every asset type is offered once, each category sorted by label',
flat.length === ASSET_TYPE_COUNT &&
new Set(flat).size === flat.length &&
Object.values(offered).every((list) => String(list) === String([...list].sort())),
JSON.stringify(offered),
);
check(
'a type sits under the category the asset panel files it under',
offered.Basics.includes('Scene') &&
offered.Visuals.includes('Sprite') &&
offered.Logics.includes('Behavior') &&
offered.Physics.includes('Collider') &&
offered.Audio.includes('Song'),
JSON.stringify(offered),
);
// The submenu opens right of the menu, its first entry level with the row that opened it. The
// pointer resting on another category swaps it after a short wait.
await clickText('Visuals');
const visualsRow = await json("__gm.locate('Visuals')");
const subBox = await json('__gm.overlayRect()');
const firstEntry = await json(`__gm.locate(${JSON.stringify(offered.Visuals[0])}, true)`);
await shot('tmp-plus-menu');
const screenH = await json('__gm.app.screen.height'); const screenH = await json('__gm.app.screen.height');
check( check(
'the whole menu stands on screen, last entry reached by scrolling at most', 'the submenu stands right of the menu, its first entry level with its category, all on screen',
Boolean(lastRow) && subBox.x >= menuBox.x + menuBox.w - 4 &&
lastRow.y < menuBox.y + menuBox.h && Math.abs(firstEntry.y - visualsRow.y) <= 2 &&
menuBox.y + menuBox.h <= screenH && subBox.y + subBox.h <= screenH - Math.round(screenH * 0.1) + 1,
menuBox.y >= 0, JSON.stringify({ menuBox, subBox, visualsRow, firstEntry, screenH }),
JSON.stringify({ menuBox, lastRow, screenH }),
); );
// A tenth of the canvas stays free below every popup. The margin used to live only in the height const audioRow = await json("__gm.locate('Audio')");
// cap, never in the placement, so a popup that had to be shifted up ended flush on the bottom await mouse('mouseMoved', audioRow.x, audioRow.y);
// edge with the whole gap collected above it. Above it is a budget, not a distance: this menu await sleep(400);
// hangs off a button near the top and stays there. const hovered = await json('__gm.menuRows()');
const above = menuBox.y;
const below = screenH - (menuBox.y + menuBox.h);
const margin = Math.round(screenH * 0.1);
check( check(
'the menu keeps a tenth of the canvas free below, and stays under its button', 'resting the pointer on another category swaps the submenu',
below >= margin - 1 && above >= 0 && above < margin, String(hovered) === String(offered.Audio),
JSON.stringify({ above, below, margin, screenH }), JSON.stringify(hovered),
); );
await clickText('Visuals');
await clickText('Sprite'); await clickText('Sprite');
const names = await json('__gm.state.project.assets.map(a => a.name)'); const names = await json('__gm.state.project.assets.map(a => a.name)');
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names)); const leftOpen = await json('__gm.menuRows()');
check(
'+ menu creates a sprite asset, and picking it closes menu and submenu',
names.includes('Sprite') && leftOpen === null,
JSON.stringify({ names, leftOpen }),
);
await shot('12-new-asset'); await shot('12-new-asset');
// 4d. Double-click on a scene row: the one thing activating an asset still does since placing // 4d. Double-click on a scene row: the one thing activating an asset still does since placing
@@ -247,8 +253,7 @@ export async function assets(t) {
// click does not give it – so it is not driven here. // click does not give it – so it is not driven here.
await showTab('scene'); await showTab('scene');
await clickText('+'); await clickText('+');
const entry = await evaluate(`__gm.locate('Behavior', true)`); await pickAssetType(t, 'Behavior');
await click(entry.x, entry.y);
const created = await json( const created = await json(
"(__gm.state.project.assets.find((a) => a.type === 'behavior' && !['mover', 'spawner'].includes(a.id)) ?? {}).id ?? ''", "(__gm.state.project.assets.find((a) => a.type === 'behavior' && !['mover', 'spawner'].includes(a.id)) ?? {}).id ?? ''",
); );
@@ -361,12 +366,11 @@ export async function assets(t) {
); );
const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }); const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
const sorted = (list) => list.every((v, i) => i === 0 || collator.compare(list[i - 1], v) <= 0); const sorted = (list) => list.every((v, i) => i === 0 || collator.compare(list[i - 1], v) <= 0);
const ORDER = ['Basics', 'Visuals', 'Logics', 'Physics', 'Audio'];
check( check(
'the assets panel groups by category and type, groups and assets sorted by name', 'the assets panel groups by category and type, groups and assets sorted by name',
groups.length >= 4 && groups.length >= 4 &&
categories.length >= 2 && categories.length >= 2 &&
sorted(categories.map((c) => ORDER.indexOf(c.category)).map(String)) && sorted(categories.map((c) => CATEGORIES.indexOf(c.category)).map(String)) &&
categories.every((c) => sorted(groups.filter((g) => g.category === c.category).map((g) => g.label))) && categories.every((c) => sorted(groups.filter((g) => g.category === c.category).map((g) => g.label))) &&
groups.every((g) => sorted(g.ids.map((id) => nameOf[id]))) && groups.every((g) => sorted(g.ids.map((id) => nameOf[id]))) &&
groups.flatMap((g) => g.ids).length === Object.keys(nameOf).length, groups.flatMap((g) => g.ids).length === Object.keys(nameOf).length,
@@ -479,15 +483,18 @@ export async function assets(t) {
// 4h. The same icons elsewhere: in front of every type in the `+` menu, and at the right edge of // 4h. The same icons elsewhere: in front of every type in the `+` menu, and at the right edge of
// a scene object's row, where its type used to be spelled out. // a scene object's row, where its type used to be spelled out.
await clickText('+'); await clickText('+');
const menuRows = await json('__gm.menuRows()'); const menuIcons = {};
const menuIcons = await json('__gm.menuIcons()'); for (const category of CATEGORIES) {
await clickText(category);
menuIcons[category] = await json('__gm.menuIcons()');
}
await shot('15d-add-menu'); await shot('15d-add-menu');
await press('Escape'); await press('Escape');
const iconRows = Object.values(menuIcons).flat();
check( check(
'every type in the + menu has its icon in front', 'every type in the + menu has its icon in front',
menuRows.filter(Boolean).length >= 15 && iconRows.length === ASSET_TYPE_COUNT && iconRows.every((icon) => icon === true),
menuRows.every((row, i) => (row === null) === (menuIcons[i] === null) && menuIcons[i] !== false), JSON.stringify(menuIcons),
JSON.stringify({ menuRows, menuIcons }),
); );
await showTab('scene'); await showTab('scene');
const first = (await json('__gm.hierarchyOrder()'))[0]; const first = (await json('__gm.hierarchyOrder()'))[0];
@@ -521,8 +528,7 @@ export async function assets(t) {
*/ */
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;
const addRows = (rows) => const addRows = (rows) => String(rows) === String(CATEGORIES);
Array.isArray(rows) && rows.filter(Boolean).length === ASSET_TYPE_COUNT && rows.includes('Sprite');
const heading = await json("__gm.assetRowShown('group:scene')"); const heading = await json("__gm.assetRowShown('group:scene')");
const sceneFolded = async () => const sceneFolded = async () =>
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded; !(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
@@ -551,7 +557,9 @@ async function addMenuOnRightClick(t) {
); );
await shot('11c-add-menu-on-right-click'); await shot('11c-add-menu-on-right-click');
const before = (await json('__gm.state.project.assets')).length; const before = (await json('__gm.state.project.assets')).length;
// The heading of the behaviors reads like the entry and is drawn first: the entry still wins. // The headings of the panel read like the menu's categories and entries and are drawn first: the
// menu still wins, for the category as for the type.
await clickText('Logics');
const behaviorWords = (await json("__gm.locateAll('Behavior')")).length; const behaviorWords = (await json("__gm.locateAll('Behavior')")).length;
await clickText('Behavior'); await clickText('Behavior');
const fromMenu = await json('__gm.state.project.assets.at(-1)'); const fromMenu = await json('__gm.state.project.assets.at(-1)');
+3 -1
View File
@@ -1,3 +1,5 @@
import { pickAssetType } from './lib/scaffold.mjs';
/** Text assets rendered in the scene, and the enabled switch in the editor and in the running game. */ /** Text assets rendered in the scene, and the enabled switch in the editor and in the running game. */
export async function textAndEnabled(t) { export async function textAndEnabled(t) {
const { const {
@@ -19,7 +21,7 @@ export async function textAndEnabled(t) {
// 10a. Text assets: created through the + menu, placed, and rendered as real text in the scene. // 10a. Text assets: created through the + menu, placed, and rendered as real text in the scene.
await clickText('+'); await clickText('+');
await clickText('Text'); await pickAssetType(t, 'Text');
const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`); const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`);
check('+ menu creates a text asset', Boolean(textAssetId)); check('+ menu creates a text asset', Boolean(textAssetId));
await mutate(` await mutate(`
+2 -2
View File
@@ -327,8 +327,8 @@ export async function textures(t) {
await evaluate('__gm.console.clear()'); await evaluate('__gm.console.clear()');
await clickText('+'); await clickText('+');
// By position, not by text: the menu entry "Texture" shares its label with the centre tab, and // By position, not by text: the menu entry "Texture" shares its label with the centre tab, and
// `locate` would hand back whichever it walked into first. The menu scrolls once it outgrows the // `locate` would hand back whichever it walked into first. `pickAssetType` opens the Visuals and
// screen, and "Texture" sits near its end. // takes the hit inside their submenu.
await pickAssetType(t, 'Texture'); await pickAssetType(t, 'Texture');
await sleep(1500); await sleep(1500);
const picker = await json('__gm.imagePicker()'); const picker = await json('__gm.imagePicker()');
+4 -3
View File
@@ -584,14 +584,15 @@ export async function vectorArt(t) {
return hits.sort((a, b) => a.y - b.y).at(-1) ?? null; return hits.sort((a, b) => a.y - b.y).at(-1) ?? null;
})()`; })()`;
// Vector art is the last of the 25 entries the menu has since the Joint (#154); on a canvas 800 // Vector art sits in the submenu of the Visuals; should that outgrow a short canvas, it scrolls,
// high the menu scrolls, and that one is scrolled to. // and the entry is scrolled to.
await clickText('+'); await clickText('+');
await clickText('Visuals');
const plusRows = (await json('__gm.menuRows()')) ?? []; const plusRows = (await json('__gm.menuRows()')) ?? [];
await inView(t, '__gm.overlayRect()', menuRow('Vector art')); await inView(t, '__gm.overlayRect()', menuRow('Vector art'));
const entry = await inOverlay('Vector art'); const entry = await inOverlay('Vector art');
check( check(
'the + menu offers Vector art among the types that only live in the library', 'the + menu offers Vector art among the Visuals',
plusRows.includes('Vector art') && Boolean(entry), plusRows.includes('Vector art') && Boolean(entry),
JSON.stringify({ offered: plusRows.includes('Vector art'), entry }), JSON.stringify({ offered: plusRows.includes('Vector art'), entry }),
); );
+1
View File
@@ -94,6 +94,7 @@ export async function pixelArt(t) {
const palettesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'palette').length"); const palettesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'palette').length");
await showTab('scene'); await showTab('scene');
await t.clickText('+'); await t.clickText('+');
await t.clickText('Visuals');
const entry = await inOverlay('Pixel art'); const entry = await inOverlay('Pixel art');
check('the + menu offers Pixel art', Boolean(entry), JSON.stringify(await json('__gm.menuRows()'))); check('the + menu offers Pixel art', Boolean(entry), JSON.stringify(await json('__gm.menuRows()')));
await click(entry.x, entry.y); await click(entry.x, entry.y);
+1
View File
@@ -55,6 +55,7 @@ export async function curves(t) {
// 53a. The + menu makes one; its row opens the Curve tab on it, a straight line of two points. // 53a. The + menu makes one; its row opens the Curve tab on it, a straight line of two points.
await showTab('scene'); await showTab('scene');
await t.clickText('+'); await t.clickText('+');
await t.clickText('Basics');
const offered = (await json('__gm.menuRows()')) ?? []; const offered = (await json('__gm.menuRows()')) ?? [];
const entry = await inOverlay('Curve'); const entry = await inOverlay('Curve');
check('the + menu offers Curve', offered.includes('Curve') && Boolean(entry), JSON.stringify(offered)); check('the + menu offers Curve', offered.includes('Curve') && Boolean(entry), JSON.stringify(offered));
+2 -1
View File
@@ -92,8 +92,9 @@ export async function structTypes(t) {
// variables, and each field has a default. // variables, and each field has a default.
await t.showTab('scene'); await t.showTab('scene');
await clickText('+'); await clickText('+');
await clickText('Logics');
const offered = (await json('__gm.menuRows()')) ?? []; const offered = (await json('__gm.menuRows()')) ?? [];
// Struct sits below the fold of the menu since the Pattern (#333); the lowest hit between the // Should the submenu outgrow a short canvas, Struct is scrolled to; the lowest hit between the
// box's sides is the entry, not the tab above it. // box's sides is the entry, not the tab above it.
await inView( await inView(
t, t,
+7 -4
View File
@@ -202,12 +202,15 @@ export async function clickDocumentTab(t, doc) {
} }
/** /**
* Picks an asset type from the already-open `+` menu. Since #43 the menu may grow to the height of * Picks an asset type from the already-open `+` menu: first its category, which opens the types of
* the canvas, and since the Instrument (#331) its last entries can sit below the fold of this * that category beside the menu, then the type in there. A submenu could still outgrow a short
* window (`Texture` in step 12). `locate` refuses a row scrolled out of its list, so the first look is `find`, * window, and `locate` refuses a row scrolled out of its list, so the first look is `find`, and a
* and a hit outside the menu (the centre tab "Texture") counts as a miss. * hit outside the submenu (the centre tab "Texture") counts as a miss.
*/ */
export async function pickAssetType(t, label) { export async function pickAssetType(t, label) {
const category = await t.json(`__gm.addMenuCategory(${JSON.stringify(label)})`);
if (!category) throw new Error(`no category of the + menu holds "${label}"`);
await t.clickText(category);
const box = await t.json('__gm.overlayRect()'); const box = await t.json('__gm.overlayRect()');
let p = await t.find(label, true); let p = await t.find(label, true);
let scrolled = false; let scrolled = false;