Left-align menu entries, add a Dropdown field, scrollable and searchable menus

Menu entries are left-aligned and live in a ScrollContainer capped at 320 px, so a
long list (the parent picker holds every object of the scene) scrolls instead of
running off the screen. `Menu(items, { searchable: true })` puts a filter box above
the scroll area; Enter takes the first match. Filtering forces a relayout because
two queries with the same hit count leave the height unchanged, which would keep
the stale row positions.

Fields that open a menu are a new `Dropdown` widget: the value sits flush left and
the chevron is drawn flush right instead of being written into the label. A menu
opened below a field is never narrower than that field.

New debug hooks `overlayTexts()` / `overlayRect()` make the popup contents
assertable; e2e step 11 covers the cap, the scrolling and the search box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 11:31:14 +02:00
1 parent d54a620ebd
commit 15ffcd5739
16 files changed
+297 -39

No files matched your search

+6 -1
View File
@@ -180,6 +180,11 @@ selected and switched back on.
- **Own canvas widget toolkit** (`src/ui/`) – panels, buttons, lists, text/number/color fields, - **Own canvas widget toolkit** (`src/ui/`) – panels, buttons, lists, text/number/color fields,
popups. The only DOM element is a hidden `<input>` used as a keyboard bridge (IME, mobile popups. The only DOM element is a hidden `<input>` used as a keyboard bridge (IME, mobile
keyboard, clipboard) while a canvas text field is focused. 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. - **Vite + TypeScript**, **Vitest** for the pure core.
- **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with - **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). 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. 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, 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)`, 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. `world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises.
+28
View File
@@ -1,4 +1,5 @@
import { type Application, type Container, Text } from 'pixi.js'; import { type Application, type Container, Text } from 'pixi.js';
import { ui, Widget } from '../ui';
import type { Editor } from './Editor'; import type { Editor } from './Editor';
import type { EditorState } from './state'; import type { EditorState } from './state';
@@ -32,9 +33,36 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = node.getGlobalPosition(); const p = node.getGlobalPosition();
return { x: p.x + node.width / 2, y: p.y + node.height / 2 }; 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 = { (window as unknown as { __gm: unknown }).__gm = {
state, state,
locate, locate,
overlayTexts,
overlayRect,
viewportCenter: () => centreOf(viewport), viewportCenter: () => centreOf(viewport),
viewportInfo: () => viewport.debugInfo(), viewportInfo: () => viewport.debugInfo(),
gameInfo: () => gameView.debugInfo(), gameInfo: () => gameView.debugInfo(),
+2 -2
View File
@@ -19,7 +19,7 @@ import {
type VariableKind, type VariableKind,
} from '../../core/properties/PropertySchema'; } from '../../core/properties/PropertySchema';
import { isModified, resolveAssetProps } from '../../core/properties/resolve'; 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 { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host'; import { addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers'; import { previewingHandlers } from './previewHandlers';
@@ -122,7 +122,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const header = new HStack({ gap: Theme.gap, align: 'center' }); const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1); header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Button('+ Variable ▾', { variant: 'ghost' }); const add = new Dropdown('+ Variable', { variant: 'ghost' });
add.onClick = () => { add.onClick = () => {
const items = VARIABLE_KINDS.map((kind) => ({ const items = VARIABLE_KINDS.map((kind) => ({
label: VARIABLE_KIND_LABELS[kind], label: VARIABLE_KIND_LABELS[kind],
+3 -2
View File
@@ -4,6 +4,7 @@ import {
Button, Button,
Checkbox, Checkbox,
ColorField, ColorField,
Dropdown,
HStack, HStack,
Label, Label,
Menu, Menu,
@@ -162,13 +163,13 @@ export function createField(def: PropertyDef, value: PropertyValue, handlers: Fi
case 'enum': { case 'enum': {
const labelOf = (v: PropertyValue) => const labelOf = (v: PropertyValue) =>
def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—'); 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 = () => { button.onClick = () => {
new Menu( new Menu(
def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })), def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })),
).openBelow(button); ).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': { case 'key': {
const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key)); const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key));
+4 -3
View File
@@ -31,7 +31,7 @@ import {
deviatesFromTemplate, deviatesFromTemplate,
resolveSceneObjectProps, resolveSceneObjectProps,
} from '../../core/properties/resolve'; } 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 { addCollider, createContainer, removeObjects } from '../actions';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory'; import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host'; 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 { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?'; const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
const parent = findObject(scene, object.parentId); const parent = findObject(scene, object.parentId);
const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`); const button = new Dropdown(parent ? nameOf(parent) : 'None');
button.onClick = () => { button.onClick = () => {
const items: MenuItem[] = [ const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) }, { 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)), 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); row.add(button, 1);
return row; return row;
+7 -7
View File
@@ -1,14 +1,14 @@
import { Graphics } from 'pixi.js'; import { Graphics } from 'pixi.js';
import { RenameProjectCommand } from '../../core/commands/projectCommands'; import { RenameProjectCommand } from '../../core/commands/projectCommands';
import { listScenes } from '../../core/model/Project'; 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'; import type { EditorState, SaveStatus } from '../state';
/** What the toolbar buttons trigger; the editor supplies the implementations. */ /** What the toolbar buttons trigger; the editor supplies the implementations. */
export interface ToolbarActions { 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; openProjectMenu(anchor: Widget): void;
/** "<scene> ▾": switch the active scene. */ /** The scene dropdown: switch the active scene. */
openSceneMenu(anchor: Widget): void; openSceneMenu(anchor: Widget): void;
/** Play in edit mode, Stop in play mode. */ /** Play in edit mode, Stop in play mode. */
togglePlay(): void; togglePlay(): void;
@@ -26,7 +26,7 @@ const SAVE_LABELS: Record<SaveStatus, string> = {
export class Toolbar extends HStack { export class Toolbar extends HStack {
private readonly bg = new Graphics(); private readonly bg = new Graphics();
private readonly projectName: TextField; private readonly projectName: TextField;
private readonly sceneButton: Button; private readonly sceneButton: Dropdown;
private readonly undoButton: Button; private readonly undoButton: Button;
private readonly redoButton: Button; private readonly redoButton: Button;
private readonly playButton: Button; private readonly playButton: Button;
@@ -42,7 +42,7 @@ export class Toolbar extends HStack {
this.minHeight = Theme.controlHeight + Theme.padding * 2; this.minHeight = Theme.controlHeight + Theme.padding * 2;
this.addChildAt(this.bg, 0); 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.add(projectButton);
this.projectName = new TextField('', { this.projectName = new TextField('', {
width: 180, width: 180,
@@ -56,7 +56,7 @@ export class Toolbar extends HStack {
this.add(new Spacer(Theme.padding)); this.add(new Spacer(Theme.padding));
this.add(new Label('Scene', { style: 'muted' })); this.add(new Label('Scene', { style: 'muted' }));
this.sceneButton = new Button('—', { this.sceneButton = new Dropdown('—', {
width: 160, width: 160,
onClick: () => actions.openSceneMenu(this.sceneButton), onClick: () => actions.openSceneMenu(this.sceneButton),
}); });
@@ -105,7 +105,7 @@ export class Toolbar extends HStack {
this.projectName.setValue(state.project.name); this.projectName.setValue(state.project.name);
const scene = state.activeScene; const scene = state.activeScene;
const scenes = listScenes(state.project); 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'; const editing = state.mode === 'edit';
// Undo/redo work in play mode too – on the runtime copy's own history. // Undo/redo work in play mode too – on the runtime copy's own history.
this.undoButton.setEnabled(state.commands.canUndo); this.undoButton.setEnabled(state.commands.canUndo);
+3 -2
View File
@@ -5,14 +5,15 @@ export { initUI, ui, type UIContext } from './UIContext';
export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw'; export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
export { clamp, centreY, zoomFactor } from './math'; export { clamp, centreY, zoomFactor } from './math';
export { Label } from './widgets/Label'; 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 { Checkbox } from './widgets/Checkbox';
export { Panel } from './widgets/Panel'; export { Panel } from './widgets/Panel';
export { Splitter } from './widgets/Splitter'; export { Splitter } from './widgets/Splitter';
export { ScrollContainer } from './widgets/ScrollContainer'; export { ScrollContainer } from './widgets/ScrollContainer';
export { ListView, type ListItem, type PickModifiers } from './widgets/ListView'; export { ListView, type ListItem, type PickModifiers } from './widgets/ListView';
export { Popup, type PopupOptions } from './widgets/Popup'; 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 { Dialog, type DialogButton } from './widgets/Dialog';
export { TextField } from './widgets/TextField'; export { TextField } from './widgets/TextField';
export { NumberField } from './widgets/NumberField'; export { NumberField } from './widgets/NumberField';
+15 -3
View File
@@ -9,6 +9,11 @@ export interface ButtonOptions {
variant?: 'default' | 'primary' | 'ghost' | 'danger'; variant?: 'default' | 'primary' | 'ghost' | 'danger';
/** Fixed width; otherwise measured from the label. */ /** Fixed width; otherwise measured from the label. */
width?: number; 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; 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. */ /** Caller-owned toggle state: renders like a primary button but is not flipped by clicks. */
toggled = false; toggled = false;
variant: NonNullable<ButtonOptions['variant']>; variant: NonNullable<ButtonOptions['variant']>;
protected readonly align: NonNullable<ButtonOptions['align']>;
onClick?: () => void; onClick?: () => void;
constructor(text: string, options: ButtonOptions = {}) { constructor(text: string, options: ButtonOptions = {}) {
super(); super();
this.variant = options.variant ?? 'default'; this.variant = options.variant ?? 'default';
this.align = options.align ?? 'center';
this.onClick = options.onClick; this.onClick = options.onClick;
this.focusable = true; this.focusable = true;
this.cursor = 'pointer'; this.cursor = 'pointer';
@@ -70,6 +77,11 @@ export class Button extends Widget {
this.invalidate(); 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 { override measure(): Size {
const metrics = CanvasTextMetrics.measureText(this.labelText.text, this.labelText.style); const metrics = CanvasTextMetrics.measureText(this.labelText.text, this.labelText.style);
return { return {
@@ -119,9 +131,9 @@ export class Button extends Widget {
} else if (stroke !== null) { } else if (stroke !== null) {
this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke }); 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(this.onAccent ? 'accent' : 'body');
this.labelText.style = textStyle(onAccent ? 'accent' : 'body');
const label = this.labelText; 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));
} }
} }
+38
View File
@@ -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);
}
}
+84 -5
View File
@@ -1,5 +1,11 @@
import { VStack } from '../layout/Stack';
import { Theme } from '../Theme';
import { ui } from '../UIContext';
import { Button } from './Button'; import { Button } from './Button';
import { Label } from './Label';
import { Popup } from './Popup'; import { Popup } from './Popup';
import { ScrollContainer } from './ScrollContainer';
import { TextField } from './TextField';
export interface MenuItem { export interface MenuItem {
label: string; label: string;
@@ -8,16 +14,89 @@ export interface MenuItem {
disabled?: boolean; 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 { export class Menu extends Popup {
constructor(items: MenuItem[]) { private readonly entries: { button: Button; haystack: string }[] = [];
super({ padding: 4, gap: 2 }); 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.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) { 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.setEnabled(!item.disabled);
button.onClick = this.closeThen(item.onSelect); 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?.();
} }
} }
+29 -6
View File
@@ -24,6 +24,9 @@ export class Popup extends Widget {
readonly content: VStack; readonly content: VStack;
onClose?: () => void; onClose?: () => void;
private readonly modal: boolean; 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 = {}) { constructor(options: PopupOptions = {}) {
super(); super();
@@ -56,18 +59,38 @@ export class Popup extends Widget {
.fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 }); .fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 });
this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height); this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height);
overlay.addChild(this.backdrop, this); overlay.addChild(this.backdrop, this);
const size = this.measure(); this.openX = globalX;
this.resize(size.w, size.h); this.openY = globalY;
const x = clamp(globalX, 0, screen.width - size.w); this.place();
const y = clamp(globalY, 0, screen.height - size.h);
this.position.set(Math.round(x), Math.round(y));
focus.blur(); focus.blur();
window.addEventListener('keydown', this.onEscape, { capture: true }); 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 { openBelow(anchor: Widget, gap = 2): void {
const g = anchor.getGlobalPosition(); const g = anchor.getGlobalPosition();
this.minWidth = Math.max(this.minWidth, anchor.w);
this.open(g.x, g.y + anchor.h + gap); this.open(g.x, g.y + anchor.h + gap);
} }
+2 -2
View File
@@ -171,10 +171,10 @@ export async function graph(t) {
await holding; await holding;
await stop(); 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 select({ kind: 'asset', assetId: 'mover' });
await sleep(150); await sleep(150);
await clickText('+ Variable ▾'); await clickText('+ Variable');
await clickText('Number'); await clickText('Number');
await sleep(100); await sleep(100);
check( check(
+20 -5
View File
@@ -90,18 +90,33 @@ export async function parenting(t) {
JSON.stringify(o2), 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('Blue crate');
await clickText('None ▾'); await clickText('None'); // the Parent dropdown, labelled with the current parent
const menuEntry = await locate('Red crate', true); // the popup is drawn last; the hierarchy row has the same text const entries = await json('__gm.overlayTexts()');
await click(menuEntry.x, menuEntry.y); check(
await sleep(100); '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'); o2 = await object('o2');
check( check(
'inspector Parent menu parents the object', 'inspector Parent menu parents the object',
o2.parentId === 'o1' && o2.position.x === 270, o2.parentId === 'o1' && o2.position.x === 270,
JSON.stringify(o2), JSON.stringify(o2),
); );
check('the picker closed after Enter', same(await json('__gm.overlayTexts()'), []));
const blueRow2 = await locate('Blue crate'); const blueRow2 = await locate('Blue crate');
await click(blueRow2.x, blueRow2.y, 'right'); await click(blueRow2.x, blueRow2.y, 'right');
await clickText('Unparent (move to top level)'); await clickText('Unparent (move to top level)');
+1 -1
View File
@@ -50,7 +50,7 @@ export async function colliders(t) {
'collider is drawn in the overlay layer', 'collider is drawn in the overlay layer',
colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay', 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 shot('21-collider');
await undo(); await undo();
const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false; const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false;
+53
View File
@@ -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);
}
+2
View File
@@ -13,6 +13,7 @@ import { parenting } from './07-parenting.mjs';
import { colliders } from './08-colliders.mjs'; import { colliders } from './08-colliders.mjs';
import { containers } from './09-containers.mjs'; import { containers } from './09-containers.mjs';
import { textAndEnabled } from './10-text-and-enabled.mjs'; import { textAndEnabled } from './10-text-and-enabled.mjs';
import { menus } from './11-menus.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -25,4 +26,5 @@ export const STEPS = [
colliders, colliders,
containers, containers,
textAndEnabled, textAndEnabled,
menus,
]; ];