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; protected readonly align: NonNullable; 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)); } }