Files
feuerwerk/src/ui/widgets/Label.ts
T
adminandClaude Opus 5.5 1aae97b335
game-maker / gate (push) Successful in 2m52s
game-maker / suite (push) Successful in 8m58s
Open a sprite's texture or shader from the menus and inspector (#221)
A sprite's texture and shader were only reachable by hunting for them
in the asset panel. Every place that shows such a reference now leads
straight to it.

- Object menu (hierarchy and scene view): "Open texture" and "Open
  shader" sit right below "Open asset" when the sprite has one. The
  object's own values count, so an overridden shader opens the
  override.
- Asset context menu: the same entries at the top.
- Inspector: every asset field with a value shows its label as a blue,
  underlined link. One click selects the asset, a double click opens
  it. An override stays gold and gets the underline too.
- The first click rebuilds the inspector, so the second one lands on a
  different row. The inspector catches that second click before any
  row sees it.
- New pure rule `references.ts`: the asset behind a reference field. A
  frame leads to its sheet. Kinds without a tab (palette, font, sound)
  get no menu entry.
- `followsPress` in `clicks.ts` keeps the double-click window and
  tolerance in one place.
- `activateAsset` in `actions.ts`: opening means the same everywhere
  as a double click in the asset panel, so a scene also becomes the
  active one. The asset panel uses it too.

Tests:
- ./check.sh: 150 files, 2226 tests, CHECK EXIT 0 (before 148/2214).
- 12 new unit tests: 6 for the reference rule, 4 for the link in
  createFieldRow, 2 for followsPress.
- 5 new e2e checks in 28l-ii: both menus, the link's look, click
  selects, double click opens.
- e2e 1-16,26,28,35,39,40 on the final build: ALL PASSED, 732 checks.
- Counter-probes: no "Open ..." entries, no underline and a second
  click never recognised each fail exactly at their checks.
- Check total 1341 (CI counted 1336 at fd6af84), computed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:44:19 +02:00

107 lines
3.7 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 { 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<LabelOptions['align']>;
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) + '…';
}