Remove comments that only restate the code, keeping the section banners and the notes that carry a reason, a measured fact or an external contract. ButtonOptions.width claimed to be a fixed width but only set minWidth, so the Graph and Texture pickers grew with the name they showed. It is now what it promises, with a separate minWidth for the buttons that have two forms. Narrow buttons shrink their padding, otherwise a 24px icon button had 8px left for its glyph and truncate dropped it entirely. __gm.labelsDropped() guards that class: the overflow guard only saw text that was too wide, never a label truncated to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
5.6 KiB
TypeScript
158 lines
5.6 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';
|
||
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<ButtonOptions['variant']>;
|
||
protected readonly align: NonNullable<ButtonOptions['align']>;
|
||
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));
|
||
}
|
||
}
|