diff --git a/README.md b/README.md index bc154e36..608b3a92 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,11 @@ selected and switched back on. - **Own canvas widget toolkit** (`src/ui/`) – panels, buttons, lists, text/number/color fields, popups. The only DOM element is a hidden `` used as a keyboard bridge (IME, mobile keyboard, clipboard) while a canvas text field is focused. + Menus read as lists: entries are left-aligned and scroll once they pass 320 px instead of + running off the screen. A menu that grows with the project – the parent picker lists every + object of the scene – is opened with `{ searchable: true }`, which puts a filter box above + the entries (Enter takes the first match). A field that opens a menu is a `Dropdown`: value + flush left, chevron drawn flush right rather than written into the label. - **Vite + TypeScript**, **Vitest** for the pure core. - **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with atomic writes and an integer revision that rejects stale saves (HTTP 409). @@ -247,7 +252,7 @@ rewritten by every e2e run and the **jump-run** project by its seeder. The **pla project is the author's own and is maintained in the editor – every tool refuses to touch it. The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing, -graph, parenting, colliders, containers, text/enabled) that build on each other's scene state +graph, parenting, colliders, containers, text/enabled, menus) that build on each other's scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises. diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 82ad0582..65488830 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -1,4 +1,5 @@ import { type Application, type Container, Text } from 'pixi.js'; +import { ui, Widget } from '../ui'; import type { Editor } from './Editor'; import type { EditorState } from './state'; @@ -32,9 +33,36 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const p = node.getGlobalPosition(); return { x: p.x + node.width / 2, y: p.y + node.height / 2 }; }; + /** + * The visible text of the overlay layer, in drawing order – everything an open popup shows. + * Menus hide filtered-out entries, so this is also what a search box currently leaves. + */ + const overlayTexts = (): string[] => { + const out: string[] = []; + const walk = (node: Container): void => { + if (!node.visible) return; + if (node instanceof Text) { + if (node.text) out.push(node.text); + return; + } + for (const child of node.children) walk(child); + }; + walk(ui().overlay); + return out; + }; + /** Screen rectangle of the open popup (the backdrop is not a widget), or null when none is. */ + const overlayRect = (): { x: number; y: number; w: number; h: number } | null => { + const popups = ui().overlay.children.filter((node): node is Widget => node instanceof Widget); + const popup = popups[popups.length - 1]; + if (!popup) return null; + const p = popup.getGlobalPosition(); + return { x: p.x, y: p.y, w: popup.w, h: popup.h }; + }; (window as unknown as { __gm: unknown }).__gm = { state, locate, + overlayTexts, + overlayRect, viewportCenter: () => centreOf(viewport), viewportInfo: () => viewport.debugInfo(), gameInfo: () => gameView.debugInfo(), diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 2e09a805..433b0124 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -19,7 +19,7 @@ import { type VariableKind, } from '../../core/properties/PropertySchema'; import { isModified, resolveAssetProps } from '../../core/properties/resolve'; -import { Button, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; +import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; import { previewingHandlers } from './previewHandlers'; @@ -122,7 +122,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { const header = new HStack({ gap: Theme.gap, align: 'center' }); header.add(new Label('VARIABLES', { style: 'title' }), 1); - const add = new Button('+ Variable ▾', { variant: 'ghost' }); + const add = new Dropdown('+ Variable', { variant: 'ghost' }); add.onClick = () => { const items = VARIABLE_KINDS.map((kind) => ({ label: VARIABLE_KIND_LABELS[kind], diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 0dc12dc4..087dc6e1 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -4,6 +4,7 @@ import { Button, Checkbox, ColorField, + Dropdown, HStack, Label, Menu, @@ -162,13 +163,13 @@ export function createField(def: PropertyDef, value: PropertyValue, handlers: Fi case 'enum': { const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—'); - const button = new Button(`${labelOf(value)} ▾`); + const button = new Dropdown(labelOf(value)); button.onClick = () => { new Menu( def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })), ).openBelow(button); }; - return { widget: button, grow: true, setValue: (v) => button.setText(`${labelOf(v)} ▾`) }; + return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) }; } case 'key': { const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key)); diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index cfd9802b..83365b9c 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -31,7 +31,7 @@ import { deviatesFromTemplate, resolveSceneObjectProps, } from '../../core/properties/resolve'; -import { Button, Menu, type MenuItem, TextField } from '../../ui'; +import { Button, Dropdown, Menu, type MenuItem, TextField } from '../../ui'; import { addCollider, createContainer, removeObjects } from '../actions'; import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; @@ -368,7 +368,7 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) const { row } = labelledRow('Parent'); const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?'; const parent = findObject(scene, object.parentId); - const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`); + const button = new Dropdown(parent ? nameOf(parent) : 'None'); button.onClick = () => { const items: MenuItem[] = [ { label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) }, @@ -381,7 +381,8 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)), }); } - new Menu(items).openBelow(button); + // Searchable: this lists every object of the scene, which grows with the project. + new Menu(items, { searchable: true }).openBelow(button); }; row.add(button, 1); return row; diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index b6f13ca1..f1342408 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -1,14 +1,14 @@ import { Graphics } from 'pixi.js'; import { RenameProjectCommand } from '../../core/commands/projectCommands'; import { listScenes } from '../../core/model/Project'; -import { Button, HStack, Label, Spacer, TextField, Theme, type Widget } from '../../ui'; +import { Button, Dropdown, HStack, Label, Spacer, TextField, Theme, type Widget } from '../../ui'; import type { EditorState, SaveStatus } from '../state'; /** What the toolbar buttons trigger; the editor supplies the implementations. */ export interface ToolbarActions { - /** "Project ▾": list, create and delete projects. `anchor` is the button to open the menu under. */ + /** The "Project" dropdown: list, create and delete projects. `anchor` is the button to open the menu under. */ openProjectMenu(anchor: Widget): void; - /** " ▾": switch the active scene. */ + /** The scene dropdown: switch the active scene. */ openSceneMenu(anchor: Widget): void; /** Play in edit mode, Stop in play mode. */ togglePlay(): void; @@ -26,7 +26,7 @@ const SAVE_LABELS: Record = { export class Toolbar extends HStack { private readonly bg = new Graphics(); private readonly projectName: TextField; - private readonly sceneButton: Button; + private readonly sceneButton: Dropdown; private readonly undoButton: Button; private readonly redoButton: Button; private readonly playButton: Button; @@ -42,7 +42,7 @@ export class Toolbar extends HStack { this.minHeight = Theme.controlHeight + Theme.padding * 2; this.addChildAt(this.bg, 0); - const projectButton = new Button('Project ▾', { onClick: () => actions.openProjectMenu(projectButton) }); + const projectButton = new Dropdown('Project', { onClick: () => actions.openProjectMenu(projectButton) }); this.add(projectButton); this.projectName = new TextField('', { width: 180, @@ -56,7 +56,7 @@ export class Toolbar extends HStack { this.add(new Spacer(Theme.padding)); this.add(new Label('Scene', { style: 'muted' })); - this.sceneButton = new Button('—', { + this.sceneButton = new Dropdown('—', { width: 160, onClick: () => actions.openSceneMenu(this.sceneButton), }); @@ -105,7 +105,7 @@ export class Toolbar extends HStack { this.projectName.setValue(state.project.name); const scene = state.activeScene; const scenes = listScenes(state.project); - this.sceneButton.setText(scene ? `${scene.name} ▾` : scenes.length ? 'Select scene ▾' : 'No scene ▾'); + this.sceneButton.setText(scene ? scene.name : scenes.length ? 'Select scene' : 'No scene'); const editing = state.mode === 'edit'; // Undo/redo work in play mode too – on the runtime copy's own history. this.undoButton.setEnabled(state.commands.canUndo); diff --git a/src/ui/index.ts b/src/ui/index.ts index e4bbe1fd..c769735f 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -5,14 +5,15 @@ export { initUI, ui, type UIContext } from './UIContext'; export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw'; export { clamp, centreY, zoomFactor } from './math'; export { Label } from './widgets/Label'; -export { Button } from './widgets/Button'; +export { Button, type ButtonOptions } from './widgets/Button'; +export { Dropdown } from './widgets/Dropdown'; export { Checkbox } from './widgets/Checkbox'; export { Panel } from './widgets/Panel'; export { Splitter } from './widgets/Splitter'; export { ScrollContainer } from './widgets/ScrollContainer'; export { ListView, type ListItem, type PickModifiers } from './widgets/ListView'; export { Popup, type PopupOptions } from './widgets/Popup'; -export { Menu, type MenuItem } from './widgets/Menu'; +export { Menu, type MenuItem, type MenuOptions } from './widgets/Menu'; export { Dialog, type DialogButton } from './widgets/Dialog'; export { TextField } from './widgets/TextField'; export { NumberField } from './widgets/NumberField'; diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index ccb59b16..f7ad8fb5 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -9,6 +9,11 @@ export interface ButtonOptions { variant?: 'default' | 'primary' | 'ghost' | 'danger'; /** Fixed width; otherwise measured from the label. */ width?: number; + /** + * Label position in a button wider than its text. 'center' suits standalone buttons, + * 'left' the ones that read as a list or a field (menu entries, dropdowns). + */ + align?: 'center' | 'left'; onClick?: () => void; } @@ -20,11 +25,13 @@ export class Button extends Widget { /** Caller-owned toggle state: renders like a primary button but is not flipped by clicks. */ toggled = false; variant: NonNullable; + protected readonly align: NonNullable; onClick?: () => void; constructor(text: string, options: ButtonOptions = {}) { super(); this.variant = options.variant ?? 'default'; + this.align = options.align ?? 'center'; this.onClick = options.onClick; this.focusable = true; this.cursor = 'pointer'; @@ -70,6 +77,11 @@ export class Button extends Widget { this.invalidate(); } + /** Whether the label sits on a filled accent surface and therefore needs the light style. */ + protected get onAccent(): boolean { + return this.toggled || this.variant === 'primary' || this.variant === 'danger'; + } + override measure(): Size { const metrics = CanvasTextMetrics.measureText(this.labelText.text, this.labelText.style); return { @@ -119,9 +131,9 @@ export class Button extends Widget { } else if (stroke !== null) { this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke }); } - const onAccent = this.toggled || this.variant === 'primary' || this.variant === 'danger'; - this.labelText.style = textStyle(onAccent ? 'accent' : 'body'); + this.labelText.style = textStyle(this.onAccent ? 'accent' : 'body'); const label = this.labelText; - label.position.set(Math.round((this.w - label.width) / 2), centreY(this.h, label.height)); + const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2; + label.position.set(Math.round(x), centreY(this.h, label.height)); } } diff --git a/src/ui/widgets/Dropdown.ts b/src/ui/widgets/Dropdown.ts new file mode 100644 index 00000000..e97f03ea --- /dev/null +++ b/src/ui/widgets/Dropdown.ts @@ -0,0 +1,38 @@ +import { Graphics } from 'pixi.js'; +import { Theme } from '../Theme'; +import type { Size } from '../Widget'; +import { Button, type ButtonOptions } from './Button'; + +/** Space reserved at the right edge for the chevron. */ +const CHEVRON_SLOT = 16; + +/** + * A button that opens a menu: value flush left, chevron flush right. The chevron is drawn + * rather than written into the label, so the label stays the plain value – which keeps it + * readable, lets it be compared against the menu entries, and stays crisp at any size. + */ +export class Dropdown extends Button { + private readonly chevron = new Graphics(); + + constructor(label: string, options: ButtonOptions = {}) { + super(label, { ...options, align: 'left' }); + this.addChild(this.chevron); + } + + override measure(): Size { + const size = super.measure(); + return { w: size.w + CHEVRON_SLOT, h: size.h }; + } + + override redraw(): void { + super.redraw(); + const x = this.w - CHEVRON_SLOT / 2 - Theme.padding / 2; + const y = this.h / 2; + this.chevron + .clear() + .moveTo(x - 4, y - 2) + .lineTo(x + 4, y - 2) + .lineTo(x, y + 3) + .fill(this.onAccent ? Theme.colors.accentText : Theme.colors.textMuted); + } +} diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts index f5a33eeb..1f7af668 100644 --- a/src/ui/widgets/Menu.ts +++ b/src/ui/widgets/Menu.ts @@ -1,5 +1,11 @@ +import { VStack } from '../layout/Stack'; +import { Theme } from '../Theme'; +import { ui } from '../UIContext'; import { Button } from './Button'; +import { Label } from './Label'; import { Popup } from './Popup'; +import { ScrollContainer } from './ScrollContainer'; +import { TextField } from './TextField'; export interface MenuItem { label: string; @@ -8,16 +14,89 @@ export interface MenuItem { disabled?: boolean; } -/** A vertical list of clickable actions. Picking one closes the menu before it runs. */ +export interface MenuOptions { + /** + * Adds a search box above the entries that filters them as you type; Enter picks the first + * match. For menus that can grow with the project (the parent picker lists every object). + */ + searchable?: boolean; +} + +/** How tall the entry list gets before it starts scrolling. */ +const MAX_LIST_HEIGHT = 320; + +/** + * A vertical list of clickable actions. Picking one closes the menu before it runs. + * Entries read as a list: left-aligned, scrolled once they no longer fit on screen. + */ export class Menu extends Popup { - constructor(items: MenuItem[]) { - super({ padding: 4, gap: 2 }); + private readonly entries: { button: Button; haystack: string }[] = []; + private readonly list = new VStack({ gap: 2 }); + private readonly scroll = new ScrollContainer(); + private readonly empty = new Label('No match', { style: 'muted', padding: Theme.padding }); + private readonly search: TextField | null; + + constructor(items: MenuItem[], options: MenuOptions = {}) { + super({ padding: 4, gap: 4 }); this.minWidth = 160; + this.search = options.searchable + ? new TextField('', { + placeholder: 'Search…', + onInput: () => this.filter(), + // Enter only. NOT onChange: that also fires when the field loses focus because an + // entry was clicked, which would then run the first match instead of the clicked one. + onSubmit: () => this.pickFirstMatch(), + }) + : null; + if (this.search) this.content.add(this.search); + for (const item of items) { - const button = new Button(item.label, { variant: item.danger ? 'danger' : 'ghost' }); + const button = new Button(item.label, { + variant: item.danger ? 'danger' : 'ghost', + align: 'left', + }); button.setEnabled(!item.disabled); button.onClick = this.closeThen(item.onSelect); - this.content.add(button); + this.list.add(button); + this.entries.push({ button, haystack: item.label.toLowerCase() }); } + this.list.add(this.empty); + this.scroll.setContent(this.list); + this.content.add(this.scroll); + this.filter(); + } + + override open(globalX: number, globalY: number): void { + super.open(globalX, globalY); + // open() blurs whatever had focus, so this has to come after it. + if (this.search) ui().focus.focus(this.search); + } + + /** Shows the entries matching the search box and resizes the menu around them. */ + private filter(): void { + const query = this.search?.value.trim().toLowerCase() ?? ''; + let matches = 0; + for (const entry of this.entries) { + entry.button.visible = entry.haystack.includes(query); + if (entry.button.visible) matches++; + } + this.empty.visible = matches === 0; + + const size = this.list.measure(); + const scrolls = size.h > MAX_LIST_HEIGHT; + this.scroll.minWidth = size.w + (scrolls ? Theme.scrollbarWidth + 4 : 0); + this.scroll.minHeight = Math.min(size.h, MAX_LIST_HEIGHT); + this.scroll.scrollTo(0); + this.refreshLayout(); + // Both calls are needed: a resize alone re-lays out only when the size actually changed, + // and two different filters can leave the list exactly as tall as before while showing + // an entirely different set of entries. + this.scroll.relayoutContent(); + this.list.relayout(); + } + + private pickFirstMatch(): void { + const first = this.entries.find((entry) => entry.button.visible && entry.button.enabled); + first?.button.onClick?.(); } } diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts index 7f011e6e..e96a8125 100644 --- a/src/ui/widgets/Popup.ts +++ b/src/ui/widgets/Popup.ts @@ -24,6 +24,9 @@ export class Popup extends Widget { readonly content: VStack; onClose?: () => void; private readonly modal: boolean; + /** Where `open()` was asked to put the top-left corner, before clamping to the screen. */ + private openX = 0; + private openY = 0; constructor(options: PopupOptions = {}) { super(); @@ -56,18 +59,38 @@ export class Popup extends Widget { .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); - const size = this.measure(); - this.resize(size.w, size.h); - const x = clamp(globalX, 0, screen.width - size.w); - const y = clamp(globalY, 0, screen.height - size.h); - this.position.set(Math.round(x), Math.round(y)); + this.openX = globalX; + this.openY = globalY; + this.place(); focus.blur(); window.addEventListener('keydown', this.onEscape, { capture: true }); } - /** Opens left-aligned with `anchor`, `gap` px below its bottom edge (dropdown menus, pickers). */ + /** + * Re-measures and repositions an open popup after its content changed size (a menu whose + * entries were filtered). No-op while closed, so it is safe to call during construction. + */ + refreshLayout(): void { + if (this.isOpen) this.place(); + } + + /** Sizes the popup to its content and puts it at the requested spot, shifted to stay on screen. */ + private place(): void { + const screen = ui().app.screen; + const size = this.measure(); + this.resize(size.w, size.h); + const x = clamp(this.openX, 0, Math.max(0, screen.width - size.w)); + const y = clamp(this.openY, 0, Math.max(0, screen.height - size.h)); + this.position.set(Math.round(x), Math.round(y)); + } + + /** + * Opens left-aligned with `anchor`, `gap` px below its bottom edge (dropdown menus, pickers). + * The popup is never narrower than the anchor, so a menu reads as the field it drops from. + */ openBelow(anchor: Widget, gap = 2): void { const g = anchor.getGlobalPosition(); + this.minWidth = Math.max(this.minWidth, anchor.w); this.open(g.x, g.y + anchor.h + gap); } diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 192d9135..40a33a1f 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -171,10 +171,10 @@ export async function graph(t) { await holding; await stop(); - // 13g. Variables: "+ Variable ▾" → Number adds "var1" with default 0; undo removes it. + // 13g. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it. await select({ kind: 'asset', assetId: 'mover' }); await sleep(150); - await clickText('+ Variable ▾'); + await clickText('+ Variable'); await clickText('Number'); await sleep(100); check( diff --git a/tools/e2e/07-parenting.mjs b/tools/e2e/07-parenting.mjs index a35f07d2..28d380eb 100644 --- a/tools/e2e/07-parenting.mjs +++ b/tools/e2e/07-parenting.mjs @@ -90,18 +90,33 @@ export async function parenting(t) { JSON.stringify(o2), ); - // 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu. + // 14c. Parent via the inspector menu: the picker is searchable, Enter takes the single match. await clickText('Blue crate'); - await clickText('None ▾'); - const menuEntry = await locate('Red crate', true); // the popup is drawn last; the hierarchy row has the same text - await click(menuEntry.x, menuEntry.y); - await sleep(100); + await clickText('None'); // the Parent dropdown, labelled with the current parent + const entries = await json('__gm.overlayTexts()'); + check( + 'the parent picker offers a search box and lists the candidates', + entries.includes('Search…') && entries.includes('None') && entries.includes('Red crate'), + JSON.stringify(entries), + ); + await typeText('red'); + await sleep(150); + const filtered = await json('__gm.overlayTexts()'); + check( + 'typing filters the picker down to the matching entries', + same(filtered, ['red', 'Red crate']), + JSON.stringify(filtered), + ); + await shot('30-parent-search'); + await press('Enter'); // takes the first match + await sleep(150); o2 = await object('o2'); check( 'inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2), ); + check('the picker closed after Enter', same(await json('__gm.overlayTexts()'), [])); const blueRow2 = await locate('Blue crate'); await click(blueRow2.x, blueRow2.y, 'right'); await clickText('Unparent (move to top level)'); diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs index 3c19f6c6..bb6ed04c 100644 --- a/tools/e2e/08-colliders.mjs +++ b/tools/e2e/08-colliders.mjs @@ -50,7 +50,7 @@ export async function colliders(t) { 'collider is drawn in the overlay layer', colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay', ); - check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box ▾')`))); + check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box')`))); await shot('21-collider'); await undo(); const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false; diff --git a/tools/e2e/11-menus.mjs b/tools/e2e/11-menus.mjs new file mode 100644 index 00000000..765472ed --- /dev/null +++ b/tools/e2e/11-menus.mjs @@ -0,0 +1,53 @@ +/** + * Menus that can grow with the project. Runs last: it fills the scene with throwaway objects + * to make the parent picker overflow, which the earlier steps' object counts would not survive. + */ +export async function menus(t) { + const { check, sleep, json, shot, click, locate, mouse, mutate } = t; + + await mutate(` + const scene = __gm.state.activeScene; + const source = scene.objects.find(o => o.id === 'o1'); + for (let i = 1; i <= 40; i++) { + const copy = structuredClone(source); + copy.id = 'filler' + i; + copy.name = 'Filler ' + i; + copy.parentId = ''; + scene.objects.push(copy); + } + __gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`); + await sleep(150); + + // 18. A picker with 40+ entries is capped in height and stays fully on screen instead of + // running off the bottom edge. + // The dropdown sits right of the 64 px label column; locate() gives the label's text centre. + const parentRow = await locate('Parent'); + await click(parentRow.x + 55, parentRow.y); + const rect = await json('__gm.overlayRect()'); + const screenHeight = await json('window.innerHeight'); + check( + 'a long parent picker is capped instead of growing with the object count', + rect && rect.h < 420, + JSON.stringify(rect), + ); + check( + 'the capped picker stays fully on screen', + rect && rect.y >= 0 && rect.y + rect.h <= screenHeight, + JSON.stringify({ rect, screenHeight }), + ); + await shot('31-long-parent-menu'); + + // 18a. The entries scroll under the wheel; the search box above them does not move. + const firstBefore = await locate('None', true); + const searchBefore = await locate('Search…', true); + await mouse('mouseWheel', rect.x + rect.w / 2, rect.y + rect.h / 2, 'none', { deltaX: 0, deltaY: 240 }); + await sleep(150); + const firstAfter = await locate('None', true); + const searchAfter = await locate('Search…', true); + check( + 'the wheel scrolls the entries', + firstAfter.y < firstBefore.y - 100, + JSON.stringify({ firstBefore, firstAfter }), + ); + check('the search box stays put while the entries scroll', searchAfter.y === searchBefore.y); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 37c70698..8ff140f3 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -13,6 +13,7 @@ import { parenting } from './07-parenting.mjs'; import { colliders } from './08-colliders.mjs'; import { containers } from './09-containers.mjs'; import { textAndEnabled } from './10-text-and-enabled.mjs'; +import { menus } from './11-menus.mjs'; export const STEPS = [ hierarchy, @@ -25,4 +26,5 @@ export const STEPS = [ colliders, containers, textAndEnabled, + menus, ];