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'; import { truncate } from './Label'; export interface ButtonOptions { variant?: 'default' | 'primary' | 'ghost' | 'danger'; /** Fixed width; otherwise measured from the label, which is truncated when too long. */ width?: number; /** Floor only – for a button whose label changes between forms and must grow with them. */ minWidth?: number; align?: 'center' | 'left'; onClick?: () => void; } export class Button extends Widget { private readonly bg = new Graphics(); private readonly labelText: Text; /** The label as set; `labelText` holds the possibly truncated version on screen. */ private fullText: string; private pressed = false; /** Caller-owned: renders like a primary button but is never flipped by a click. */ toggled = false; variant: NonNullable; protected readonly align: NonNullable; protected readonly fixedWidth: number; 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; this.fixedWidth = options.width ?? 0; this.minWidth = this.fixedWidth || (options.minWidth ?? 0); this.fullText = text; 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.fullText; } /** What is drawn – shorter than `text` when the button is too narrow to hold it. */ get shownText(): string { return this.labelText.text; } /** Repaints only; the parent must relayout when the measured width matters. */ setText(text: string): void { if (text === this.fullText) return; this.fullText = text; this.invalidate(); } setToggled(toggled: boolean): void { if (this.toggled === toggled) return; this.toggled = toggled; this.invalidate(); } protected get onAccent(): boolean { return this.toggled || this.variant === 'primary' || this.variant === 'danger'; } override measure(): Size { const h = Math.max(this.minHeight, Theme.controlHeight); // A given width is a width, not a floor: a picker must not resize around the name it shows. if (this.fixedWidth) return { w: this.fixedWidth, h }; // From the full label, never the on-screen one: measuring truncated text would keep a button // that was once too narrow too narrow for good. const metrics = CanvasTextMetrics.measureText(this.fullText, this.labelText.style); return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2), h }; } /** Width the label may occupy; subclasses that draw inside the button take their room off here. */ protected labelSlot(): number { // The padding is an inset, not a claim: on a 24 px icon button it would leave 8 px and // truncate the glyph away entirely. From 48 px up this is the full padding again. return this.w - Math.min(Theme.padding, Math.floor(this.w / 6)) * 2; } 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 }); } // Style first: it decides the glyph widths that the truncation then measures against. this.labelText.style = textStyle(this.onAccent ? 'accent' : 'body'); const label = this.labelText; label.text = truncate(this.fullText, label.style, this.labelSlot()); const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2; label.position.set(Math.round(x), centreY(this.h, label.height)); } }