Files
feuerwerk/src/ui/widgets/Label.ts
T
adminandClaude Opus 5.5 32a0af6e2c Put the text and boxes of the interface on device pixels
On a screen scaled to 125 % or 150 % the editor looked soft beside
a page the browser draws itself. The layout counts in whole CSS
pixels, and most of those fall between two device pixels there. The
GPU averages both: text loses its edges, and a 1 px line becomes a
pixel and a half of mixed colour. Now text and boxes are rounded to
device pixels, and a line is one device pixel wide.

- ui/pixelGrid.ts holds the rule. crispText() makes a text with
  Pixi's roundPixels, crisp() does the same for a Graphics of
  upright boxes, and hairline() is the width of one device pixel.
- Every text of the interface is made with crispText() instead of
  new Text().
- crisp() only rounds where the scale is not a whole number. At
  100 % and 200 % nothing changes.
- drawControlBox, drawHeaderBar and drawSectionBand draw their
  lines a hairline wide and flush with the edge. The widgets that
  draw lines of their own do the same.
- Two bands share exactly the line they draw, and a column still
  ends on a whole pixel.
- The stage is nudged by 1/32 px where the scale is not whole. At
  150 % every odd pixel is an exact half, and without the nudge one
  band came out a row shorter than its neighbours.
- Curves, icons and everything of the game stay unrounded. Rounding
  their points would bend them.
- The harness takes deviceScaleFactor as an option, and the hooks
  deviceColumn, edgeContrast and pixelGrid read the frame as the
  screen gets it.

Not in here: the subpixel smoothing a browser gives its own text.
Pixi draws a text into a transparent picture first, and there only
greyscale smoothing is possible.

Measured at 150 %, edge contrast before and after: top bar 31.5 to
47.8, the lists on the left 68.6 to 86.4, inspector 27.6 to 38.4.
At 100 % it is 46.5, 90.1 and 37.9, and the picture there is the
same as before pixel for pixel, apart from the fps display.

Tests:
- Gate on melektaus-atx: 206 files, 3114 tests (3104 + 10), exit 0
- Full run on melektaus-atx: 1655 of 1656 checks passed, smokes
  24/19/21. The one that failed was a condition of the new step
  62 itself: it scrolled an inspector that had nothing to scroll.
  The step now makes the window lower for that reading.
- e2e 61,62 after that fix: 14 checks. The full run was not
  repeated, and CI is switched off for now.
- Ten unit tests, nine in tests/ui/pixel-grid.test.ts
- Eight e2e checks in the new step 62, which runs at 150 %
- Eleven unit counter-probes fail tests
- Four browser counter-probes fail their checks. They ran before
  the fix to step 62 and were not repeated for it.
- Screenshots looked at for 125 % and 150 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:42:26 +02:00

108 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, type Text } from 'pixi.js';
import { centreY } from '../math';
import { Theme, textStyle, type TextStyleToken } from '../Theme';
import { type Size, Widget } from '../Widget';
import { crispText } from '../pixelGrid';
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 = crispText({ 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) + '…';
}