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:
1 parent
d54a620ebd
commit
15ffcd5739
16 files changed
+297
-39
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-2
@@ -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';
|
||||
|
||||
@@ -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<ButtonOptions['variant']>;
|
||||
protected readonly align: NonNullable<ButtonOptions['align']>;
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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?.();
|
||||
}
|
||||
}
|
||||
+29
-6
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user