Files
feuerwerk/src/editor/DocumentTab.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

81 lines
2.9 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, type FederatedPointerEvent, Rectangle, type Text } from 'pixi.js';
import { centreY, Theme, textStyle, crispText } from '../ui';
import { TabButton } from './TabButton';
const GAP = 4;
const CLOSE = '×';
/** The × is small; the hit around it is not, or closing a tab would take aim. */
const CLOSE_SLOP = 4;
/** A long name is cut here rather than pushing every other tab behind the `»` button. */
const MAX_NAME_WIDTH = 160;
/**
* A tab that stands for an open document (#171): the icon of its kind before the name, so three
* assets called "Player" can be told apart, and an × that closes it – as does a middle click.
*/
export class DocumentTab extends TabButton {
private readonly closeText: Text;
constructor(
label: string,
private readonly kind: string,
icon: string,
onPick: () => void,
private readonly onClose: () => void,
) {
super(label, { align: 'left', icon, onClick: onPick });
this.closeText = crispText({ text: CLOSE, style: textStyle('muted') });
this.closeText.eventMode = 'static';
this.closeText.cursor = Theme.hoverCursor;
// Stopped on the way down as well, or the tab under it would count the press as its own click.
this.closeText.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.closeText.on('pointerup', (e: FederatedPointerEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
this.onClose();
});
this.on('pointerup', (e: FederatedPointerEvent) => {
if (e.button === 1) this.onClose();
});
this.addChild(this.closeText);
}
/** The × in global coordinates, for the check scripts. */
closePoint(): { x: number; y: number } {
const at = this.closeText.getGlobalPosition();
return { x: at.x + this.closeText.width / 2, y: at.y + this.closeText.height / 2 };
}
/** The kind the icon stands for, as a word – the icon itself says nothing to a check script. */
get kindLabel(): string {
return this.kind;
}
override measure(): { w: number; h: number } {
const own = super.measure();
const cap = MAX_NAME_WIDTH + this.padding * 2 + this.iconSpace();
return { w: Math.min(own.w, cap) + this.closeWidth(), h: own.h };
}
protected override labelSlot(): number {
return super.labelSlot() - this.closeWidth();
}
override redraw(): void {
super.redraw();
this.closeText.style = textStyle(this.toggled ? 'body' : 'muted');
const closeX = this.w - this.padding - this.closeText.width;
this.closeText.position.set(closeX, centreY(this.h, this.closeText.height));
this.closeText.hitArea = new Rectangle(
-CLOSE_SLOP,
-CLOSE_SLOP,
this.closeText.width + CLOSE_SLOP * 2,
this.closeText.height + CLOSE_SLOP * 2,
);
}
private closeWidth(): number {
return Math.ceil(CanvasTextMetrics.measureText(CLOSE, textStyle('muted')).width) + GAP;
}
}