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>
107 lines
3.7 KiB
TypeScript
107 lines
3.7 KiB
TypeScript
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) + '…';
|
||
}
|