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>
70 lines
2.8 KiB
TypeScript
70 lines
2.8 KiB
TypeScript
import type { Graphics } from 'pixi.js';
|
||
import { crisp, hairline } from './pixelGrid';
|
||
import { Theme } from './Theme';
|
||
|
||
export interface ControlBoxOptions {
|
||
fill: number;
|
||
border: number;
|
||
radius?: number;
|
||
x?: number;
|
||
y?: number;
|
||
}
|
||
|
||
/**
|
||
* The shared look of every control. The stroke is a hairline inset by half its width, so it lands
|
||
* on whole device pixels and stays crisp instead of blurring over two. Only for a `g` that holds
|
||
* nothing but boxes – see `crisp`.
|
||
*/
|
||
export function drawControlBox(g: Graphics, w: number, h: number, options: ControlBoxOptions): void {
|
||
const { fill, border, radius = Theme.radius, x = 0, y = 0 } = options;
|
||
const line = hairline();
|
||
crisp(g).roundRect(x, y, w, h, radius).fill(fill);
|
||
g.roundRect(x + line / 2, y + line / 2, w - line, h - line, radius).stroke({ width: line, color: border });
|
||
}
|
||
|
||
/** A bar across the top of something – the toolbar, a tab's header, the breadcrumb: header colour, a line below. */
|
||
export function drawHeaderBar(g: Graphics, w: number, h: number): void {
|
||
const line = hairline();
|
||
crisp(g).clear().rect(0, 0, w, h).fill(Theme.colors.panelHeader);
|
||
g.rect(0, h - line, w, line).fill(Theme.colors.border);
|
||
}
|
||
|
||
export interface GridOptions {
|
||
/** Where the origin of the drawn space sits on screen, in the same pixels as `w`/`h`. */
|
||
originX: number;
|
||
originY: number;
|
||
/** Spacing in screen pixels (world/graph step times zoom). */
|
||
step: number;
|
||
w: number;
|
||
h: number;
|
||
alpha: number;
|
||
/** Alpha of the two lines through the origin; omitted = no axes. */
|
||
axisAlpha?: number;
|
||
}
|
||
|
||
/**
|
||
* White at a low alpha rather than a fixed colour, so the grid reads on whatever background the
|
||
* scene sets. `g` is cleared by the caller.
|
||
*/
|
||
export function drawGrid(g: Graphics, options: GridOptions): void {
|
||
const { originX, originY, step, w, h, alpha, axisAlpha } = options;
|
||
const startX = ((originX % step) + step) % step;
|
||
const startY = ((originY % step) + step) % step;
|
||
for (let x = startX; x < w; x += step) g.moveTo(x, 0).lineTo(x, h);
|
||
for (let y = startY; y < h; y += step) g.moveTo(0, y).lineTo(w, y);
|
||
g.stroke({ width: 1, color: GRID_COLOR, alpha });
|
||
if (axisAlpha === undefined) return;
|
||
const axis = { width: 1, color: GRID_COLOR, alpha: axisAlpha };
|
||
if (originX >= 0 && originX <= w) g.moveTo(originX, 0).lineTo(originX, h).stroke(axis);
|
||
if (originY >= 0 && originY <= h) g.moveTo(0, originY).lineTo(w, originY).stroke(axis);
|
||
}
|
||
|
||
const GRID_COLOR = 0xffffff;
|
||
|
||
/** A rubber-band selection between two corners, in whatever space `g` draws. */
|
||
export function drawBand(g: Graphics, a: { x: number; y: number }, b: { x: number; y: number }): void {
|
||
g.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y))
|
||
.fill({ color: Theme.colors.marquee, alpha: 0.12 })
|
||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||
}
|