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>
81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
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;
|
||
}
|
||
}
|