diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index f287c824..ca1dfe20 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -1,4 +1,4 @@ -import type { AssetType } from '../../core/model/AssetTypes'; +import { ASSET_TYPE_LABELS, type AssetType } from '../../core/model/AssetTypes'; import { Button, Grip, @@ -15,6 +15,7 @@ import { import { Theme } from '../../ui/Theme'; import { currentFontPicker } from '../font/FontPicker'; import { generalSettings, keepGeneralSettings } from '../generalSettings'; +import { assetMenuCategories } from '../panels/assetMenu'; import { currentGroupDialog } from '../panels/GroupNameDialog'; import { currentSoundPicker } from '../sound/SoundPicker'; import { rectOf, textProbe } from '../textProbe'; @@ -275,6 +276,8 @@ export function uiHooks({ app, state, editor }: HookContext) { menuRows: () => currentMenu()?.rowsDrawn() ?? 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. */ + menuSubmenus: () => currentMenu()?.submenusDrawn() ?? null, /** Per entry of `menuRows`, its tick box: `true`/`false`, `null` for an entry without one. */ menuTicks: () => currentMenu()?.ticksDrawn() ?? null, /** 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(), /** The categories above the groups; a heading's row is `assetRow('category:')`. */ 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), /** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */ assetsRect: () => rectOf(assets), diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 3613970f..182fb653 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -37,7 +37,7 @@ import { } from '../../ui'; import { type AssetCategory, ASSET_GROUPS, groupAssets } from './assetGroups'; import { ASSET_ICON_SVG } from './assetIcons'; -import { assetMenuGroups } from './assetMenu'; +import { assetMenuCategories } from './assetMenu'; import { TOOLBAR_ICON_SVG } from './toolbarIcons'; import { activateAsset, confirmDeleteAsset } from '../actions'; import { openReferenceItems } from '../objectMenu'; @@ -125,16 +125,16 @@ export class AssetPanel extends Panel { 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[] { - const entry = (type: AssetType): MenuEntry => ({ - label: ASSET_TYPE_LABELS[type], - icon: ASSET_ICON_SVG[type], - onSelect: () => this.addAsset(type), - }); - return assetMenuGroups().flatMap((group, index) => - index === 0 ? group.map(entry) : [{ separator: true }, ...group.map(entry)], - ); + 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), + })), + })); } /** diff --git a/src/editor/panels/assetMenu.ts b/src/editor/panels/assetMenu.ts index d6b9481e..815eef08 100644 --- a/src/editor/panels/assetMenu.ts +++ b/src/editor/panels/assetMenu.ts @@ -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 - * library. At fifteen types declaration order is nothing anyone can search by eye, and the group - * answers the question that comes right after "which type?" – whether the thing will be placed. + * The `+` menu: one entry per category of the asset panel, in the panel's order, each opening the + * types of that category sorted by label. The menu and the panel file a type under the same + * 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, * 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]); - return [ - ASSET_TYPES.filter(isPlaceableType).sort(byLabel), - ASSET_TYPES.filter((type) => !isPlaceableType(type)).sort(byLabel), - ]; + return ASSET_CATEGORIES.map((category) => ({ + category, + types: ASSET_TYPES.filter((type) => ASSET_GROUPS[type].category === category).sort(byLabel), + })).filter((entry) => entry.types.length > 0); } diff --git a/src/ui/index.ts b/src/ui/index.ts index 727ee3a0..8267177e 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -76,6 +76,7 @@ export { type MenuItem, type MenuOptions, type MenuSeparator, + type MenuSubmenu, } from './widgets/Menu'; export { Separator } from './widgets/Separator'; export { searchRank } from './search'; diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts index e4b97428..d65647d0 100644 --- a/src/ui/widgets/Menu.ts +++ b/src/ui/widgets/Menu.ts @@ -1,10 +1,12 @@ +import { Graphics } from 'pixi.js'; import { VStack } from '../layout/Stack'; import { UI_ICON_SVG } from '../icons'; import { searchRank } from '../search'; import { Theme } from '../Theme'; import { ui } from '../UIContext'; -import type { Widget } from '../Widget'; -import { Button } from './Button'; +import type { Size, Widget } from '../Widget'; +import { Button, type ButtonOptions } from './Button'; +import { CHEVRON_SLOT } from './Dropdown'; import { Label } from './Label'; import { Popup } from './Popup'; import { ScrollContainer } from './ScrollContainer'; @@ -31,13 +33,36 @@ export interface MenuSeparator { 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 { /** Adds a search box above the entries that filters as you type; Enter picks the first match. */ 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 { widget: Widget; /** 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 empty = new Label('No match', { style: 'muted', padding: Theme.padding }); 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 | null = null; 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.search = options.searchable ? new TextField('', { @@ -74,6 +104,16 @@ export class Menu extends Popup { this.rows.push({ widget: new Separator(), button: null, label: '' }); 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; // 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. @@ -89,7 +129,12 @@ export class Menu extends Popup { item.onSelect(); 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.scroll.setContent(this.list); @@ -97,6 +142,59 @@ export class Menu extends Popup { 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. */ private retick(): void { 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); } + /** Closes a submenu standing open first; the menu it hangs off becomes the current one again. */ override close(): void { - if (current === this) current = null; + this.cancelHover(); + this.closeSubmenu(); + if (current === this) current = this.under?.isOpen ? this.under : null; super.close(); } @@ -153,6 +254,13 @@ export class Menu extends Popup { .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 { const query = this.search?.value.trim().toLowerCase() ?? ''; 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 { return ticked ? UI_ICON_SVG.ticked : UI_ICON_SVG.unticked; } diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts index 7439427f..a1bad818 100644 --- a/src/ui/widgets/Popup.ts +++ b/src/ui/widgets/Popup.ts @@ -11,6 +11,11 @@ export interface PopupOptions { gap?: number; /** Modal popups dim the screen and ignore click-away; only Escape or an action closes them. */ 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; onClose?: () => void; private readonly modal: boolean; + private readonly hasBackdrop: boolean; /** Where `open()` was asked to put the top-left corner, before clamping to the screen. */ private openX = 0; private openY = 0; @@ -56,6 +62,7 @@ export class Popup extends Widget { constructor(options: PopupOptions = {}) { super(); 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.addChild(this.bg, this.content); this.backdrop.eventMode = 'static'; @@ -66,7 +73,7 @@ export class Popup extends Widget { this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation()); } - private get isOpen(): boolean { + get isOpen(): boolean { return this.parent !== null; } @@ -74,17 +81,20 @@ export class Popup extends Widget { open(globalX: number, globalY: number): void { const { overlay, app, focus } = ui(); const screen = app.screen; - this.backdrop - .clear() - .rect(0, 0, screen.width, screen.height) - .fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 }); - this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height); - overlay.addChild(this.backdrop, this); + if (this.hasBackdrop) { + this.backdrop + .clear() + .rect(0, 0, screen.width, screen.height) + .fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 }); + 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.openY = globalY; this.place(); focus.blur(); - window.addEventListener('keydown', this.onEscape, { capture: true }); } /** 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 { if (!this.isOpen) return; 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, // swallowing every later Delete or Ctrl+Z. Blurring here mirrors the blur in `open()`. ui().focus.blur(); diff --git a/tests/editor/asset-menu.test.ts b/tests/editor/asset-menu.test.ts index e75de820..c5442033 100644 --- a/tests/editor/asset-menu.test.ts +++ b/tests/editor/asset-menu.test.ts @@ -1,36 +1,31 @@ import { describe, expect, it } from 'vitest'; -import { - ASSET_TYPE_LABELS, - ASSET_TYPES, - type AssetType, - isPlaceableType, -} from '../../src/core/model/AssetTypes'; -import { assetMenuGroups } from '../../src/editor/panels/assetMenu'; +import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType } from '../../src/core/model/AssetTypes'; +import { ASSET_CATEGORIES, ASSET_GROUPS } from '../../src/editor/panels/assetGroups'; +import { assetMenuCategories } from '../../src/editor/panels/assetMenu'; const labels = (types: AssetType[]) => types.map((type) => ASSET_TYPE_LABELS[type]); -describe('assetMenuGroups', () => { +describe('assetMenuCategories', () => { it('offers every type exactly once', () => { - const flat = assetMenuGroups().flat(); + const flat = assetMenuCategories().flatMap((entry) => entry.types); expect([...flat].sort()).toEqual([...ASSET_TYPES].sort()); }); - it('splits into placeable first, library second (#43)', () => { - const [placeable, library] = assetMenuGroups(); - expect(placeable!.every(isPlaceableType)).toBe(true); - expect(library!.some(isPlaceableType)).toBe(false); + it('files every type under the category the asset panel shows it in, in the panel order', () => { + const menu = assetMenuCategories(); + expect(menu.map((entry) => entry.category)).toEqual([...ASSET_CATEGORIES]); + 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', () => { - for (const group of assetMenuGroups()) { - expect(labels(group)).toEqual([...labels(group)].sort()); - } - // Declaration order starts with Scene, which is not placeable and no longer leads the menu. - expect(assetMenuGroups()[0]![0]).not.toBe('scene'); + it('sorts each category by label, not by declaration order', () => { + for (const { types } of assetMenuCategories()) expect(labels(types)).toEqual([...labels(types)].sort()); + // Declaration order has Camera after Scene; by label it comes first. + expect(assetMenuCategories()[0]!.types.slice(0, 2)).toEqual(['camera', 'container']); }); it('leaves ASSET_TYPES alone – it is also the serialisation order', () => { - assetMenuGroups(); + assetMenuCategories(); expect(ASSET_TYPES[0]).toBe('scene'); }); }); diff --git a/tests/editor/point-gestures.test.ts b/tests/editor/point-gestures.test.ts index 472204cb..5dc066a4 100644 --- a/tests/editor/point-gestures.test.ts +++ b/tests/editor/point-gestures.test.ts @@ -127,7 +127,7 @@ describe('the rule of a press in a point editor', () => { expect(labels).toEqual(['Delete point', '-', 'Straight', 'Corner', 'Smooth', 'Symmetric']); expect(typed[0]).toMatchObject({ disabled: false }); const smooth = typed[4]!; - if (!('separator' in smooth)) smooth.onSelect(); + if ('onSelect' in smooth) smooth.onSelect(); expect(kinds).toEqual(['smooth']); }); }); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 10133401..0db7f5a6 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -1,8 +1,10 @@ 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. */ 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. */ export async function assets(t) { @@ -100,68 +102,72 @@ export async function assets(t) { await redo(); 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 - // can be placed in a scene, a line, then what only lives in the library. Its types do not - // all fit under the button on a canvas 800 high: there it scrolls. + // 4c. Create a new asset through the + menu. The menu lists the categories of the asset panel, in + // the panel's order; each opens the types filed under it, sorted by label, beside the menu. await clickText('+'); const rows = await json('__gm.menuRows()'); - const cut = rows.indexOf(null); - 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 opens = await json('__gm.menuSubmenus()'); const menuBox = await json('__gm.overlayRect()'); - const lastRow = await inView( - t, - '__gm.overlayRect()', - `__gm.locate(${JSON.stringify(library.at(-1))}, true)`, + 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 }), ); + 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'); check( - 'the whole menu stands on screen, last entry reached by scrolling at most', - Boolean(lastRow) && - lastRow.y < menuBox.y + menuBox.h && - menuBox.y + menuBox.h <= screenH && - menuBox.y >= 0, - JSON.stringify({ menuBox, lastRow, screenH }), + 'the submenu stands right of the menu, its first entry level with its category, all on screen', + subBox.x >= menuBox.x + menuBox.w - 4 && + Math.abs(firstEntry.y - visualsRow.y) <= 2 && + subBox.y + subBox.h <= screenH - Math.round(screenH * 0.1) + 1, + JSON.stringify({ menuBox, subBox, visualsRow, firstEntry, screenH }), ); - // A tenth of the canvas stays free below every popup. The margin used to live only in the height - // cap, never in the placement, so a popup that had to be shifted up ended flush on the bottom - // edge with the whole gap collected above it. Above it is a budget, not a distance: this menu - // hangs off a button near the top and stays there. - const above = menuBox.y; - const below = screenH - (menuBox.y + menuBox.h); - const margin = Math.round(screenH * 0.1); + const audioRow = await json("__gm.locate('Audio')"); + await mouse('mouseMoved', audioRow.x, audioRow.y); + await sleep(400); + const hovered = await json('__gm.menuRows()'); check( - 'the menu keeps a tenth of the canvas free below, and stays under its button', - below >= margin - 1 && above >= 0 && above < margin, - JSON.stringify({ above, below, margin, screenH }), + 'resting the pointer on another category swaps the submenu', + String(hovered) === String(offered.Audio), + JSON.stringify(hovered), ); + await clickText('Visuals'); await clickText('Sprite'); 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'); // 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. await showTab('scene'); await clickText('+'); - const entry = await evaluate(`__gm.locate('Behavior', true)`); - await click(entry.x, entry.y); + await pickAssetType(t, 'Behavior'); const created = await json( "(__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 sorted = (list) => list.every((v, i) => i === 0 || collator.compare(list[i - 1], v) <= 0); - const ORDER = ['Basics', 'Visuals', 'Logics', 'Physics', 'Audio']; check( 'the assets panel groups by category and type, groups and assets sorted by name', groups.length >= 4 && 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))) && groups.every((g) => sorted(g.ids.map((id) => nameOf[id]))) && 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 // a scene object's row, where its type used to be spelled out. await clickText('+'); - const menuRows = await json('__gm.menuRows()'); - const menuIcons = await json('__gm.menuIcons()'); + const menuIcons = {}; + for (const category of CATEGORIES) { + await clickText(category); + menuIcons[category] = await json('__gm.menuIcons()'); + } await shot('15d-add-menu'); await press('Escape'); + const iconRows = Object.values(menuIcons).flat(); check( 'every type in the + menu has its icon in front', - menuRows.filter(Boolean).length >= 15 && - menuRows.every((row, i) => (row === null) === (menuIcons[i] === null) && menuIcons[i] !== false), - JSON.stringify({ menuRows, menuIcons }), + iconRows.length === ASSET_TYPE_COUNT && iconRows.every((icon) => icon === true), + JSON.stringify(menuIcons), ); await showTab('scene'); const first = (await json('__gm.hierarchyOrder()'))[0]; @@ -521,8 +528,7 @@ export async function assets(t) { */ async function addMenuOnRightClick(t) { const { check, json, shot, click, clickText, press, undo, showTab } = t; - const addRows = (rows) => - Array.isArray(rows) && rows.filter(Boolean).length === ASSET_TYPE_COUNT && rows.includes('Sprite'); + const addRows = (rows) => String(rows) === String(CATEGORIES); const heading = await json("__gm.assetRowShown('group:scene')"); const sceneFolded = async () => !(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'); 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; await clickText('Behavior'); const fromMenu = await json('__gm.state.project.assets.at(-1)'); diff --git a/tools/e2e/10-text-and-enabled.mjs b/tools/e2e/10-text-and-enabled.mjs index d35a33ee..446d0f05 100644 --- a/tools/e2e/10-text-and-enabled.mjs +++ b/tools/e2e/10-text-and-enabled.mjs @@ -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. */ export async function textAndEnabled(t) { 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. await clickText('+'); - await clickText('Text'); + await pickAssetType(t, 'Text'); const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`); check('+ menu creates a text asset', Boolean(textAssetId)); await mutate(` diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index aaf3cd57..b410b92b 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -327,8 +327,8 @@ export async function textures(t) { await evaluate('__gm.console.clear()'); await clickText('+'); // 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 - // screen, and "Texture" sits near its end. + // `locate` would hand back whichever it walked into first. `pickAssetType` opens the Visuals and + // takes the hit inside their submenu. await pickAssetType(t, 'Texture'); await sleep(1500); const picker = await json('__gm.imagePicker()'); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 5c13cad6..b984d99f 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -584,14 +584,15 @@ export async function vectorArt(t) { 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 - // high the menu scrolls, and that one is scrolled to. + // Vector art sits in the submenu of the Visuals; should that outgrow a short canvas, it scrolls, + // and the entry is scrolled to. await clickText('+'); + await clickText('Visuals'); const plusRows = (await json('__gm.menuRows()')) ?? []; await inView(t, '__gm.overlayRect()', menuRow('Vector art')); const entry = await inOverlay('Vector art'); 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), JSON.stringify({ offered: plusRows.includes('Vector art'), entry }), ); diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index b7432944..6f4e4c2c 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -94,6 +94,7 @@ export async function pixelArt(t) { const palettesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'palette').length"); await showTab('scene'); await t.clickText('+'); + await t.clickText('Visuals'); const entry = await inOverlay('Pixel art'); check('the + menu offers Pixel art', Boolean(entry), JSON.stringify(await json('__gm.menuRows()'))); await click(entry.x, entry.y); diff --git a/tools/e2e/53-curves.mjs b/tools/e2e/53-curves.mjs index ed915956..cb2044ed 100644 --- a/tools/e2e/53-curves.mjs +++ b/tools/e2e/53-curves.mjs @@ -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. await showTab('scene'); await t.clickText('+'); + await t.clickText('Basics'); const offered = (await json('__gm.menuRows()')) ?? []; const entry = await inOverlay('Curve'); check('the + menu offers Curve', offered.includes('Curve') && Boolean(entry), JSON.stringify(offered)); diff --git a/tools/e2e/54-struct-types.mjs b/tools/e2e/54-struct-types.mjs index 6ff9f67e..7595b3b3 100644 --- a/tools/e2e/54-struct-types.mjs +++ b/tools/e2e/54-struct-types.mjs @@ -92,8 +92,9 @@ export async function structTypes(t) { // variables, and each field has a default. await t.showTab('scene'); await clickText('+'); + await clickText('Logics'); 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. await inView( t, diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index e3e98a30..55bc1b38 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -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 - * the canvas, and since the Instrument (#331) its last entries can sit below the fold of this - * window (`Texture` in step 12). `locate` refuses a row scrolled out of its list, so the first look is `find`, - * and a hit outside the menu (the centre tab "Texture") counts as a miss. + * Picks an asset type from the already-open `+` menu: first its category, which opens the types of + * that category beside the menu, then the type in there. A submenu could still outgrow a short + * window, and `locate` refuses a row scrolled out of its list, so the first look is `find`, and a + * hit outside the submenu (the centre tab "Texture") counts as a miss. */ 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()'); let p = await t.find(label, true); let scrolled = false;