diff --git a/README.md b/README.md index 02c95693..16855c10 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1648 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1656 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/DocumentTab.ts b/src/editor/DocumentTab.ts index 37ac7b52..d32aaa3c 100644 --- a/src/editor/DocumentTab.ts +++ b/src/editor/DocumentTab.ts @@ -1,5 +1,5 @@ -import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, Text } from 'pixi.js'; -import { centreY, Theme, textStyle } from '../ui'; +import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, type Text } from 'pixi.js'; +import { centreY, Theme, textStyle, crispText } from '../ui'; import { TabButton } from './TabButton'; const GAP = 4; @@ -24,7 +24,7 @@ export class DocumentTab extends TabButton { private readonly onClose: () => void, ) { super(label, { align: 'left', icon, onClick: onPick }); - this.closeText = new Text({ text: CLOSE, style: textStyle('muted') }); + 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. diff --git a/src/editor/FilePicker.ts b/src/editor/FilePicker.ts index 8f0c4e01..938b0ed8 100644 --- a/src/editor/FilePicker.ts +++ b/src/editor/FilePicker.ts @@ -7,7 +7,7 @@ * The list differs per kind (tiles for pictures, rows with a sample line for typefaces, rows with a * ▶ for sounds); the dialog around it does not. */ -import { type Container, type FederatedPointerEvent, type Graphics, Text } from 'pixi.js'; +import type { Container, FederatedPointerEvent, Graphics, Text } from 'pixi.js'; import type { AssetType } from '../core/model/AssetTypes'; import type { Project } from '../core/model/Project'; import type { StoredFile } from '../core/store/FileStore'; @@ -22,6 +22,7 @@ import { Theme, textStyle, Widget, + crispText, } from '../ui'; import { dialogButtons } from './FormDialog'; @@ -66,7 +67,7 @@ export abstract class PickerList protected items: Item[] = []; protected rows: Row[] = []; private selectedFile: string | null = null; - private readonly empty = new Text({ text: '', style: textStyle('muted') }); + private readonly empty = crispText({ text: '', style: textStyle('muted') }); private readonly unwatch: (() => void)[] = []; onPick?: (file: string) => void; onActivate?: (file: string) => void; diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts index 9f54babd..5f01e5ad 100644 --- a/src/editor/TabStrip.ts +++ b/src/editor/TabStrip.ts @@ -1,5 +1,5 @@ import { Graphics } from 'pixi.js'; -import { Button, HStack, Menu, Theme } from '../ui'; +import { Button, crisp, hairline, HStack, Menu, Theme } from '../ui'; import { DocumentTab } from './DocumentTab'; import { TabButton } from './TabButton'; import { tabRowLayout } from './tabRow'; @@ -44,7 +44,7 @@ export class TabStrip extends HStack { private readonly icons = new Map(); private readonly overflow: Button; /** The bar the cards sit on, drawn behind every tab. */ - private readonly bar = new Graphics(); + private readonly bar = crisp(new Graphics()); private hiddenTabs: T[] = []; private active: T | null; @@ -165,7 +165,7 @@ export class TabStrip extends HStack { this.bar.clear(); this.bar.rect(0, 0, this.w, this.h).fill(c.tabBar); // The line the cards stand on; the one showing covers its own stretch of it. - this.bar.rect(0, this.h - 1, this.w, 1).fill(c.border); + this.bar.rect(0, this.h - hairline(), this.w, hairline()).fill(c.border); } private applyOverflow(): void { diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 5f6b4798..0e50b14f 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -1,4 +1,10 @@ -import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import { + Container, + type FederatedPointerEvent, + type FederatedWheelEvent, + Graphics, + type Text, +} from 'pixi.js'; import { clipboardKeyCount, copyAnimationKeys, @@ -24,7 +30,18 @@ import { import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset'; import type { Vec2 } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; -import { drawBand, Menu, pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; +import { + drawBand, + Menu, + pressCount, + type Size, + Theme, + textStyle, + ui, + Widget, + zoomFactor, + crispText, +} from '../../ui'; import { type EditorState, trackSelection } from '../state'; import { FramesDialog, openFramesDialog } from './FramesDialog'; import { @@ -87,7 +104,7 @@ export class TimelineCanvas extends Widget { private readonly rulerLayer = new Container(); private readonly labels = new Map(); private readonly rulerTexts: Text[] = []; - private readonly hint = new Text({ text: '', style: textStyle('muted') }); + private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly clip = new Graphics(); private view: TimelineView = { ...DEFAULT_VIEW }; private assetId = ''; @@ -604,7 +621,7 @@ export class TimelineCanvas extends Widget { this.cells.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted); let text = this.rulerTexts[used]; if (!text) { - text = new Text({ style: textStyle('small') }); + text = crispText({ style: textStyle('small') }); this.rulerTexts.push(text); this.rulerLayer.addChild(text); } @@ -667,7 +684,7 @@ export class TimelineCanvas extends Widget { alive.add(track.id); let label = this.labels.get(track.id); if (!label) { - label = new Text({ style: textStyle('body') }); + label = crispText({ style: textStyle('body') }); this.labels.set(track.id, label); this.labelLayer.addChild(label); } diff --git a/src/editor/curve/CurveCanvas.ts b/src/editor/curve/CurveCanvas.ts index 11c6c3d8..f0ab50cd 100644 --- a/src/editor/curve/CurveCanvas.ts +++ b/src/editor/curve/CurveCanvas.ts @@ -8,13 +8,13 @@ * what a key or a `Lerp` reads. A dot runs along it as a preview, and a second one beside the graph * moves only up and down – the motion the curve gives a value. */ -import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { type FederatedPointerEvent, Graphics, type Text } from 'pixi.js'; import { SetCurvePointsCommand } from '../../core/commands/curveCommands'; import type { CurveAsset, VectorPoint, VectorRing } from '../../core/model/Asset'; import { curveExtent, curveValue, sanitizedCurve } from '../../core/model/curve'; import { findAssetOf } from '../../core/model/Project'; import type { Vec2 } from '../../core/model/Properties'; -import { cancelFrameWork, onFrame, pressCount, Theme, textStyle } from '../../ui'; +import { cancelFrameWork, onFrame, pressCount, Theme, textStyle, crispText } from '../../ui'; import { FramePreview } from '../framePreview'; import { deleteTakes, pickedIn, pointPress } from '../pointGestures'; import { DRAG_THRESHOLD, drawPointGrip, POINT_SIZE } from '../pointGrips'; @@ -75,7 +75,9 @@ export class CurveCanvas extends SheetCanvas { private readonly guides = new Graphics(); private readonly line = new Graphics(); private readonly overlay = new Graphics(); - private readonly labels = ['0', '1', '0', '1'].map((text) => new Text({ text, style: textStyle('muted') })); + private readonly labels = ['0', '1', '0', '1'].map((text) => + crispText({ text, style: textStyle('muted') }), + ); private drag: CurveDrag | null = null; /** The point last pressed or put down – the one the header's point type is about; -1 for none. */ private picked = -1; diff --git a/src/editor/debug/renderHooks.ts b/src/editor/debug/renderHooks.ts index 74b1492a..675c0bfc 100644 --- a/src/editor/debug/renderHooks.ts +++ b/src/editor/debug/renderHooks.ts @@ -1,18 +1,59 @@ import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture'; import { effectiveScale, gameClock } from '../../core/time'; -import { afterFrames, isRendering, renderCount, requestRender, stagePixel, stagePixels } from '../../ui'; +import { + afterFrames, + hairline, + isRendering, + renderCount, + requestRender, + stagePixel, + stagePixels, +} from '../../ui'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { type Container, Mesh } from 'pixi.js'; import type { HookContext } from './hookHelpers'; +import { edgeContrast } from './sharpness'; /** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */ export function renderHooks({ app, state, editor }: HookContext) { const { viewport, gameView } = editor; const pixelAt = (x: number, y: number): string => stagePixel(app, x, y); + /** + * A part of the frame as the screen gets it, device pixel for device pixel. `pixelAt` cannot say + * that: it draws the stage again into a target of its own, at the layout's size. + */ + const deviceFrame = (x: number, y: number, w: number, h: number): ImageData => { + const res = app.renderer.resolution; + const [sx, sy, sw, sh] = [x, y, w, h].map((v) => Math.round(v * res)) as [number, number, number, number]; + const copy = document.createElement('canvas'); + copy.width = Math.max(1, sw); + copy.height = Math.max(1, sh); + const context = copy.getContext('2d')!; + // In one go with the render: the drawing buffer is only promised to hold the frame until the task ends. + app.render(); + context.drawImage(app.canvas, sx, sy, copy.width, copy.height, 0, 0, copy.width, copy.height); + return context.getImageData(0, 0, copy.width, copy.height); + }; return { pixelAt, + /** The grid the interface rounds to: device pixels per layout pixel, and what a hairline measures. */ + pixelGrid: () => ({ resolution: app.renderer.resolution, hairline: hairline() }), + /** How hard the edges are in a rectangle of the screen, given in layout pixels – see `edgeContrast`. */ + edgeContrast: (x: number, y: number, w: number, h: number) => { + const frame = deviceFrame(x, y, w, h); + return edgeContrast(frame.data, frame.width, frame.height); + }, + /** The device pixels down one column, from layout `y` over `h`, as `#rrggbb`. */ + deviceColumn: (x: number, y: number, h: number) => { + const { data, height } = deviceFrame(x, y, 1 / app.renderer.resolution, h); + const hex = (v: number | undefined) => (v ?? 0).toString(16).padStart(2, '0'); + return Array.from( + { length: height }, + (_, row) => `#${hex(data[row * 4])}${hex(data[row * 4 + 1])}${hex(data[row * 4 + 2])}`, + ); + }, /** Many points from one render, each the colour `pixelAt` would give – for checks that sample hundreds. */ pixels: (points: readonly { x: number; y: number }[]) => stagePixels(app, points), /** diff --git a/src/editor/debug/sharpness.ts b/src/editor/debug/sharpness.ts new file mode 100644 index 00000000..ec73e282 --- /dev/null +++ b/src/editor/debug/sharpness.ts @@ -0,0 +1,25 @@ +/** + * How hard the edges in a picture are: the mean jump in brightness between neighbours, over the + * pixels that have one at all. Text and lines drawn between two device pixels are averaged across + * both and score lower – the one number that tells such a frame from a crisp one (see `pixelGrid.ts`). + */ +export function edgeContrast(rgba: ArrayLike, width: number, height: number): number { + const lum = (x: number, y: number): number => { + const i = (y * width + x) * 4; + return 0.299 * (rgba[i] ?? 0) + 0.587 * (rgba[i + 1] ?? 0) + 0.114 * (rgba[i + 2] ?? 0); + }; + let sum = 0; + let edges = 0; + for (let y = 0; y < height - 1; y++) + for (let x = 0; x < width - 1; x++) { + const here = lum(x, y); + const dx = Math.abs(lum(x + 1, y) - here); + const dy = Math.abs(lum(x, y + 1) - here); + sum += dx + dy; + if (dx > EDGE || dy > EDGE) edges += 1; + } + return edges ? sum / edges : 0; +} + +/** Below this a difference is noise of the blending, not an edge. */ +const EDGE = 2; diff --git a/src/editor/font/FontPicker.ts b/src/editor/font/FontPicker.ts index 4be72135..a68f08d3 100644 --- a/src/editor/font/FontPicker.ts +++ b/src/editor/font/FontPicker.ts @@ -1,6 +1,6 @@ -import { Container, Graphics, Text, TextStyle } from 'pixi.js'; +import { Container, Graphics, type Text, TextStyle } from 'pixi.js'; import type { StoredFont } from '../../core/store/FontStore'; -import { formatBytes, Theme, textStyle, truncate } from '../../ui'; +import { formatBytes, Theme, textStyle, truncate, crispText } from '../../ui'; import { fontLibrary } from '../../viewport/FontLibrary'; import { currentPicker, @@ -41,8 +41,8 @@ class FontList extends RowList { const background = new Graphics(); // Its own style object, never `textStyle('body')`: those are shared across the whole editor, // so writing a family into one would restyle every label in the window. - const sample = new Text({ style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }) }); - const caption = new Text({ style: textStyle('small') }); + const sample = crispText({ style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }) }); + const caption = crispText({ style: textStyle('small') }); box.addChild(background, sample, caption); return { box, background, sample, caption }; } diff --git a/src/editor/font/FontPreview.ts b/src/editor/font/FontPreview.ts index 2889af35..3253ce5b 100644 --- a/src/editor/font/FontPreview.ts +++ b/src/editor/font/FontPreview.ts @@ -1,5 +1,5 @@ -import { Text, TextStyle } from 'pixi.js'; -import { Theme, truncate, type Size, Widget } from '../../ui'; +import { TextStyle } from 'pixi.js'; +import { Theme, truncate, type Size, Widget, crispText } from '../../ui'; import { fontLibrary } from '../../viewport/FontLibrary'; const SAMPLE_SIZE = 26; @@ -10,7 +10,7 @@ const SAMPLE_TEXT = 'Aa Bb Cc 0123'; * hash. Reads in the editor's own typeface until the face has arrived, then redraws itself. */ export class FontPreview extends Widget { - private readonly sample = new Text({ + private readonly sample = crispText({ style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }), }); private readonly stopWatching: () => void; diff --git a/src/editor/graph/AnnotationView.ts b/src/editor/graph/AnnotationView.ts index 513e89ec..ec4388d7 100644 --- a/src/editor/graph/AnnotationView.ts +++ b/src/editor/graph/AnnotationView.ts @@ -1,6 +1,6 @@ -import { Graphics, Rectangle, Text, TextStyle } from 'pixi.js'; +import { Graphics, Rectangle, type Text, TextStyle } from 'pixi.js'; import type { AnnotationKind, GraphAnnotation } from '../../core/graph'; -import { readableTextColor, Theme, truncate, Widget } from '../../ui'; +import { readableTextColor, Theme, truncate, Widget, crispText } from '../../ui'; /** Height of a group's title bar – the same as a node's, so the two line up in the eye. */ export const ANNOTATION_TITLE_HEIGHT = 24; @@ -26,7 +26,7 @@ export class AnnotationView extends Widget { ) { super(); this.addChild(this.box); - this.caption = new Text({ text: '', style: this.captionStyle(0xffffff) }); + this.caption = crispText({ text: '', style: this.captionStyle(0xffffff) }); this.caption.position.set(PAD, kind === 'group' ? 0 : PAD); this.addChild(this.caption); this.cursor = Theme.hoverCursor; diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index 80ec4285..c8a0611f 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -1,6 +1,6 @@ -import { Container, Graphics, Text } from 'pixi.js'; +import { Container, Graphics, type Text } from 'pixi.js'; import { type GraphEdge, portsCompatible } from '../../core/graph'; -import { Theme, textStyle, truncate } from '../../ui'; +import { Theme, textStyle, truncate, crispText } from '../../ui'; import { dashedLine } from '../../viewport/symbols'; import { arrowCorners, @@ -314,7 +314,7 @@ export class EdgeLayer extends Container { private nextLabel(): Text { let label = this.labels[this.labelCount]; if (!label) { - label = new Text({ text: '', style: textStyle('small') }); + label = crispText({ text: '', style: textStyle('small') }); this.labels.push(label); this.labelLayer.addChild(label); } diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 6e6c972e..6fb59d5a 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -1,4 +1,4 @@ -import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; import { textOf } from '../../core/model/Properties'; import { AddNodeCommand, CollapseNodesCommand } from '../../core/commands/graphCommands'; import { SetMachineStartCommand } from '../../core/commands/assetCommands'; @@ -42,6 +42,7 @@ import { truncate, Widget, zoomFactor, + crispText, } from '../../ui'; import { annotationSelection, @@ -104,7 +105,7 @@ export class GraphCanvas extends Widget { private readonly tempEdge = new Graphics(); /** Drawn in screen space, so the band keeps a hairline border at any zoom. */ private readonly band = new Graphics(); - private readonly hint = new Text({ text: '', style: textStyle('muted') }); + private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly views = new Map(); private assetId = ''; private pan = { x: 0, y: 0 }; diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index b0357757..8ae2af7a 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -1,4 +1,4 @@ -import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js'; +import { CanvasTextMetrics, Graphics, Rectangle, type Text } from 'pixi.js'; import { asNumber, asString, @@ -20,7 +20,16 @@ import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap'; import type { Project } from '../../core/model/Project'; import { isList, type PropertyValue } from '../../core/model/Properties'; import type { PropertyDef } from '../../core/properties/PropertySchema'; -import { formatNumber, Theme, textStyle, type TextStyleToken, toHex, truncate, Widget } from '../../ui'; +import { + formatNumber, + Theme, + textStyle, + type TextStyleToken, + toHex, + truncate, + Widget, + crispText, +} from '../../ui'; import { keyLabel, referenceLabel } from '../inspector/fieldFactory'; import { collapsedNodeWidth } from './folding'; @@ -275,7 +284,7 @@ export class NodeView extends Widget { for (const [key, value] of this.liveTexts) { const slot = this.valueSlots.get(key); if (!slot || slot.right - slot.left < 12) continue; - const text = new Text({ text: truncate(value, style, slot.right - slot.left), style }); + const text = crispText({ text: truncate(value, style, slot.right - slot.left), style }); text.position.set(slot.right - text.width, slot.top); this.addChild(text); this.valueTexts.set(key, text); @@ -339,7 +348,7 @@ export class NodeView extends Widget { align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2, ) => { - const node = new Text({ text: truncate(text, textStyle(style), maxWidth), style: textStyle(style) }); + const node = crispText({ text: truncate(text, textStyle(style), maxWidth), style: textStyle(style) }); node.position.set(align === 'left' ? x : x - node.width, y); this.addChild(node); this.texts.push(node); diff --git a/src/editor/particles/ParticlePreview.ts b/src/editor/particles/ParticlePreview.ts index e649f0a1..69392c34 100644 --- a/src/editor/particles/ParticlePreview.ts +++ b/src/editor/particles/ParticlePreview.ts @@ -5,7 +5,7 @@ import type { PropertyBag } from '../../core/model/Properties'; import { resolveTexture } from '../../core/model/texture'; import { defaultsFor } from '../../core/properties/PropertySchema'; import { SHEET_EDGE } from '../../core/shader/nodes'; -import { type Size, Theme, Widget } from '../../ui'; +import { crisp, hairline, type Size, Theme, Widget } from '../../ui'; import { ParticleField } from '../../viewport/particles'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; @@ -21,7 +21,7 @@ const MIN_HEIGHT = 40; * has no way to know which of them is meant. */ export class ParticlePreview extends Widget { - private readonly bg = new Graphics(); + private readonly bg = crisp(new Graphics()); private readonly content = new Container(); private readonly clip = new Graphics(); private readonly field = new ParticleField(); @@ -80,7 +80,7 @@ export class ParticlePreview extends Widget { // A ground of its own and a rule along the top: on the graph's own background the strip was // invisible as a thing, and a particle standing still read as a node that had lost its box. this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel); - this.bg.rect(0, 0, this.w, 1).fill(c.border); + this.bg.rect(0, 0, this.w, hairline()).fill(c.border); this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); this.field.container.visible = this.asset !== null; if (!this.asset) return; diff --git a/src/editor/shader/ShaderPreview.ts b/src/editor/shader/ShaderPreview.ts index 6e107e36..da0e68a1 100644 --- a/src/editor/shader/ShaderPreview.ts +++ b/src/editor/shader/ShaderPreview.ts @@ -3,7 +3,7 @@ import type { ShaderAsset } from '../../core/model/Asset'; import type { PropertyBag } from '../../core/model/Properties'; import { SHADER_KEY } from '../../core/properties/shaders'; import { type EdgeMode, SHEET_EDGE } from '../../core/shader/nodes'; -import { type Size, Theme, Widget } from '../../ui'; +import { crisp, hairline, type Size, Theme, Widget } from '../../ui'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { syncShader } from '../../viewport/shaders'; import { disposeSpriteMesh, newSpriteMesh } from '../../viewport/symbols'; @@ -21,7 +21,7 @@ const MAX_ZOOM = 8; * The shader is the scene's own (`syncShader`), so what the panel shows is what a sprite would. */ export class ShaderPreview extends Widget { - private readonly bg = new Graphics(); + private readonly bg = crisp(new Graphics()); private readonly content = new Container(); private readonly clip = new Graphics(); private readonly mesh = newSpriteMesh(); @@ -87,7 +87,7 @@ export class ShaderPreview extends Widget { override redraw(): void { const c = Theme.colors; this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel); - this.bg.rect(0, 0, this.w, 1).fill(c.border); + this.bg.rect(0, 0, this.w, hairline()).fill(c.border); this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); this.mesh.visible = this.shader !== null; if (!this.shader) return; diff --git a/src/editor/sound/SoundPicker.ts b/src/editor/sound/SoundPicker.ts index 3230e552..833ad6a7 100644 --- a/src/editor/sound/SoundPicker.ts +++ b/src/editor/sound/SoundPicker.ts @@ -1,6 +1,6 @@ -import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js'; import type { StoredSound } from '../../core/store/SoundStore'; -import { drawSvg, formatBytes, textStyle, Theme, truncate } from '../../ui'; +import { drawSvg, formatBytes, textStyle, Theme, truncate, crispText } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { currentPicker, @@ -48,8 +48,8 @@ class SoundList extends RowList { const background = new Graphics(); const play = new Graphics(); drawSvg(play, TOOLBAR_ICON_SVG.play, Theme.colors.accentText); - const caption = new Text({ style: textStyle('body') }); - const detail = new Text({ style: textStyle('small') }); + const caption = crispText({ style: textStyle('body') }); + const detail = crispText({ style: textStyle('small') }); box.addChild(background, play, caption, detail); return { box, background, play, caption, detail }; } diff --git a/src/editor/texture/ImagePicker.ts b/src/editor/texture/ImagePicker.ts index 1a353bc0..c9db93e1 100644 --- a/src/editor/texture/ImagePicker.ts +++ b/src/editor/texture/ImagePicker.ts @@ -1,6 +1,6 @@ -import { Container, Graphics, Sprite, Text } from 'pixi.js'; +import { Container, Graphics, Sprite } from 'pixi.js'; import type { StoredTexture } from '../../core/store/TextureStore'; -import { formatBytes, type Size, textStyle, truncate } from '../../ui'; +import { formatBytes, type Size, textStyle, truncate, crispText } from '../../ui'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { currentPicker, @@ -63,7 +63,7 @@ class ImageGrid extends PickerList { const box = new Container(); const background = new Graphics(); const sprite = new Sprite(); - const caption = new Text({ style: textStyle('small') }); + const caption = crispText({ style: textStyle('small') }); box.addChild(background, sprite, caption); return { box, background, sprite, caption }; } diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts index 22cc7428..cc5e11fe 100644 --- a/src/editor/texture/SheetCanvas.ts +++ b/src/editor/texture/SheetCanvas.ts @@ -1,6 +1,6 @@ -import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; import type { Vec2 } from '../../core/model/Properties'; -import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; +import { type Size, Theme, textStyle, ui, Widget, zoomFactor, crispText } from '../../ui'; import { imageToLocal, type SheetView, zoomAbout } from './sheetView'; /** @@ -14,7 +14,7 @@ export abstract class SheetCanvas extends Widget { /** Everything the view moves; the checker board is its bottom layer. */ protected readonly content = new Container(); protected readonly checker = new Graphics(); - protected readonly hint = new Text({ text: '', style: textStyle('muted') }); + protected readonly hint = crispText({ text: '', style: textStyle('muted') }); protected readonly clip = new Graphics(); protected view: SheetView = { panX: 0, panY: 0, zoom: 1 }; protected assetId = ''; diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 1e2f07d4..94fb2234 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -1,4 +1,4 @@ -import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js'; +import { Container, type FederatedPointerEvent, Graphics, Sprite, type Text } from 'pixi.js'; import { AddFrameCommand, SetFrameCollisionCommand, @@ -25,7 +25,7 @@ import { setFrameSlice, setFrameSortLine, } from '../../core/model/texture'; -import { pressCount, Theme, textStyle, type Widget } from '../../ui'; +import { pressCount, Theme, textStyle, type Widget, crispText } from '../../ui'; import { gripAt, insideOutline, @@ -885,7 +885,7 @@ export class TextureCanvas extends SheetCanvas { return; } if (!label) { - label = new Text({ style: textStyle('viewportLabel') }); + label = crispText({ style: textStyle('viewportLabel') }); this.labels.set(frame.id, label); this.labelLayer.addChild(label); } diff --git a/src/editor/tile/TileCanvas.ts b/src/editor/tile/TileCanvas.ts index 9065d547..0ae67c31 100644 --- a/src/editor/tile/TileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -1,4 +1,4 @@ -import { Container, Graphics, Sprite, Text, type TextStyle } from 'pixi.js'; +import { Container, Graphics, Sprite, type Text, type TextStyle } from 'pixi.js'; import type { TileAsset } from '../../core/model/Asset'; import { autotileCombinations, @@ -19,7 +19,7 @@ import { tilePlayback, } from '../../core/model/tile'; import { findAssetOf } from '../../core/model/Project'; -import { type Size, Theme, textStyle, ui, Widget } from '../../ui'; +import { type Size, Theme, textStyle, ui, Widget, crispText } from '../../ui'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view'; import type { Point } from '../framing'; @@ -77,7 +77,7 @@ export class TileCanvas extends Widget { private readonly captionLayer = new Container(); private readonly clipped = new Container(); private readonly clip = new Graphics(); - private readonly hint = new Text({ text: '', style: textStyle('muted') }); + private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly tiles: BoardTile[] = []; private readonly captions: Caption[] = []; /** Own copies, since the size follows the zoom here and nowhere else. */ @@ -421,9 +421,9 @@ export class TileCanvas extends Widget { /** `NESW-ne,se` becomes two lines, so the long captions need no more width than a tile. */ private addCaption(label: string, x: number, y: number): void { const [sides = label, notches] = label.split('-'); - const sidesText = new Text({ text: sides, style: this.sidesStyle, anchor: { x: 0.5, y: 0 } }); + const sidesText = crispText({ text: sides, style: this.sidesStyle, anchor: { x: 0.5, y: 0 } }); const notchesText = notches - ? new Text({ text: notches, style: this.notchesStyle, anchor: { x: 0.5, y: 0 } }) + ? crispText({ text: notches, style: this.notchesStyle, anchor: { x: 0.5, y: 0 } }) : null; this.captionLayer.addChild(sidesText); if (notchesText) this.captionLayer.addChild(notchesText); diff --git a/src/editor/tile/TilePalette.ts b/src/editor/tile/TilePalette.ts index 020a315a..0b14d43a 100644 --- a/src/editor/tile/TilePalette.ts +++ b/src/editor/tile/TilePalette.ts @@ -1,4 +1,4 @@ -import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js'; +import { Container, type FederatedPointerEvent, Graphics, Sprite, type Text } from 'pixi.js'; import { loneTileQuadrants } from '../../core/model/autotile'; import { findAssetOf } from '../../core/model/Project'; import { quadrantRef, readTile } from '../../core/model/tile'; @@ -11,6 +11,7 @@ import { textStyle, truncate, Widget, + crispText, } from '../../ui'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { gridMetrics, type GridMetrics, tileAt, tileRect } from '../texture/imageGrid'; @@ -187,8 +188,8 @@ export class TilePalette extends Widget { const box = new Container(); const background = new Graphics(); const sprites = [new Sprite(), new Sprite(), new Sprite(), new Sprite()] as Swatch['sprites']; - const empty = new Text({ text: '?', style: textStyle('muted') }); - const caption = new Text({ style: textStyle('small') }); + const empty = crispText({ text: '?', style: textStyle('muted') }); + const caption = crispText({ style: textStyle('small') }); box.addChild(background, ...sprites, empty, caption); box.eventMode = 'none'; this.addChild(box); diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index b552a589..52c22fb4 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -2,11 +2,10 @@ * The scene seen through its active camera at the chosen scale, letterboxed: the running world while * playing, the model as a still preview otherwise. */ -import { Text } from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; import type { EditorState } from '../state'; import type { World } from '../../runtime/World'; -import { requestRender, Theme, textStyle, ui } from '../../ui'; +import { requestRender, Theme, textStyle, ui, crispText } from '../../ui'; import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; import { FullscreenCorner } from '../../viewport/FullscreenCorner'; import { collectHolderItems, collectWorldItems } from '../../viewport/items'; @@ -16,8 +15,8 @@ import { type Frame, WorldViewport } from '../../viewport/WorldViewport'; import { PerformanceOverlay } from './PerformanceOverlay'; export class GameViewport extends WorldViewport { - private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') }); - private readonly editorRunCaption = new Text({ + private readonly hint = crispText({ text: 'No active camera', style: textStyle('muted') }); + private readonly editorRunCaption = crispText({ text: 'Behaviors run here · keys and clicks go to the game', style: textStyle('muted'), }); diff --git a/src/editor/viewport/PerformanceOverlay.ts b/src/editor/viewport/PerformanceOverlay.ts index 222dc4cc..f9f62cec 100644 --- a/src/editor/viewport/PerformanceOverlay.ts +++ b/src/editor/viewport/PerformanceOverlay.ts @@ -1,11 +1,11 @@ -import { Container, Graphics, Text } from 'pixi.js'; -import { Theme, textStyle } from '../../ui'; +import { Container, Graphics } from 'pixi.js'; +import { Theme, textStyle, crispText } from '../../ui'; import { performanceLines, type PerformanceReading } from '../frameMeter'; /** Frame rate, entities and the time per frame by area over a running game (#275), top left. */ export class PerformanceOverlay extends Container { private readonly bg = new Graphics(); - private readonly text = new Text({ text: '', style: textStyle('mono') }); + private readonly text = crispText({ text: '', style: textStyle('mono') }); constructor() { super(); diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index a2c3c1aa..2b29048f 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -3,7 +3,7 @@ * copy, so live positions are visible and stay editable. A container's template is the same * work on the same shape of data, so the Container tab subclasses this and swaps `holder()`. */ -import { Container, type FederatedPointerEvent, Graphics, Rectangle, Text } from 'pixi.js'; +import { Container, type FederatedPointerEvent, Graphics, Rectangle, type Text } from 'pixi.js'; import { oneStep } from '../../core/commands/CompoundCommand'; import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; import { hasSceneIcon, isSymbolType } from '../../core/model/AssetTypes'; @@ -28,7 +28,17 @@ import { FramePreview } from '../framePreview'; import { panelPolicy } from '../playThrottle'; import { type EditorState, objectSelection } from '../state'; import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; -import { activeDrag, drawBand, drawGrid, endDrag, Menu, onDragChange, Theme, textStyle } from '../../ui'; +import { + activeDrag, + drawBand, + drawGrid, + endDrag, + Menu, + onDragChange, + Theme, + textStyle, + crispText, +} from '../../ui'; import { canPasteObjects, pasteObjects } from '../clipboard'; import { collectHolderItems, type DrawItem, type TilemapSolids } from '../../viewport/items'; import { sortRowOf } from '../../viewport/drawOrder'; @@ -946,7 +956,7 @@ export class SceneViewport extends FreeViewport { return; } if (!label) { - label = new Text({ text: '', style: textStyle('viewportLabel') }); + label = crispText({ text: '', style: textStyle('viewportLabel') }); label.eventMode = 'none'; this.labels.set(item.id, label); this.labelLayer.addChild(label); diff --git a/src/page.ts b/src/page.ts index dd51aedb..5dcb484f 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,5 +1,5 @@ import { Application, Container } from 'pixi.js'; -import { initRenderLoop, initUI, loadEditorFonts, Theme, VStack } from './ui'; +import { initPixelGrid, initRenderLoop, initUI, loadEditorFonts, Theme, tieBreakFor, VStack } from './ui'; // `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may // tear; `?vsync` opts out. Pixi has no option for it, hence our own context. @@ -31,12 +31,14 @@ export async function createPage(params: URLSearchParams): Promise<{ app: Applic preference: 'webgl', ...(params.has('vsync') ? {} : lowLatencyContext()), }); + initPixelGrid(app.renderer.resolution); document.body.appendChild(app.canvas); await fonts; const root = new VStack(); const overlay = new Container(); app.stage.addChild(root, overlay); + app.stage.position.set(tieBreakFor(app.renderer.resolution)); app.stage.eventMode = 'static'; app.stage.hitArea = app.screen; initUI(app, root, overlay); diff --git a/src/player/GameScreen.ts b/src/player/GameScreen.ts index 258592cb..5dcd360c 100644 --- a/src/player/GameScreen.ts +++ b/src/player/GameScreen.ts @@ -1,6 +1,6 @@ -import { Text, type TextStyle } from 'pixi.js'; +import type { TextStyle } from 'pixi.js'; import type { World } from '../runtime/World'; -import { Theme, textStyle, ui } from '../ui'; +import { Theme, textStyle, ui, crispText } from '../ui'; import { cameraOfWorld } from '../viewport/activeCamera'; import { FullscreenCorner } from '../viewport/FullscreenCorner'; import { routePointer } from '../viewport/gamePointer'; @@ -22,8 +22,8 @@ function captionStyle(): TextStyle { } export class GameScreen extends WorldViewport { - private readonly caption = new Text({ text: '', style: captionStyle() }); - private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') }); + private readonly caption = crispText({ text: '', style: captionStyle() }); + private readonly hint = crispText({ text: 'No active camera', style: textStyle('muted') }); private running: World | null = null; /** Asked when the caption is clicked while it offers to start. */ onStart: (() => void) | null = null; diff --git a/src/ui/drag.ts b/src/ui/drag.ts index 38f3b7fa..e95a6d07 100644 --- a/src/ui/drag.ts +++ b/src/ui/drag.ts @@ -1,6 +1,7 @@ -import { Container, Graphics, Text } from 'pixi.js'; +import { Container, Graphics } from 'pixi.js'; import { Theme, textStyle } from './Theme'; import { ui } from './UIContext'; +import { crispText } from './pixelGrid'; /** What is being dragged. `kind` lets a drop target ignore payloads it does not take. */ export interface DragPayload { @@ -74,7 +75,7 @@ function buildGhost(label: string): Container { const ghost = new Container(); ghost.eventMode = 'none'; ghost.alpha = 0.85; - const text = new Text({ text: label, style: textStyle('body') }); + const text = crispText({ text: label, style: textStyle('body') }); const box = new Graphics() .roundRect(0, 0, text.width + 16, Theme.rowHeight, 4) .fill(Theme.colors.panel) diff --git a/src/ui/draw.ts b/src/ui/draw.ts index e93b65fc..8e4a80b8 100644 --- a/src/ui/draw.ts +++ b/src/ui/draw.ts @@ -1,4 +1,5 @@ import type { Graphics } from 'pixi.js'; +import { crisp, hairline } from './pixelGrid'; import { Theme } from './Theme'; export interface ControlBoxOptions { @@ -10,19 +11,22 @@ export interface ControlBoxOptions { } /** - * The shared look of every control. The stroke is inset by half a pixel so it lands on whole - * device pixels and stays crisp instead of blurring over two. + * 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; - g.roundRect(x, y, w, h, radius).fill(fill); - g.roundRect(x + 0.5, y + 0.5, w - 1, h - 1, radius).stroke({ width: 1, color: border }); + 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 { - g.clear().rect(0, 0, w, h).fill(Theme.colors.panelHeader); - g.rect(0, h - 1, w, 1).fill(Theme.colors.border); + 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 { diff --git a/src/ui/index.ts b/src/ui/index.ts index ed98f916..dff7aa47 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -11,6 +11,15 @@ export { } from './sections'; export { Theme, textStyle, type TextStyleToken } from './Theme'; export { loadEditorFonts } from './fonts'; +export { + crisp, + crispText, + hairline, + hairlineFor, + initPixelGrid, + offDeviceGrid, + tieBreakFor, +} from './pixelGrid'; export { initUI, ui, type UIContext } from './UIContext'; export { afterFrames, diff --git a/src/ui/layout/SectionStack.ts b/src/ui/layout/SectionStack.ts index f25db288..2fedaaf4 100644 --- a/src/ui/layout/SectionStack.ts +++ b/src/ui/layout/SectionStack.ts @@ -1,3 +1,4 @@ +import { hairline } from '../pixelGrid'; import { hiddenBySections, sectionOffsets, type SectionRow, sectionStartOf } from '../sections'; import { type Size, Widget } from '../Widget'; import { type StackOptions, VStack } from './Stack'; @@ -51,7 +52,7 @@ export class SectionStack extends VStack { this.applyFolds(); const sizes = this.items.map((item) => item.measure()); const w = Math.max(0, ...sizes.map((size) => size.w)) + this.padding * 2; - const { height } = sectionOffsets(this.rows(sizes), this.gap, this.padding); + const { height } = sectionOffsets(this.rows(sizes), this.gap, this.padding, hairline()); return { w: Math.max(w, this.minWidth), h: Math.max(height, this.minHeight) }; } @@ -59,10 +60,10 @@ export class SectionStack extends VStack { this.applyFolds(); const items = this.items; const sizes = items.map((item) => item.measure()); - const { ys } = sectionOffsets(this.rows(sizes), this.gap, this.padding); + const { ys } = sectionOffsets(this.rows(sizes), this.gap, this.padding, hairline()); items.forEach((item, i) => { const band = sectionStartOf(item)?.sectionStart === 'band'; - item.position.set(band ? 0 : this.padding, Math.round(ys[i]!)); + item.position.set(band ? 0 : this.padding, ys[i] ?? 0); item.resize(Math.max(0, band ? this.w : this.w - this.padding * 2), Math.round(sizes[i]!.h)); }); } diff --git a/src/ui/pixelGrid.ts b/src/ui/pixelGrid.ts new file mode 100644 index 00000000..335abbd2 --- /dev/null +++ b/src/ui/pixelGrid.ts @@ -0,0 +1,60 @@ +import { type Graphics, Text, type TextOptions } from 'pixi.js'; + +/** + * The editor lays itself out in whole CSS pixels, and a screen scaled to 125 % or 150 % has no + * whole device pixel under most of them: every odd coordinate lands half-way between two, and the + * GPU answers by averaging both. Text loses its edges that way and a hairline becomes a pixel and + * a half of mixed colour. + * + * Pixi rounds what it draws to device pixels when asked (`roundPixels`), but only per object, and + * the answer cannot be a blanket yes: a sprite of the game or a curve would be bent by it. + * `crispText` and `crisp` are how a text or a box of the interface asks for it, and `hairline` is + * the width that keeps a line one device pixel wherever the rounding puts it. + */ + +let resolution = 1; + +/** Whether whole CSS pixels miss the device's grid at this resolution. */ +export function offDeviceGrid(res: number): boolean { + return !Number.isInteger(res); +} + +/** The width, in CSS pixels, of the line a browser draws for `1px`: whole device pixels, never fewer than one. */ +export function hairlineFor(res: number): number { + return Math.max(1, Math.floor(res)) / res; +} + +/** `hairlineFor` the screen the editor runs on; 1 wherever a CSS pixel is a whole number of device pixels. */ +export function hairline(): number { + return hairlineFor(resolution); +} + +/** + * How far the whole stage is pushed off its origin, in CSS pixels. At 150 % every odd layout pixel + * is exactly half a device pixel, and which way such a tie is rounded is decided by the last bit of + * the GPU's arithmetic: one band came out a row shorter than its neighbours. A nudge far above that + * noise and far below anything visible sends every tie the same way. + */ +export function tieBreakFor(res: number): number { + return offDeviceGrid(res) ? 1 / 32 : 0; +} + +/** Tells the grid how many device pixels a layout pixel is. Called once, before the first widget exists. */ +export function initPixelGrid(res: number): void { + resolution = res; +} + +/** A text of the interface: drawn texel on pixel, wherever the layout put it. */ +export function crispText(options: TextOptions = {}): Text { + return new Text({ roundPixels: true, ...options }); +} + +/** + * Puts the corners of a box on device pixels. For shapes made of upright rectangles only – a curve + * or a slanted line would be bent by it – and left alone where the layout's pixels already are the + * device's, so nothing changes there. + */ +export function crisp(shape: T): T { + shape.roundPixels = offDeviceGrid(resolution); + return shape; +} diff --git a/src/ui/sections.ts b/src/ui/sections.ts index f5ef8504..fd6600f1 100644 --- a/src/ui/sections.ts +++ b/src/ui/sections.ts @@ -3,6 +3,7 @@ * one belongs to it. A folded group keeps its band and hides its rows. */ import type { Graphics } from 'pixi.js'; +import { crisp, hairline } from './pixelGrid'; import { Theme } from './Theme'; /** Height of a band, here and at the head of a list that carries its own. */ @@ -48,30 +49,33 @@ export interface SectionRow { /** * Where the shown children of a column go. Bands stand flush on each other and on the top edge, * a group keeps the column's padding to the band after it, and rows keep the gap between them. + * `line` is how wide the bands draw their lines. */ export function sectionOffsets( rows: readonly SectionRow[], gap: number, padding: number, + line = 1, ): { ys: number[]; height: number } { const ys: number[] = []; let y = rows[0]?.start ? 0 : padding; rows.forEach((row, i) => { const prev = rows[i - 1]; - // One pixel shared, so two bands show one line between them, not two. - if (prev) y += row.start ? (prev.band ? -1 : padding) : prev.band ? gap + 2 : gap; + // One line shared, so two bands show one between them, not two. + if (prev) y += row.start ? (prev.band ? -line : padding) : prev.band ? gap + 2 : gap; ys.push(y); y += row.h; }); const last = rows.at(-1); - return { ys, height: y + (last && !last.band ? padding : 0) }; + return { ys, height: Math.ceil(y + (last && !last.band ? padding : 0)) }; } /** The band itself: header colour between two lines, from `x` on. `g` is cleared by the caller. */ export function drawSectionBand(g: Graphics, x: number, y: number, w: number, h: number): void { - g.rect(x, y, w, h).fill(Theme.colors.panelHeader); - g.rect(x, y, w, 1).fill(Theme.colors.border); - g.rect(x, y + h - 1, w, 1).fill(Theme.colors.border); + const line = hairline(); + crisp(g).rect(x, y, w, h).fill(Theme.colors.panelHeader); + g.rect(x, y, w, line).fill(Theme.colors.border); + g.rect(x, y + h - line, w, line).fill(Theme.colors.border); } /** The mark in front of a band's title, centred on `x`/`y`: pointing down while open, right while folded. */ diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index 10f88227..d183f09d 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -3,7 +3,7 @@ import { type ColorSource, type FederatedPointerEvent, Graphics, - Text, + type Text, CanvasTextMetrics, } from 'pixi.js'; import { drawControlBox } from '../draw'; @@ -12,6 +12,7 @@ import { drawSvg } from '../svg'; import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { type Size, Widget } from '../Widget'; import { truncate } from './Label'; +import { crisp, crispText, hairline } from '../pixelGrid'; export interface ButtonOptions { variant?: 'default' | 'primary' | 'ghost' | 'danger'; @@ -36,7 +37,7 @@ const ICON_SIZE = 16; const ICON_GAP = 6; export class Button extends Widget { - protected readonly bg = new Graphics(); + protected readonly bg = crisp(new Graphics()); protected readonly labelText: Text; /** The label as set; `labelText` holds the possibly truncated version on screen. */ private fullText: string; @@ -70,7 +71,10 @@ export class Button extends Widget { this.fullText = text; this.style = options.style ?? 'body'; this.toggle = options.toggle ?? false; - this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : this.style) }); + this.labelText = crispText({ + text, + style: textStyle(this.variant === 'primary' ? 'accent' : this.style), + }); this.icon = options.icon ?? null; this.addChild(this.bg, this.labelText); if (this.icon) this.addChild(this.iconGfx); @@ -252,7 +256,10 @@ export class Button extends Widget { } else if (fill !== null) { this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(fill); } else if (stroke !== null) { - this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke }); + const line = hairline(); + this.bg + .roundRect(line / 2, line / 2, this.w - line, this.h - line, Theme.radius) + .stroke({ width: line, color: stroke }); } } diff --git a/src/ui/widgets/Checkbox.ts b/src/ui/widgets/Checkbox.ts index 9594646f..5f2f5b55 100644 --- a/src/ui/widgets/Checkbox.ts +++ b/src/ui/widgets/Checkbox.ts @@ -6,6 +6,8 @@ import { Widget } from '../Widget'; export class Checkbox extends Widget { private readonly box = new Graphics(); + /** Apart from the box: its corners go on whole pixels, a slanted stroke must not. */ + private readonly tick = new Graphics(); onChange?: (checked: boolean) => void; constructor(public checked = false) { @@ -14,7 +16,7 @@ export class Checkbox extends Widget { this.cursor = Theme.hoverCursor; this.minWidth = Theme.controlHeight; this.minHeight = Theme.controlHeight; - this.addChild(this.box); + this.addChild(this.box, this.tick); this.trackHover(); this.on('pointertap', () => this.toggle()); } @@ -51,8 +53,9 @@ export class Checkbox extends Widget { radius: 3, y, }); + this.tick.clear(); if (this.checked) { - this.box + this.tick .moveTo(4, y + 8.5) .lineTo(7, y + 11.5) .lineTo(12.5, y + 5) diff --git a/src/ui/widgets/Label.ts b/src/ui/widgets/Label.ts index 3cfceb88..49f6b3d4 100644 --- a/src/ui/widgets/Label.ts +++ b/src/ui/widgets/Label.ts @@ -1,7 +1,8 @@ -import { CanvasTextMetrics, Graphics, Text } from 'pixi.js'; +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; @@ -28,7 +29,7 @@ export class Label extends Widget { 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.textNode = crispText({ text, style: textStyle(options.style ?? 'body') }); this.addChild(this.textNode); this.minHeight = Theme.rowHeight; } diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts index a3c752b3..abf809af 100644 --- a/src/ui/widgets/ListRow.ts +++ b/src/ui/widgets/ListRow.ts @@ -1,10 +1,11 @@ -import { Color, type ColorSource, Graphics, Text } from 'pixi.js'; +import { Color, type ColorSource, Graphics } from 'pixi.js'; import { centreY } from '../math'; import { drawSvg } from '../svg'; import { Theme, textStyle } from '../Theme'; import { Widget } from '../Widget'; import { truncate } from './Label'; import type { ListItem } from './ListView'; +import { crisp, crispText } from '../pixelGrid'; /** Width reserved in front of every row of a tree list for the chevron. */ const CHEVRON_SLOT = 14; @@ -13,9 +14,9 @@ const ICON_SIZE = 16; /** One row of a `ListView`. Purely visual: pointer handling and selection state live in the list. */ export class ListRow extends Widget { - private readonly bg = new Graphics(); - private readonly labelText = new Text({ text: '', style: textStyle('body') }); - private readonly hint = new Text({ text: '', style: textStyle('small') }); + private readonly bg = crisp(new Graphics()); + private readonly labelText = crispText({ text: '', style: textStyle('body') }); + private readonly hint = crispText({ text: '', style: textStyle('small') }); private readonly icon = new Graphics(); private readonly marks: Graphics[] = []; private selected = false; diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 94367577..53ed2a47 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -1,4 +1,4 @@ -import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { type FederatedPointerEvent, Graphics } from 'pixi.js'; import { pressCount } from '../clicks'; import { centreY } from '../math'; import { cancelFrameWork, onFrame } from '../render'; @@ -6,6 +6,7 @@ import { Theme, textStyle } from '../Theme'; import { type Size, Widget } from '../Widget'; import { ListRow } from './ListRow'; import type { Popup } from './Popup'; +import { crisp, crispText } from '../pixelGrid'; export interface ListItem { id: string; @@ -107,9 +108,9 @@ export class ListView extends Widget { onToggle?: (id: string, expand: boolean) => void; rowHeight: number = Theme.rowHeight; emptyText = 'Nothing here yet'; - private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') }); - private readonly dropMarker = new Graphics(); - private readonly pingMarker = new Graphics(); + private readonly emptyLabel = crispText({ text: '', style: textStyle('muted') }); + private readonly dropMarker = crisp(new Graphics()); + private readonly pingMarker = crisp(new Graphics()); private drag: DragState | null = null; /** The row that glows after `ping`, and when it started. */ private pinged: { id: string; start: number } | null = null; diff --git a/src/ui/widgets/Panel.ts b/src/ui/widgets/Panel.ts index f5050f65..bf5824aa 100644 --- a/src/ui/widgets/Panel.ts +++ b/src/ui/widgets/Panel.ts @@ -1,5 +1,6 @@ import { Graphics } from 'pixi.js'; import { HStack, VStack } from '../layout/Stack'; +import { crisp, hairline } from '../pixelGrid'; import { Theme } from '../Theme'; import { type Size, Widget } from '../Widget'; import { Label } from './Label'; @@ -16,9 +17,9 @@ export interface PanelOptions { export class Panel extends Widget { /** Height of the title row – the floor for a panel that must stay grabbable when dragged shut. */ static readonly HEADER_HEIGHT = Theme.rowHeight + 4; - private readonly bg = new Graphics(); + private readonly bg = crisp(new Graphics()); /** Drawn above the content: a row flush with the edge (padding 0) would otherwise paint over it. */ - private readonly frame = new Graphics(); + private readonly frame = crisp(new Graphics()); readonly header: HStack; readonly content: VStack; private readonly titleLabel: Label | null = null; @@ -67,6 +68,9 @@ export class Panel extends Widget { const c = Theme.colors; this.bg.rect(0, 0, this.w, this.h).fill(c.panel); if (this.titleLabel) this.bg.rect(0, 0, this.w, this.header.minHeight).fill(c.panelHeader); - this.frame.rect(0.5, 0.5, this.w - 1, this.h - 1).stroke({ width: 1, color: c.border }); + const line = hairline(); + this.frame + .rect(line / 2, line / 2, this.w - line, this.h - line) + .stroke({ width: line, color: c.border }); } } diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index cccb4df9..ac0d5e30 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -1,5 +1,6 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; import { clamp } from '../math'; +import { crisp } from '../pixelGrid'; import { Theme } from '../Theme'; import { Widget } from '../Widget'; @@ -9,7 +10,7 @@ import { Widget } from '../Widget'; * rather than on top of its right edge. */ export class ScrollContainer extends Widget { - private readonly maskShape = new Graphics(); + private readonly maskShape = crisp(new Graphics()); private readonly scrollbar = new Graphics(); private readonly viewportNode = new Container(); private content: Widget | null = null; diff --git a/src/ui/widgets/Separator.ts b/src/ui/widgets/Separator.ts index 2e8330c8..285adc23 100644 --- a/src/ui/widgets/Separator.ts +++ b/src/ui/widgets/Separator.ts @@ -1,10 +1,11 @@ import { Graphics } from 'pixi.js'; +import { crisp, hairline } from '../pixelGrid'; import { Theme } from '../Theme'; import { Widget } from '../Widget'; /** A hairline between two groups of entries. Takes no pointer events and answers no search. */ export class Separator extends Widget { - private readonly line = new Graphics(); + private readonly line = crisp(new Graphics()); constructor() { super(); @@ -16,7 +17,7 @@ export class Separator extends Widget { override redraw(): void { this.line .clear() - .rect(Theme.padding, Math.floor(this.h / 2), Math.max(0, this.w - Theme.padding * 2), 1) + .rect(Theme.padding, Math.floor(this.h / 2), Math.max(0, this.w - Theme.padding * 2), hairline()) .fill({ color: Theme.colors.border }); } } diff --git a/src/ui/widgets/SortableList.ts b/src/ui/widgets/SortableList.ts index c9aeb603..0c12cac8 100644 --- a/src/ui/widgets/SortableList.ts +++ b/src/ui/widgets/SortableList.ts @@ -12,7 +12,7 @@ * The list never changes its items itself. A drop, a cross, a name or a number reports to the owner, * the owner runs its command, and the next `setItems` shows the result. */ -import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js'; import { pressCount } from '../clicks'; import { drawFoldMark, @@ -29,6 +29,7 @@ import { type Size, Widget } from '../Widget'; import { truncate } from './Label'; import { NumberField } from './NumberField'; import { TextField } from './TextField'; +import { crisp, crispText } from '../pixelGrid'; /** Height of one row: the picture plus the air a hovered row needs to look like a row. */ export const SORTABLE_ROW_HEIGHT = 30; @@ -174,9 +175,9 @@ class RowNameField extends TextField { export class SortableList extends Widget implements SectionStart { private collapsed: boolean; - private readonly background = new Graphics(); - private readonly marker = new Graphics(); - private readonly heading = new Text({ text: '', style: textStyle('title') }); + private readonly background = crisp(new Graphics()); + private readonly marker = crisp(new Graphics()); + private readonly heading = crispText({ text: '', style: textStyle('title') }); private readonly plus = new Graphics(); private readonly rows: Row[] = []; /** Display order, top first. */ @@ -376,8 +377,8 @@ export class SortableList extends Widget implements SectionStart { const box = new Container(); const art = new Graphics(); const clip = new Graphics(); - const label = new Text({ text: '', style: textStyle('body') }); - const hint = new Text({ text: '', style: textStyle('small') }); + const label = crispText({ text: '', style: textStyle('body') }); + const hint = crispText({ text: '', style: textStyle('small') }); const eye = new Graphics(); const duplicate = new Graphics(); const cross = new Graphics(); @@ -699,7 +700,7 @@ const suffixWidths = new Map(); function suffixWidth(suffix: string): number { let width = suffixWidths.get(suffix); if (width === undefined) { - const text = new Text({ text: suffix, style: textStyle('small') }); + const text = crispText({ text: suffix, style: textStyle('small') }); width = text.width; text.destroy(); suffixWidths.set(suffix, width); diff --git a/src/ui/widgets/Splitter.ts b/src/ui/widgets/Splitter.ts index ac336aef..4ec7dfa8 100644 --- a/src/ui/widgets/Splitter.ts +++ b/src/ui/widgets/Splitter.ts @@ -1,4 +1,5 @@ import { type FederatedPointerEvent, Graphics } from 'pixi.js'; +import { crisp } from '../pixelGrid'; import { Theme } from '../Theme'; import { Widget } from '../Widget'; @@ -6,7 +7,7 @@ import { Widget } from '../Widget'; export class Splitter extends Widget { /** Size across the drag axis; the visible bar fills it, so this is also the grab area. */ static readonly THICKNESS = 5; - private readonly bar = new Graphics(); + private readonly bar = crisp(new Graphics()); private dragging = false; private last = 0; onDrag?: (delta: number) => void; diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts index a9e0b98d..945b90be 100644 --- a/src/ui/widgets/TextField.ts +++ b/src/ui/widgets/TextField.ts @@ -1,4 +1,4 @@ -import { CanvasTextMetrics, Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { CanvasTextMetrics, Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js'; import { pressCount } from '../clicks'; import { drawControlBox } from '../draw'; import type { TextEditTarget } from '../KeyboardBridge'; @@ -6,6 +6,7 @@ import { centreY, clamp } from '../math'; import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { ui } from '../UIContext'; import { type Size, Widget } from '../Widget'; +import { crisp, crispText, hairline } from '../pixelGrid'; export interface TextFieldOptions { placeholder?: string; @@ -34,15 +35,15 @@ const BULLET = '•'; */ export class TextField extends Widget implements TextEditTarget { protected readonly bg = new Graphics(); - protected readonly selectionShape = new Graphics(); - protected readonly caret = new Graphics(); + protected readonly selectionShape = crisp(new Graphics()); + protected readonly caret = crisp(new Graphics()); protected readonly textNode: Text; protected readonly placeholderNode: Text; protected valueText = ''; protected editing = false; protected selStart = 0; protected selEnd = 0; - private readonly clipShape = new Graphics(); + private readonly clipShape = crisp(new Graphics()); private originalValue = ''; private scrollX = 0; private caretVisible = true; @@ -68,8 +69,8 @@ export class TextField extends Widget implements TextEditTarget { this.cursor = this.readOnly ? 'default' : 'text'; this.minHeight = Theme.controlHeight; this.minWidth = options.width ?? 80; - this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') }); - this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') }); + this.textNode = crispText({ text: value, style: textStyle(options.style ?? 'body') }); + this.placeholderNode = crispText({ text: this.placeholder, style: textStyle('muted') }); // Only the contents are clipped: a mask on the whole field cut its border away along the // straight edges and left it showing at the rounded corners alone. const content = new Container(); @@ -293,7 +294,7 @@ export class TextField extends Widget implements TextEditTarget { .fill({ color: c.accent, alpha: 0.45 }); } if (this.caretVisible) { - this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text); + this.caret.rect(Math.round(textX + caretX), textY, hairline(), this.textNode.height).fill(c.text); } } diff --git a/tests/editor/sections.test.ts b/tests/editor/sections.test.ts index 766b7913..77f5cff1 100644 --- a/tests/editor/sections.test.ts +++ b/tests/editor/sections.test.ts @@ -95,6 +95,13 @@ describe('where bands and rows go', () => { expect(height).toBe(ys[2]! + SECTION_BAND_HEIGHT); }); + it('shares exactly the line the bands draw, and still ends on a whole pixel', () => { + const line = 2 / 3; + const { ys, height } = sectionOffsets([band, band], GAP, PADDING, line); + expect(ys[1]).toBeCloseTo(SECTION_BAND_HEIGHT - line, 10); + expect(height).toBe(Math.ceil(2 * SECTION_BAND_HEIGHT - line)); + }); + it('starts inside the padding without a band on top', () => { expect(sectionOffsets([plain, plain], GAP, PADDING).ys).toEqual([PADDING, PADDING + 24 + GAP]); }); diff --git a/tests/ui/pixel-grid.test.ts b/tests/ui/pixel-grid.test.ts new file mode 100644 index 00000000..49d0ec52 --- /dev/null +++ b/tests/ui/pixel-grid.test.ts @@ -0,0 +1,124 @@ +import { Graphics } from 'pixi.js'; +import { afterEach, describe, expect, it } from 'vitest'; +import { edgeContrast } from '../../src/editor/debug/sharpness'; +import { drawControlBox, drawHeaderBar } from '../../src/ui/draw'; +import { + crisp, + crispText, + hairline, + hairlineFor, + initPixelGrid, + offDeviceGrid, + tieBreakFor, +} from '../../src/ui/pixelGrid'; +import { drawSectionBand } from '../../src/ui/sections'; + +afterEach(() => initPixelGrid(1)); + +/** The rectangles a shape was drawn from, as `[x, y, w, h]`. */ +function rects(g: Graphics): number[][] { + return g.context.instructions.flatMap((instruction) => { + const data = instruction.data as { path?: { instructions: { action: string; data: number[] }[] } }; + return (data.path?.instructions ?? []) + .filter((step) => step.action === 'rect' || step.action === 'roundRect') + .map((step) => step.data.slice(0, 4)); + }); +} + +describe('the device pixel grid', () => { + it('names the scales at which whole layout pixels miss the device grid', () => { + expect([1, 2, 1.25, 1.5, 1.75].map(offDeviceGrid)).toEqual([false, false, true, true, true]); + }); + + it('makes a hairline whole device pixels wide, and never thinner than one', () => { + expect(hairlineFor(1)).toBe(1); + expect(hairlineFor(2)).toBe(1); + expect(hairlineFor(1.5)).toBeCloseTo(1 / 1.5, 10); + expect(hairlineFor(1.25)).toBeCloseTo(1 / 1.25, 10); + expect(hairlineFor(1.75) * 1.75).toBeCloseTo(1, 10); + expect(hairlineFor(0.5) * 0.5).toBeCloseTo(1, 10); + initPixelGrid(1.5); + expect(hairline()).toBeCloseTo(2 / 3, 10); + }); + + it('nudges the stage only where ties can happen, and by far less than a device pixel', () => { + expect(tieBreakFor(1)).toBe(0); + expect(tieBreakFor(2)).toBe(0); + for (const res of [1.25, 1.5, 1.75]) { + expect(tieBreakFor(res)).toBeGreaterThan(0.01); + expect(tieBreakFor(res) * res).toBeLessThan(0.1); + } + }); + + it('rounds every text of the interface, at any scale', () => { + expect(crispText({ text: 'a' }).roundPixels).toBe(true); + initPixelGrid(1.5); + expect(crispText().roundPixels).toBe(true); + }); + + it('rounds a box only where the layout pixels miss the device grid', () => { + expect(crisp(new Graphics()).roundPixels).toBe(false); + initPixelGrid(2); + expect(crisp(new Graphics()).roundPixels).toBe(false); + initPixelGrid(1.5); + expect(crisp(new Graphics()).roundPixels).toBe(true); + }); + + it('draws the shared boxes with lines of one layout pixel where that is a device pixel', () => { + const bar = new Graphics(); + drawHeaderBar(bar, 200, 30); + expect(rects(bar)).toEqual([ + [0, 0, 200, 30], + [0, 29, 200, 1], + ]); + const box = new Graphics(); + drawControlBox(box, 100, 24, { fill: 0, border: 1 }); + expect(rects(box)).toEqual([ + [0, 0, 100, 24], + [0.5, 0.5, 99, 23], + ]); + expect(box.roundPixels).toBe(false); + }); + + it('draws them with lines of one device pixel at 150 %, flush with the edge they belong to', () => { + initPixelGrid(1.5); + const line = 2 / 3; + const bar = new Graphics(); + drawHeaderBar(bar, 200, 30); + expect(bar.roundPixels).toBe(true); + expect(rects(bar)[1]).toEqual([0, 30 - line, 200, line]); + + const box = new Graphics(); + drawControlBox(box, 100, 24, { fill: 0, border: 1 }); + expect(box.roundPixels).toBe(true); + expect(rects(box)[1]).toEqual([line / 2, line / 2, 100 - line, 24 - line]); + + const band = new Graphics(); + drawSectionBand(band, 0, 10, 80, 26); + expect(band.roundPixels).toBe(true); + expect(rects(band)).toEqual([ + [0, 10, 80, 26], + [0, 10, 80, line], + [0, 36 - line, 80, line], + ]); + }); +}); + +describe('edgeContrast', () => { + /** One row of grey values, as RGBA, two rows high so every pixel has a neighbour below. */ + const picture = (row: number[]) => { + const rgba = [...row, ...row].flatMap((v) => [v, v, v, 255]); + return edgeContrast(rgba, row.length, 2); + }; + + it('scores a hard edge higher than the same edge averaged over two pixels', () => { + const hard = picture([0, 0, 200, 200, 0, 0]); + const soft = picture([0, 100, 200, 100, 0, 0]); + expect(hard).toBeCloseTo(200, 5); + expect(soft).toBeCloseTo(100, 5); + }); + + it('is zero for a picture without an edge', () => { + expect(picture([40, 40, 41, 40])).toBe(0); + }); +}); diff --git a/tools/e2e/62-device-pixels.mjs b/tools/e2e/62-device-pixels.mjs new file mode 100644 index 00000000..c0f7ac5c --- /dev/null +++ b/tools/e2e/62-device-pixels.mjs @@ -0,0 +1,105 @@ +/** + * The interface on a screen scaled to 150 %: the one thing every other step is blind to, since the + * harness runs at 100 %, where a layout pixel is a device pixel. Text has to come out as hard as it + * does at 100 %, and a hairline has to be one device pixel of its own colour, not one and a half + * with the half mixed. Reloads the page twice, so the steps after it start from a fresh editor. + */ +export async function devicePixels(t) { + const { check, json, cdp, navigate, select, storedObjects, wheel, exceptions } = t; + const size = { width: await json('innerWidth'), height: await json('innerHeight') }; + const scale = (deviceScaleFactor) => + cdp.send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor, mobile: false }); + // The toolbar: captions on a plain ground, read at 100 % and at 150 %. + const textEdges = () => json(`__gm.edgeContrast(0, 0, innerWidth, 30)`); + const pickFirst = async () => { + const holderId = await json('__gm.state.activeScene.id'); + const first = (await storedObjects())[0]; + await select({ kind: 'object', holderId, objectId: first.id, objectIds: [first.id] }); + }; + + await pickFirst(); + const plain = { grid: await json('__gm.pixelGrid()'), edges: await textEdges() }; + + await scale(1.5); + await navigate(); + await pickFirst(); + + // 62a. The editor knows the grid it draws on. + const grid = await json('__gm.pixelGrid()'); + check( + 'at 150 % a hairline is one device pixel wide', + plain.grid.resolution === 1 && + plain.grid.hairline === 1 && + grid.resolution === 1.5 && + Math.abs(grid.hairline * 1.5 - 1) < 1e-9, + JSON.stringify({ plain: plain.grid, grid }), + ); + + // 62b. Both lines of every band of the inspector, read down a column of device pixels: one row + // each, in the border's own colour, and only the band's colour between them. A band on an odd + // layout pixel starts half-way between two device pixels, which is the case that used to blur. + const colours = { + border: await json(`__gm.themeColor('border')`), + header: await json(`__gm.themeColor('panelHeader')`), + }; + // One pixel of scroll flips every band between even and odd, so both are read. The window is made + // too low for the inspector's rows first: one that shows them all has nothing to scroll. + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: size.width, + height: 420, + deviceScaleFactor: 1.5, + mobile: false, + }); + await t.settle(); + const inspector = await json('__gm.inspectorRect()'); + const bands = []; + for (const scrolled of [0, 1]) { + if (scrolled) await wheel(inspector.x + inspector.w / 2, inspector.y + inspector.h / 2, 1); + for (const title of ['SWITCHES', 'POSITION', 'TRANSFORM', 'PROPERTIES']) { + const band = await json(`__gm.section(${JSON.stringify(title)})`); + if (!band) continue; + const { x, y, w, h } = band.rect; + // A band cut off by the edge of the panel has no second line to read. + if (y < inspector.y || y + h > inspector.y + inspector.h) continue; + const column = await json(`__gm.deviceColumn(${x + w - 40}, ${y}, ${h})`); + const runs = []; + for (const colour of column) { + if (runs.at(-1)?.colour === colour) runs.at(-1).rows += 1; + else runs.push({ colour, rows: 1 }); + } + bands.push({ title, y, runs: runs.map((run) => `${run.colour}*${run.rows}`) }); + } + } + const crisp = (band) => + band.runs.length === 3 && + band.runs[0] === `${colours.border}*1` && + band.runs[1].startsWith(`${colours.header}*`) && + band.runs[2] === `${colours.border}*1`; + const odd = (band) => Math.round(band.y) % 2 === 1; + check( + 'the lines of a band are one device pixel each, in the unmixed colour', + bands.every(crisp) && bands.some(odd) && bands.some((band) => !odd(band)), + JSON.stringify({ bands, colours }), + ); + await scale(1.5); + await t.settle(); + + // 62c. Text keeps its edges: against the same captions at 100 %, where nothing can sit between pixels. + const edges = await textEdges(); + const kept = edges / plain.edges; + check( + 'text is as hard-edged at 150 % as at 100 %', + kept > 0.96, + JSON.stringify({ plain: plain.edges, edges, kept }), + ); + await t.shot('62-device-pixels'); + + await scale(1); + await navigate(); + const back = await json('__gm.pixelGrid()'); + check( + 'back at 100 % without exceptions', + back.resolution === 1 && exceptions().length === 0, + JSON.stringify({ back, exceptions: exceptions() }), + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 6b041c62..0c12c187 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -64,6 +64,7 @@ import { clipAreas } from './58-clip-areas.mjs'; import { whilePlaying } from './59-while-playing.mjs'; import { release } from './60-release.mjs'; import { fullscreen } from './61-fullscreen.mjs'; +import { devicePixels } from './62-device-pixels.mjs'; export const STEPS = [ hierarchy, @@ -127,4 +128,5 @@ export const STEPS = [ whilePlaying, release, fullscreen, + devicePixels, ]; diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 3673a8d1..e1ebc7f1 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -541,6 +541,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { export async function runChecks(options, body) { const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options; + const { deviceScaleFactor = 1 } = options; const { proc: chrome, stop: stopBrowser, where } = await launchBrowser({ port, width, height }); console.log(`browser: ${where}`); @@ -603,7 +604,7 @@ export async function runChecks(options, body) { await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, - deviceScaleFactor: 1, + deviceScaleFactor, mobile: false, }); await requireGpu(cdp);