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

+28
View File
@@ -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(),
+2 -2
View File
@@ -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],
+3 -2
View File
@@ -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));
+4 -3
View File
@@ -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;
+7 -7
View File
@@ -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;
/** "<scene> ▾": 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<SaveStatus, string> = {
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);