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

+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);
}
}