Files
feuerwerk/src/ui/widgets/Button.ts
T
rootandClaude Opus 5 4d84231b9f Trim needless comments and fix the picker width
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>
2026-09-08 15:52:05 +02:00

158 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}