import { CanvasTextMetrics, Graphics, Text } from 'pixi.js'; import { centreY } from '../math'; import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { type Size, Widget } from '../Widget'; export interface LabelOptions { style?: TextStyleToken; align?: 'left' | 'center' | 'right'; padding?: number; /** Fixed width – a column that must not grow with a long text; the text is truncated instead. */ width?: number; } /** Single-line text that truncates with an ellipsis when narrower than its content. */ export class Label extends Widget { private readonly textNode: Text; private fullText: string; private readonly align: NonNullable; private readonly padding: number; private readonly fixedWidth: number; private underline: Graphics | null = null; constructor(text = '', options: LabelOptions = {}) { super(); this.eventMode = 'none'; this.fullText = text; this.align = options.align ?? 'left'; this.padding = options.padding ?? 0; this.fixedWidth = options.width ?? 0; this.minWidth = this.fixedWidth; this.textNode = new Text({ text, style: textStyle(options.style ?? 'body') }); this.addChild(this.textNode); this.minHeight = Theme.rowHeight; } /** The full text, not the truncated version on screen. */ get text(): string { return this.fullText; } setText(text: string): void { if (text === this.fullText) return; this.fullText = text; this.invalidate(); } /** Repaints only; the measured size may change, so the parent must relayout when that matters. */ setStyle(token: TextStyleToken): void { this.textNode.style = textStyle(token); this.invalidate(); } /** A line under the shown text in its own colour – how a label reads as a link. */ setUnderline(on: boolean): void { if (on === Boolean(this.underline)) return; if (on) this.underline = this.addChild(new Graphics()); else { this.underline?.destroy(); this.underline = null; } this.invalidate(); } /** Whether the label is underlined (test hook). */ get underlined(): boolean { return this.underline !== null; } override measure(): Size { if (this.fixedWidth) return { w: this.fixedWidth, h: this.minHeight }; const metrics = CanvasTextMetrics.measureText(this.fullText, this.textNode.style); return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), h: Math.max(this.minHeight, Math.ceil(metrics.height)), }; } override redraw(): void { const available = this.w - this.padding * 2; this.textNode.text = truncate(this.fullText, this.textNode.style, available); const tw = this.textNode.width; let x = this.padding; if (this.align === 'center') x = (this.w - tw) / 2; else if (this.align === 'right') x = this.w - this.padding - tw; this.textNode.position.set(Math.round(x), centreY(this.h, this.textNode.height)); if (this.underline) { const y = Math.round(this.textNode.y + this.textNode.height) - 1; const color = this.textNode.style.fill as number; this.underline.clear().rect(this.textNode.x, y, tw, 1).fill(color); } } } /** Cuts `text` to fit `maxWidth` in the given style, appending "…" when shortened. */ export function truncate(text: string, style: Text['style'], maxWidth: number): string { if (maxWidth <= 0) return ''; if (CanvasTextMetrics.measureText(text, style).width <= maxWidth) return text; let lo = 0; let hi = text.length; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); if (CanvasTextMetrics.measureText(text.slice(0, mid) + '…', style).width <= maxWidth) lo = mid; else hi = mid - 1; } return lo === 0 ? '' : text.slice(0, lo) + '…'; }