Files
feuerwerk/src/ui/widgets/Button.ts
T
rootandClaude Opus 5 15ffcd5739 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>
2026-09-08 11:31:14 +02:00

140 lines
4.7 KiB
TypeScript

import { type FederatedPointerEvent, Graphics, Text, CanvasTextMetrics } from 'pixi.js';
import { drawControlBox } from '../draw';
import { centreY } from '../math';
import { Theme, textStyle } from '../Theme';
import { type Size, Widget } from '../Widget';
export interface ButtonOptions {
/** Visual weight: 'primary' uses the accent color, 'ghost' has no background until hovered. */
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;
}
/** Push button; Enter/Space activate it while focused. Clicks fire only for the primary pointer button. */
export class Button extends Widget {
private readonly bg = new Graphics();
private readonly labelText: Text;
private pressed = false;
/** 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';
this.minHeight = Theme.controlHeight;
if (options.width) this.minWidth = options.width;
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') });
this.addChild(this.bg, this.labelText);
// Leaving while pressed cancels the click: a release elsewhere must not activate.
this.trackHover(() => {
this.pressed = false;
});
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button !== 0) return;
this.pressed = true;
this.invalidate();
});
this.on('pointerup', (e: FederatedPointerEvent) => {
if (e.button !== 0 || !this.pressed) return;
this.pressed = false;
this.invalidate();
this.onClick?.();
});
this.on('pointerupoutside', () => {
this.pressed = false;
this.invalidate();
});
}
get text(): string {
return this.labelText.text;
}
/** Repaints only; the parent must relayout when the measured width matters. */
setText(text: string): void {
this.labelText.text = text;
this.invalidate();
}
setToggled(toggled: boolean): void {
if (this.toggled === toggled) return;
this.toggled = toggled;
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 {
w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2),
h: Math.max(this.minHeight, Theme.controlHeight),
};
}
override onKeyDown(event: KeyboardEvent): boolean {
if (event.key === 'Enter' || event.key === ' ') {
this.onClick?.();
return true;
}
return false;
}
override redraw(): void {
const c = Theme.colors;
const flat = this.variant === 'default' || this.variant === 'ghost';
let fill: number | null = c.control;
let border: number | null = c.border;
if (this.variant === 'primary') {
fill = c.accent;
border = null;
}
if (this.variant === 'danger') {
fill = c.danger;
border = null;
}
if (this.variant === 'ghost') {
fill = null;
border = null;
}
if (this.toggled) {
fill = c.accent;
border = null;
}
if (this.hovered && !this.toggled && flat) fill = c.controlHover;
if (this.pressed && flat) fill = c.controlActive;
const stroke = this.focused ? c.accent : border;
this.bg.clear();
if (fill !== null && stroke !== null) {
drawControlBox(this.bg, this.w, this.h, { fill, border: stroke });
} else if (fill !== null) {
this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(fill);
} 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.labelText.style = textStyle(this.onAccent ? 'accent' : 'body');
const label = this.labelText;
const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2;
label.position.set(Math.round(x), centreY(this.h, label.height));
}
}