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>
This commit is contained in:
1 parent
275e50af01
commit
32a0af6e2c
49 files changed
+582
-134
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, Text } from 'pixi.js';
|
import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, type Text } from 'pixi.js';
|
||||||
import { centreY, Theme, textStyle } from '../ui';
|
import { centreY, Theme, textStyle, crispText } from '../ui';
|
||||||
import { TabButton } from './TabButton';
|
import { TabButton } from './TabButton';
|
||||||
|
|
||||||
const GAP = 4;
|
const GAP = 4;
|
||||||
@@ -24,7 +24,7 @@ export class DocumentTab extends TabButton {
|
|||||||
private readonly onClose: () => void,
|
private readonly onClose: () => void,
|
||||||
) {
|
) {
|
||||||
super(label, { align: 'left', icon, onClick: onPick });
|
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.eventMode = 'static';
|
||||||
this.closeText.cursor = Theme.hoverCursor;
|
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.
|
// Stopped on the way down as well, or the tab under it would count the press as its own click.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* The list differs per kind (tiles for pictures, rows with a sample line for typefaces, rows with a
|
* 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.
|
* ▶ 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 { AssetType } from '../core/model/AssetTypes';
|
||||||
import type { Project } from '../core/model/Project';
|
import type { Project } from '../core/model/Project';
|
||||||
import type { StoredFile } from '../core/store/FileStore';
|
import type { StoredFile } from '../core/store/FileStore';
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Theme,
|
Theme,
|
||||||
textStyle,
|
textStyle,
|
||||||
Widget,
|
Widget,
|
||||||
|
crispText,
|
||||||
} from '../ui';
|
} from '../ui';
|
||||||
import { dialogButtons } from './FormDialog';
|
import { dialogButtons } from './FormDialog';
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export abstract class PickerList<Item extends PickerItem, Row extends PickerRow>
|
|||||||
protected items: Item[] = [];
|
protected items: Item[] = [];
|
||||||
protected rows: Row[] = [];
|
protected rows: Row[] = [];
|
||||||
private selectedFile: string | null = null;
|
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)[] = [];
|
private readonly unwatch: (() => void)[] = [];
|
||||||
onPick?: (file: string) => void;
|
onPick?: (file: string) => void;
|
||||||
onActivate?: (file: string) => void;
|
onActivate?: (file: string) => void;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
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 { DocumentTab } from './DocumentTab';
|
||||||
import { TabButton } from './TabButton';
|
import { TabButton } from './TabButton';
|
||||||
import { tabRowLayout } from './tabRow';
|
import { tabRowLayout } from './tabRow';
|
||||||
@@ -44,7 +44,7 @@ export class TabStrip<T extends string> extends HStack {
|
|||||||
private readonly icons = new Map<T, string>();
|
private readonly icons = new Map<T, string>();
|
||||||
private readonly overflow: Button;
|
private readonly overflow: Button;
|
||||||
/** The bar the cards sit on, drawn behind every tab. */
|
/** 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 hiddenTabs: T[] = [];
|
||||||
private active: T | null;
|
private active: T | null;
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ export class TabStrip<T extends string> extends HStack {
|
|||||||
this.bar.clear();
|
this.bar.clear();
|
||||||
this.bar.rect(0, 0, this.w, this.h).fill(c.tabBar);
|
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.
|
// 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 {
|
private applyOverflow(): void {
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
clipboardKeyCount,
|
clipboardKeyCount,
|
||||||
copyAnimationKeys,
|
copyAnimationKeys,
|
||||||
@@ -24,7 +30,18 @@ import {
|
|||||||
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
|
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
import { findAssetOf } from '../../core/model/Project';
|
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 { type EditorState, trackSelection } from '../state';
|
||||||
import { FramesDialog, openFramesDialog } from './FramesDialog';
|
import { FramesDialog, openFramesDialog } from './FramesDialog';
|
||||||
import {
|
import {
|
||||||
@@ -87,7 +104,7 @@ export class TimelineCanvas extends Widget {
|
|||||||
private readonly rulerLayer = new Container();
|
private readonly rulerLayer = new Container();
|
||||||
private readonly labels = new Map<string, Text>();
|
private readonly labels = new Map<string, Text>();
|
||||||
private readonly rulerTexts: Text[] = [];
|
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 readonly clip = new Graphics();
|
||||||
private view: TimelineView = { ...DEFAULT_VIEW };
|
private view: TimelineView = { ...DEFAULT_VIEW };
|
||||||
private assetId = '';
|
private assetId = '';
|
||||||
@@ -604,7 +621,7 @@ export class TimelineCanvas extends Widget {
|
|||||||
this.cells.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted);
|
this.cells.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted);
|
||||||
let text = this.rulerTexts[used];
|
let text = this.rulerTexts[used];
|
||||||
if (!text) {
|
if (!text) {
|
||||||
text = new Text({ style: textStyle('small') });
|
text = crispText({ style: textStyle('small') });
|
||||||
this.rulerTexts.push(text);
|
this.rulerTexts.push(text);
|
||||||
this.rulerLayer.addChild(text);
|
this.rulerLayer.addChild(text);
|
||||||
}
|
}
|
||||||
@@ -667,7 +684,7 @@ export class TimelineCanvas extends Widget {
|
|||||||
alive.add(track.id);
|
alive.add(track.id);
|
||||||
let label = this.labels.get(track.id);
|
let label = this.labels.get(track.id);
|
||||||
if (!label) {
|
if (!label) {
|
||||||
label = new Text({ style: textStyle('body') });
|
label = crispText({ style: textStyle('body') });
|
||||||
this.labels.set(track.id, label);
|
this.labels.set(track.id, label);
|
||||||
this.labelLayer.addChild(label);
|
this.labelLayer.addChild(label);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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 { SetCurvePointsCommand } from '../../core/commands/curveCommands';
|
||||||
import type { CurveAsset, VectorPoint, VectorRing } from '../../core/model/Asset';
|
import type { CurveAsset, VectorPoint, VectorRing } from '../../core/model/Asset';
|
||||||
import { curveExtent, curveValue, sanitizedCurve } from '../../core/model/curve';
|
import { curveExtent, curveValue, sanitizedCurve } from '../../core/model/curve';
|
||||||
import { findAssetOf } from '../../core/model/Project';
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
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 { FramePreview } from '../framePreview';
|
||||||
import { deleteTakes, pickedIn, pointPress } from '../pointGestures';
|
import { deleteTakes, pickedIn, pointPress } from '../pointGestures';
|
||||||
import { DRAG_THRESHOLD, drawPointGrip, POINT_SIZE } from '../pointGrips';
|
import { DRAG_THRESHOLD, drawPointGrip, POINT_SIZE } from '../pointGrips';
|
||||||
@@ -75,7 +75,9 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
|
|||||||
private readonly guides = new Graphics();
|
private readonly guides = new Graphics();
|
||||||
private readonly line = new Graphics();
|
private readonly line = new Graphics();
|
||||||
private readonly overlay = 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;
|
private drag: CurveDrag | null = null;
|
||||||
/** The point last pressed or put down – the one the header's point type is about; -1 for none. */
|
/** The point last pressed or put down – the one the header's point type is about; -1 for none. */
|
||||||
private picked = -1;
|
private picked = -1;
|
||||||
|
|||||||
@@ -1,18 +1,59 @@
|
|||||||
import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture';
|
import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture';
|
||||||
import { effectiveScale, gameClock } from '../../core/time';
|
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 { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||||
import { type Container, Mesh } from 'pixi.js';
|
import { type Container, Mesh } from 'pixi.js';
|
||||||
import type { HookContext } from './hookHelpers';
|
import type { HookContext } from './hookHelpers';
|
||||||
|
import { edgeContrast } from './sharpness';
|
||||||
|
|
||||||
/** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */
|
/** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */
|
||||||
export function renderHooks({ app, state, editor }: HookContext) {
|
export function renderHooks({ app, state, editor }: HookContext) {
|
||||||
const { viewport, gameView } = editor;
|
const { viewport, gameView } = editor;
|
||||||
const pixelAt = (x: number, y: number): string => stagePixel(app, x, y);
|
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 {
|
return {
|
||||||
pixelAt,
|
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. */
|
/** 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),
|
pixels: (points: readonly { x: number; y: number }[]) => stagePixels(app, points),
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<number>, 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;
|
||||||
@@ -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 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 { fontLibrary } from '../../viewport/FontLibrary';
|
||||||
import {
|
import {
|
||||||
currentPicker,
|
currentPicker,
|
||||||
@@ -41,8 +41,8 @@ class FontList extends RowList<FontItem, FontRow> {
|
|||||||
const background = new Graphics();
|
const background = new Graphics();
|
||||||
// Its own style object, never `textStyle('body')`: those are shared across the whole editor,
|
// 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.
|
// 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 sample = crispText({ style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }) });
|
||||||
const caption = new Text({ style: textStyle('small') });
|
const caption = crispText({ style: textStyle('small') });
|
||||||
box.addChild(background, sample, caption);
|
box.addChild(background, sample, caption);
|
||||||
return { box, background, sample, caption };
|
return { box, background, sample, caption };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Text, TextStyle } from 'pixi.js';
|
import { TextStyle } from 'pixi.js';
|
||||||
import { Theme, truncate, type Size, Widget } from '../../ui';
|
import { Theme, truncate, type Size, Widget, crispText } from '../../ui';
|
||||||
import { fontLibrary } from '../../viewport/FontLibrary';
|
import { fontLibrary } from '../../viewport/FontLibrary';
|
||||||
|
|
||||||
const SAMPLE_SIZE = 26;
|
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.
|
* hash. Reads in the editor's own typeface until the face has arrived, then redraws itself.
|
||||||
*/
|
*/
|
||||||
export class FontPreview extends Widget {
|
export class FontPreview extends Widget {
|
||||||
private readonly sample = new Text({
|
private readonly sample = crispText({
|
||||||
style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }),
|
style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }),
|
||||||
});
|
});
|
||||||
private readonly stopWatching: () => void;
|
private readonly stopWatching: () => void;
|
||||||
|
|||||||
@@ -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 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. */
|
/** 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;
|
export const ANNOTATION_TITLE_HEIGHT = 24;
|
||||||
@@ -26,7 +26,7 @@ export class AnnotationView extends Widget {
|
|||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.addChild(this.box);
|
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.caption.position.set(PAD, kind === 'group' ? 0 : PAD);
|
||||||
this.addChild(this.caption);
|
this.addChild(this.caption);
|
||||||
this.cursor = Theme.hoverCursor;
|
this.cursor = Theme.hoverCursor;
|
||||||
|
|||||||
@@ -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 { 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 { dashedLine } from '../../viewport/symbols';
|
||||||
import {
|
import {
|
||||||
arrowCorners,
|
arrowCorners,
|
||||||
@@ -314,7 +314,7 @@ export class EdgeLayer extends Container {
|
|||||||
private nextLabel(): Text {
|
private nextLabel(): Text {
|
||||||
let label = this.labels[this.labelCount];
|
let label = this.labels[this.labelCount];
|
||||||
if (!label) {
|
if (!label) {
|
||||||
label = new Text({ text: '', style: textStyle('small') });
|
label = crispText({ text: '', style: textStyle('small') });
|
||||||
this.labels.push(label);
|
this.labels.push(label);
|
||||||
this.labelLayer.addChild(label);
|
this.labelLayer.addChild(label);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { textOf } from '../../core/model/Properties';
|
||||||
import { AddNodeCommand, CollapseNodesCommand } from '../../core/commands/graphCommands';
|
import { AddNodeCommand, CollapseNodesCommand } from '../../core/commands/graphCommands';
|
||||||
import { SetMachineStartCommand } from '../../core/commands/assetCommands';
|
import { SetMachineStartCommand } from '../../core/commands/assetCommands';
|
||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
truncate,
|
truncate,
|
||||||
Widget,
|
Widget,
|
||||||
zoomFactor,
|
zoomFactor,
|
||||||
|
crispText,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import {
|
import {
|
||||||
annotationSelection,
|
annotationSelection,
|
||||||
@@ -104,7 +105,7 @@ export class GraphCanvas extends Widget {
|
|||||||
private readonly tempEdge = new Graphics();
|
private readonly tempEdge = new Graphics();
|
||||||
/** Drawn in screen space, so the band keeps a hairline border at any zoom. */
|
/** Drawn in screen space, so the band keeps a hairline border at any zoom. */
|
||||||
private readonly band = new Graphics();
|
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<string, NodeView>();
|
private readonly views = new Map<string, NodeView>();
|
||||||
private assetId = '';
|
private assetId = '';
|
||||||
private pan = { x: 0, y: 0 };
|
private pan = { x: 0, y: 0 };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
|
import { CanvasTextMetrics, Graphics, Rectangle, type Text } from 'pixi.js';
|
||||||
import {
|
import {
|
||||||
asNumber,
|
asNumber,
|
||||||
asString,
|
asString,
|
||||||
@@ -20,7 +20,16 @@ import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
|
|||||||
import type { Project } from '../../core/model/Project';
|
import type { Project } from '../../core/model/Project';
|
||||||
import { isList, type PropertyValue } from '../../core/model/Properties';
|
import { isList, type PropertyValue } from '../../core/model/Properties';
|
||||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
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 { keyLabel, referenceLabel } from '../inspector/fieldFactory';
|
||||||
import { collapsedNodeWidth } from './folding';
|
import { collapsedNodeWidth } from './folding';
|
||||||
|
|
||||||
@@ -275,7 +284,7 @@ export class NodeView extends Widget {
|
|||||||
for (const [key, value] of this.liveTexts) {
|
for (const [key, value] of this.liveTexts) {
|
||||||
const slot = this.valueSlots.get(key);
|
const slot = this.valueSlots.get(key);
|
||||||
if (!slot || slot.right - slot.left < 12) continue;
|
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);
|
text.position.set(slot.right - text.width, slot.top);
|
||||||
this.addChild(text);
|
this.addChild(text);
|
||||||
this.valueTexts.set(key, text);
|
this.valueTexts.set(key, text);
|
||||||
@@ -339,7 +348,7 @@ export class NodeView extends Widget {
|
|||||||
align: 'left' | 'right' = 'left',
|
align: 'left' | 'right' = 'left',
|
||||||
maxWidth = NODE_WIDTH - PAD * 2,
|
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);
|
node.position.set(align === 'left' ? x : x - node.width, y);
|
||||||
this.addChild(node);
|
this.addChild(node);
|
||||||
this.texts.push(node);
|
this.texts.push(node);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { PropertyBag } from '../../core/model/Properties';
|
|||||||
import { resolveTexture } from '../../core/model/texture';
|
import { resolveTexture } from '../../core/model/texture';
|
||||||
import { defaultsFor } from '../../core/properties/PropertySchema';
|
import { defaultsFor } from '../../core/properties/PropertySchema';
|
||||||
import { SHEET_EDGE } from '../../core/shader/nodes';
|
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 { ParticleField } from '../../viewport/particles';
|
||||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||||
@@ -21,7 +21,7 @@ const MIN_HEIGHT = 40;
|
|||||||
* has no way to know which of them is meant.
|
* has no way to know which of them is meant.
|
||||||
*/
|
*/
|
||||||
export class ParticlePreview extends Widget {
|
export class ParticlePreview extends Widget {
|
||||||
private readonly bg = new Graphics();
|
private readonly bg = crisp(new Graphics());
|
||||||
private readonly content = new Container();
|
private readonly content = new Container();
|
||||||
private readonly clip = new Graphics();
|
private readonly clip = new Graphics();
|
||||||
private readonly field = new ParticleField();
|
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
|
// 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.
|
// 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.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.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||||
this.field.container.visible = this.asset !== null;
|
this.field.container.visible = this.asset !== null;
|
||||||
if (!this.asset) return;
|
if (!this.asset) return;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ShaderAsset } from '../../core/model/Asset';
|
|||||||
import type { PropertyBag } from '../../core/model/Properties';
|
import type { PropertyBag } from '../../core/model/Properties';
|
||||||
import { SHADER_KEY } from '../../core/properties/shaders';
|
import { SHADER_KEY } from '../../core/properties/shaders';
|
||||||
import { type EdgeMode, SHEET_EDGE } from '../../core/shader/nodes';
|
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 { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||||
import { syncShader } from '../../viewport/shaders';
|
import { syncShader } from '../../viewport/shaders';
|
||||||
import { disposeSpriteMesh, newSpriteMesh } from '../../viewport/symbols';
|
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.
|
* The shader is the scene's own (`syncShader`), so what the panel shows is what a sprite would.
|
||||||
*/
|
*/
|
||||||
export class ShaderPreview extends Widget {
|
export class ShaderPreview extends Widget {
|
||||||
private readonly bg = new Graphics();
|
private readonly bg = crisp(new Graphics());
|
||||||
private readonly content = new Container();
|
private readonly content = new Container();
|
||||||
private readonly clip = new Graphics();
|
private readonly clip = new Graphics();
|
||||||
private readonly mesh = newSpriteMesh();
|
private readonly mesh = newSpriteMesh();
|
||||||
@@ -87,7 +87,7 @@ export class ShaderPreview extends Widget {
|
|||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel);
|
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.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||||
this.mesh.visible = this.shader !== null;
|
this.mesh.visible = this.shader !== null;
|
||||||
if (!this.shader) return;
|
if (!this.shader) return;
|
||||||
|
|||||||
@@ -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 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 { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import {
|
import {
|
||||||
currentPicker,
|
currentPicker,
|
||||||
@@ -48,8 +48,8 @@ class SoundList extends RowList<SoundItem, SoundRow> {
|
|||||||
const background = new Graphics();
|
const background = new Graphics();
|
||||||
const play = new Graphics();
|
const play = new Graphics();
|
||||||
drawSvg(play, TOOLBAR_ICON_SVG.play, Theme.colors.accentText);
|
drawSvg(play, TOOLBAR_ICON_SVG.play, Theme.colors.accentText);
|
||||||
const caption = new Text({ style: textStyle('body') });
|
const caption = crispText({ style: textStyle('body') });
|
||||||
const detail = new Text({ style: textStyle('small') });
|
const detail = crispText({ style: textStyle('small') });
|
||||||
box.addChild(background, play, caption, detail);
|
box.addChild(background, play, caption, detail);
|
||||||
return { box, background, play, caption, detail };
|
return { box, background, play, caption, detail };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 { textureLibrary } from '../../viewport/TextureLibrary';
|
||||||
import {
|
import {
|
||||||
currentPicker,
|
currentPicker,
|
||||||
@@ -63,7 +63,7 @@ class ImageGrid extends PickerList<ImageItem, Tile> {
|
|||||||
const box = new Container();
|
const box = new Container();
|
||||||
const background = new Graphics();
|
const background = new Graphics();
|
||||||
const sprite = new Sprite();
|
const sprite = new Sprite();
|
||||||
const caption = new Text({ style: textStyle('small') });
|
const caption = crispText({ style: textStyle('small') });
|
||||||
box.addChild(background, sprite, caption);
|
box.addChild(background, sprite, caption);
|
||||||
return { box, background, sprite, caption };
|
return { box, background, sprite, caption };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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';
|
import { imageToLocal, type SheetView, zoomAbout } from './sheetView';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +14,7 @@ export abstract class SheetCanvas<A> extends Widget {
|
|||||||
/** Everything the view moves; the checker board is its bottom layer. */
|
/** Everything the view moves; the checker board is its bottom layer. */
|
||||||
protected readonly content = new Container();
|
protected readonly content = new Container();
|
||||||
protected readonly checker = new Graphics();
|
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 readonly clip = new Graphics();
|
||||||
protected view: SheetView = { panX: 0, panY: 0, zoom: 1 };
|
protected view: SheetView = { panX: 0, panY: 0, zoom: 1 };
|
||||||
protected assetId = '';
|
protected assetId = '';
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
AddFrameCommand,
|
AddFrameCommand,
|
||||||
SetFrameCollisionCommand,
|
SetFrameCollisionCommand,
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
setFrameSlice,
|
setFrameSlice,
|
||||||
setFrameSortLine,
|
setFrameSortLine,
|
||||||
} from '../../core/model/texture';
|
} from '../../core/model/texture';
|
||||||
import { pressCount, Theme, textStyle, type Widget } from '../../ui';
|
import { pressCount, Theme, textStyle, type Widget, crispText } from '../../ui';
|
||||||
import {
|
import {
|
||||||
gripAt,
|
gripAt,
|
||||||
insideOutline,
|
insideOutline,
|
||||||
@@ -885,7 +885,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!label) {
|
if (!label) {
|
||||||
label = new Text({ style: textStyle('viewportLabel') });
|
label = crispText({ style: textStyle('viewportLabel') });
|
||||||
this.labels.set(frame.id, label);
|
this.labels.set(frame.id, label);
|
||||||
this.labelLayer.addChild(label);
|
this.labelLayer.addChild(label);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { TileAsset } from '../../core/model/Asset';
|
||||||
import {
|
import {
|
||||||
autotileCombinations,
|
autotileCombinations,
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
tilePlayback,
|
tilePlayback,
|
||||||
} from '../../core/model/tile';
|
} from '../../core/model/tile';
|
||||||
import { findAssetOf } from '../../core/model/Project';
|
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 { textureLibrary } from '../../viewport/TextureLibrary';
|
||||||
import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view';
|
import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view';
|
||||||
import type { Point } from '../framing';
|
import type { Point } from '../framing';
|
||||||
@@ -77,7 +77,7 @@ export class TileCanvas extends Widget {
|
|||||||
private readonly captionLayer = new Container();
|
private readonly captionLayer = new Container();
|
||||||
private readonly clipped = new Container();
|
private readonly clipped = new Container();
|
||||||
private readonly clip = new Graphics();
|
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 tiles: BoardTile[] = [];
|
||||||
private readonly captions: Caption[] = [];
|
private readonly captions: Caption[] = [];
|
||||||
/** Own copies, since the size follows the zoom here and nowhere else. */
|
/** 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. */
|
/** `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 {
|
private addCaption(label: string, x: number, y: number): void {
|
||||||
const [sides = label, notches] = label.split('-');
|
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
|
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;
|
: null;
|
||||||
this.captionLayer.addChild(sidesText);
|
this.captionLayer.addChild(sidesText);
|
||||||
if (notchesText) this.captionLayer.addChild(notchesText);
|
if (notchesText) this.captionLayer.addChild(notchesText);
|
||||||
|
|||||||
@@ -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 { loneTileQuadrants } from '../../core/model/autotile';
|
||||||
import { findAssetOf } from '../../core/model/Project';
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
import { quadrantRef, readTile } from '../../core/model/tile';
|
import { quadrantRef, readTile } from '../../core/model/tile';
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
textStyle,
|
textStyle,
|
||||||
truncate,
|
truncate,
|
||||||
Widget,
|
Widget,
|
||||||
|
crispText,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||||
import { gridMetrics, type GridMetrics, tileAt, tileRect } from '../texture/imageGrid';
|
import { gridMetrics, type GridMetrics, tileAt, tileRect } from '../texture/imageGrid';
|
||||||
@@ -187,8 +188,8 @@ export class TilePalette extends Widget {
|
|||||||
const box = new Container();
|
const box = new Container();
|
||||||
const background = new Graphics();
|
const background = new Graphics();
|
||||||
const sprites = [new Sprite(), new Sprite(), new Sprite(), new Sprite()] as Swatch['sprites'];
|
const sprites = [new Sprite(), new Sprite(), new Sprite(), new Sprite()] as Swatch['sprites'];
|
||||||
const empty = new Text({ text: '?', style: textStyle('muted') });
|
const empty = crispText({ text: '?', style: textStyle('muted') });
|
||||||
const caption = new Text({ style: textStyle('small') });
|
const caption = crispText({ style: textStyle('small') });
|
||||||
box.addChild(background, ...sprites, empty, caption);
|
box.addChild(background, ...sprites, empty, caption);
|
||||||
box.eventMode = 'none';
|
box.eventMode = 'none';
|
||||||
this.addChild(box);
|
this.addChild(box);
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
* The scene seen through its active camera at the chosen scale, letterboxed: the running world while
|
* The scene seen through its active camera at the chosen scale, letterboxed: the running world while
|
||||||
* playing, the model as a still preview otherwise.
|
* playing, the model as a still preview otherwise.
|
||||||
*/
|
*/
|
||||||
import { Text } from 'pixi.js';
|
|
||||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import type { World } from '../../runtime/World';
|
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 { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
||||||
import { FullscreenCorner } from '../../viewport/FullscreenCorner';
|
import { FullscreenCorner } from '../../viewport/FullscreenCorner';
|
||||||
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
||||||
@@ -16,8 +15,8 @@ import { type Frame, WorldViewport } from '../../viewport/WorldViewport';
|
|||||||
import { PerformanceOverlay } from './PerformanceOverlay';
|
import { PerformanceOverlay } from './PerformanceOverlay';
|
||||||
|
|
||||||
export class GameViewport extends WorldViewport<EditorState> {
|
export class GameViewport extends WorldViewport<EditorState> {
|
||||||
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
|
private readonly hint = crispText({ text: 'No active camera', style: textStyle('muted') });
|
||||||
private readonly editorRunCaption = new Text({
|
private readonly editorRunCaption = crispText({
|
||||||
text: 'Behaviors run here · keys and clicks go to the game',
|
text: 'Behaviors run here · keys and clicks go to the game',
|
||||||
style: textStyle('muted'),
|
style: textStyle('muted'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Container, Graphics, Text } from 'pixi.js';
|
import { Container, Graphics } from 'pixi.js';
|
||||||
import { Theme, textStyle } from '../../ui';
|
import { Theme, textStyle, crispText } from '../../ui';
|
||||||
import { performanceLines, type PerformanceReading } from '../frameMeter';
|
import { performanceLines, type PerformanceReading } from '../frameMeter';
|
||||||
|
|
||||||
/** Frame rate, entities and the time per frame by area over a running game (#275), top left. */
|
/** Frame rate, entities and the time per frame by area over a running game (#275), top left. */
|
||||||
export class PerformanceOverlay extends Container {
|
export class PerformanceOverlay extends Container {
|
||||||
private readonly bg = new Graphics();
|
private readonly bg = new Graphics();
|
||||||
private readonly text = new Text({ text: '', style: textStyle('mono') });
|
private readonly text = crispText({ text: '', style: textStyle('mono') });
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* copy, so live positions are visible and stay editable. A container's template is the same
|
* 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()`.
|
* 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 { oneStep } from '../../core/commands/CompoundCommand';
|
||||||
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||||
import { hasSceneIcon, isSymbolType } from '../../core/model/AssetTypes';
|
import { hasSceneIcon, isSymbolType } from '../../core/model/AssetTypes';
|
||||||
@@ -28,7 +28,17 @@ import { FramePreview } from '../framePreview';
|
|||||||
import { panelPolicy } from '../playThrottle';
|
import { panelPolicy } from '../playThrottle';
|
||||||
import { type EditorState, objectSelection } from '../state';
|
import { type EditorState, objectSelection } from '../state';
|
||||||
import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
|
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 { canPasteObjects, pasteObjects } from '../clipboard';
|
||||||
import { collectHolderItems, type DrawItem, type TilemapSolids } from '../../viewport/items';
|
import { collectHolderItems, type DrawItem, type TilemapSolids } from '../../viewport/items';
|
||||||
import { sortRowOf } from '../../viewport/drawOrder';
|
import { sortRowOf } from '../../viewport/drawOrder';
|
||||||
@@ -946,7 +956,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!label) {
|
if (!label) {
|
||||||
label = new Text({ text: '', style: textStyle('viewportLabel') });
|
label = crispText({ text: '', style: textStyle('viewportLabel') });
|
||||||
label.eventMode = 'none';
|
label.eventMode = 'none';
|
||||||
this.labels.set(item.id, label);
|
this.labels.set(item.id, label);
|
||||||
this.labelLayer.addChild(label);
|
this.labelLayer.addChild(label);
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,5 @@
|
|||||||
import { Application, Container } from 'pixi.js';
|
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
|
// `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.
|
// 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',
|
preference: 'webgl',
|
||||||
...(params.has('vsync') ? {} : lowLatencyContext()),
|
...(params.has('vsync') ? {} : lowLatencyContext()),
|
||||||
});
|
});
|
||||||
|
initPixelGrid(app.renderer.resolution);
|
||||||
document.body.appendChild(app.canvas);
|
document.body.appendChild(app.canvas);
|
||||||
await fonts;
|
await fonts;
|
||||||
|
|
||||||
const root = new VStack();
|
const root = new VStack();
|
||||||
const overlay = new Container();
|
const overlay = new Container();
|
||||||
app.stage.addChild(root, overlay);
|
app.stage.addChild(root, overlay);
|
||||||
|
app.stage.position.set(tieBreakFor(app.renderer.resolution));
|
||||||
app.stage.eventMode = 'static';
|
app.stage.eventMode = 'static';
|
||||||
app.stage.hitArea = app.screen;
|
app.stage.hitArea = app.screen;
|
||||||
initUI(app, root, overlay);
|
initUI(app, root, overlay);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Text, type TextStyle } from 'pixi.js';
|
import type { TextStyle } from 'pixi.js';
|
||||||
import type { World } from '../runtime/World';
|
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 { cameraOfWorld } from '../viewport/activeCamera';
|
||||||
import { FullscreenCorner } from '../viewport/FullscreenCorner';
|
import { FullscreenCorner } from '../viewport/FullscreenCorner';
|
||||||
import { routePointer } from '../viewport/gamePointer';
|
import { routePointer } from '../viewport/gamePointer';
|
||||||
@@ -22,8 +22,8 @@ function captionStyle(): TextStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GameScreen extends WorldViewport {
|
export class GameScreen extends WorldViewport {
|
||||||
private readonly caption = new Text({ text: '', style: captionStyle() });
|
private readonly caption = crispText({ text: '', style: captionStyle() });
|
||||||
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
|
private readonly hint = crispText({ text: 'No active camera', style: textStyle('muted') });
|
||||||
private running: World | null = null;
|
private running: World | null = null;
|
||||||
/** Asked when the caption is clicked while it offers to start. */
|
/** Asked when the caption is clicked while it offers to start. */
|
||||||
onStart: (() => void) | null = null;
|
onStart: (() => void) | null = null;
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
import { Container, Graphics, Text } from 'pixi.js';
|
import { Container, Graphics } from 'pixi.js';
|
||||||
import { Theme, textStyle } from './Theme';
|
import { Theme, textStyle } from './Theme';
|
||||||
import { ui } from './UIContext';
|
import { ui } from './UIContext';
|
||||||
|
import { crispText } from './pixelGrid';
|
||||||
|
|
||||||
/** What is being dragged. `kind` lets a drop target ignore payloads it does not take. */
|
/** What is being dragged. `kind` lets a drop target ignore payloads it does not take. */
|
||||||
export interface DragPayload {
|
export interface DragPayload {
|
||||||
@@ -74,7 +75,7 @@ function buildGhost(label: string): Container {
|
|||||||
const ghost = new Container();
|
const ghost = new Container();
|
||||||
ghost.eventMode = 'none';
|
ghost.eventMode = 'none';
|
||||||
ghost.alpha = 0.85;
|
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()
|
const box = new Graphics()
|
||||||
.roundRect(0, 0, text.width + 16, Theme.rowHeight, 4)
|
.roundRect(0, 0, text.width + 16, Theme.rowHeight, 4)
|
||||||
.fill(Theme.colors.panel)
|
.fill(Theme.colors.panel)
|
||||||
|
|||||||
+10
-6
@@ -1,4 +1,5 @@
|
|||||||
import type { Graphics } from 'pixi.js';
|
import type { Graphics } from 'pixi.js';
|
||||||
|
import { crisp, hairline } from './pixelGrid';
|
||||||
import { Theme } from './Theme';
|
import { Theme } from './Theme';
|
||||||
|
|
||||||
export interface ControlBoxOptions {
|
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
|
* The shared look of every control. The stroke is a hairline inset by half its width, so it lands
|
||||||
* device pixels and stays crisp instead of blurring over two.
|
* 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 {
|
export function drawControlBox(g: Graphics, w: number, h: number, options: ControlBoxOptions): void {
|
||||||
const { fill, border, radius = Theme.radius, x = 0, y = 0 } = options;
|
const { fill, border, radius = Theme.radius, x = 0, y = 0 } = options;
|
||||||
g.roundRect(x, y, w, h, radius).fill(fill);
|
const line = hairline();
|
||||||
g.roundRect(x + 0.5, y + 0.5, w - 1, h - 1, radius).stroke({ width: 1, color: border });
|
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. */
|
/** 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 {
|
export function drawHeaderBar(g: Graphics, w: number, h: number): void {
|
||||||
g.clear().rect(0, 0, w, h).fill(Theme.colors.panelHeader);
|
const line = hairline();
|
||||||
g.rect(0, h - 1, w, 1).fill(Theme.colors.border);
|
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 {
|
export interface GridOptions {
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ export {
|
|||||||
} from './sections';
|
} from './sections';
|
||||||
export { Theme, textStyle, type TextStyleToken } from './Theme';
|
export { Theme, textStyle, type TextStyleToken } from './Theme';
|
||||||
export { loadEditorFonts } from './fonts';
|
export { loadEditorFonts } from './fonts';
|
||||||
|
export {
|
||||||
|
crisp,
|
||||||
|
crispText,
|
||||||
|
hairline,
|
||||||
|
hairlineFor,
|
||||||
|
initPixelGrid,
|
||||||
|
offDeviceGrid,
|
||||||
|
tieBreakFor,
|
||||||
|
} from './pixelGrid';
|
||||||
export { initUI, ui, type UIContext } from './UIContext';
|
export { initUI, ui, type UIContext } from './UIContext';
|
||||||
export {
|
export {
|
||||||
afterFrames,
|
afterFrames,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { hairline } from '../pixelGrid';
|
||||||
import { hiddenBySections, sectionOffsets, type SectionRow, sectionStartOf } from '../sections';
|
import { hiddenBySections, sectionOffsets, type SectionRow, sectionStartOf } from '../sections';
|
||||||
import { type Size, Widget } from '../Widget';
|
import { type Size, Widget } from '../Widget';
|
||||||
import { type StackOptions, VStack } from './Stack';
|
import { type StackOptions, VStack } from './Stack';
|
||||||
@@ -51,7 +52,7 @@ export class SectionStack extends VStack {
|
|||||||
this.applyFolds();
|
this.applyFolds();
|
||||||
const sizes = this.items.map((item) => item.measure());
|
const sizes = this.items.map((item) => item.measure());
|
||||||
const w = Math.max(0, ...sizes.map((size) => size.w)) + this.padding * 2;
|
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) };
|
return { w: Math.max(w, this.minWidth), h: Math.max(height, this.minHeight) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,10 +60,10 @@ export class SectionStack extends VStack {
|
|||||||
this.applyFolds();
|
this.applyFolds();
|
||||||
const items = this.items;
|
const items = this.items;
|
||||||
const sizes = items.map((item) => item.measure());
|
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) => {
|
items.forEach((item, i) => {
|
||||||
const band = sectionStartOf(item)?.sectionStart === 'band';
|
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));
|
item.resize(Math.max(0, band ? this.w : this.w - this.padding * 2), Math.round(sizes[i]!.h));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T extends Graphics>(shape: T): T {
|
||||||
|
shape.roundPixels = offDeviceGrid(resolution);
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
+10
-6
@@ -3,6 +3,7 @@
|
|||||||
* one belongs to it. A folded group keeps its band and hides its rows.
|
* one belongs to it. A folded group keeps its band and hides its rows.
|
||||||
*/
|
*/
|
||||||
import type { Graphics } from 'pixi.js';
|
import type { Graphics } from 'pixi.js';
|
||||||
|
import { crisp, hairline } from './pixelGrid';
|
||||||
import { Theme } from './Theme';
|
import { Theme } from './Theme';
|
||||||
|
|
||||||
/** Height of a band, here and at the head of a list that carries its own. */
|
/** 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,
|
* 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.
|
* 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(
|
export function sectionOffsets(
|
||||||
rows: readonly SectionRow[],
|
rows: readonly SectionRow[],
|
||||||
gap: number,
|
gap: number,
|
||||||
padding: number,
|
padding: number,
|
||||||
|
line = 1,
|
||||||
): { ys: number[]; height: number } {
|
): { ys: number[]; height: number } {
|
||||||
const ys: number[] = [];
|
const ys: number[] = [];
|
||||||
let y = rows[0]?.start ? 0 : padding;
|
let y = rows[0]?.start ? 0 : padding;
|
||||||
rows.forEach((row, i) => {
|
rows.forEach((row, i) => {
|
||||||
const prev = rows[i - 1];
|
const prev = rows[i - 1];
|
||||||
// One pixel shared, so two bands show one line between them, not two.
|
// One line shared, so two bands show one between them, not two.
|
||||||
if (prev) y += row.start ? (prev.band ? -1 : padding) : prev.band ? gap + 2 : gap;
|
if (prev) y += row.start ? (prev.band ? -line : padding) : prev.band ? gap + 2 : gap;
|
||||||
ys.push(y);
|
ys.push(y);
|
||||||
y += row.h;
|
y += row.h;
|
||||||
});
|
});
|
||||||
const last = rows.at(-1);
|
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. */
|
/** 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 {
|
export function drawSectionBand(g: Graphics, x: number, y: number, w: number, h: number): void {
|
||||||
g.rect(x, y, w, h).fill(Theme.colors.panelHeader);
|
const line = hairline();
|
||||||
g.rect(x, y, w, 1).fill(Theme.colors.border);
|
crisp(g).rect(x, y, w, h).fill(Theme.colors.panelHeader);
|
||||||
g.rect(x, y + h - 1, w, 1).fill(Theme.colors.border);
|
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. */
|
/** The mark in front of a band's title, centred on `x`/`y`: pointing down while open, right while folded. */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
type ColorSource,
|
type ColorSource,
|
||||||
type FederatedPointerEvent,
|
type FederatedPointerEvent,
|
||||||
Graphics,
|
Graphics,
|
||||||
Text,
|
type Text,
|
||||||
CanvasTextMetrics,
|
CanvasTextMetrics,
|
||||||
} from 'pixi.js';
|
} from 'pixi.js';
|
||||||
import { drawControlBox } from '../draw';
|
import { drawControlBox } from '../draw';
|
||||||
@@ -12,6 +12,7 @@ import { drawSvg } from '../svg';
|
|||||||
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
||||||
import { type Size, Widget } from '../Widget';
|
import { type Size, Widget } from '../Widget';
|
||||||
import { truncate } from './Label';
|
import { truncate } from './Label';
|
||||||
|
import { crisp, crispText, hairline } from '../pixelGrid';
|
||||||
|
|
||||||
export interface ButtonOptions {
|
export interface ButtonOptions {
|
||||||
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
||||||
@@ -36,7 +37,7 @@ const ICON_SIZE = 16;
|
|||||||
const ICON_GAP = 6;
|
const ICON_GAP = 6;
|
||||||
|
|
||||||
export class Button extends Widget {
|
export class Button extends Widget {
|
||||||
protected readonly bg = new Graphics();
|
protected readonly bg = crisp(new Graphics());
|
||||||
protected readonly labelText: Text;
|
protected readonly labelText: Text;
|
||||||
/** The label as set; `labelText` holds the possibly truncated version on screen. */
|
/** The label as set; `labelText` holds the possibly truncated version on screen. */
|
||||||
private fullText: string;
|
private fullText: string;
|
||||||
@@ -70,7 +71,10 @@ export class Button extends Widget {
|
|||||||
this.fullText = text;
|
this.fullText = text;
|
||||||
this.style = options.style ?? 'body';
|
this.style = options.style ?? 'body';
|
||||||
this.toggle = options.toggle ?? false;
|
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.icon = options.icon ?? null;
|
||||||
this.addChild(this.bg, this.labelText);
|
this.addChild(this.bg, this.labelText);
|
||||||
if (this.icon) this.addChild(this.iconGfx);
|
if (this.icon) this.addChild(this.iconGfx);
|
||||||
@@ -252,7 +256,10 @@ export class Button extends Widget {
|
|||||||
} else if (fill !== null) {
|
} else if (fill !== null) {
|
||||||
this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(fill);
|
this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(fill);
|
||||||
} else if (stroke !== null) {
|
} 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Widget } from '../Widget';
|
|||||||
|
|
||||||
export class Checkbox extends Widget {
|
export class Checkbox extends Widget {
|
||||||
private readonly box = new Graphics();
|
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;
|
onChange?: (checked: boolean) => void;
|
||||||
|
|
||||||
constructor(public checked = false) {
|
constructor(public checked = false) {
|
||||||
@@ -14,7 +16,7 @@ export class Checkbox extends Widget {
|
|||||||
this.cursor = Theme.hoverCursor;
|
this.cursor = Theme.hoverCursor;
|
||||||
this.minWidth = Theme.controlHeight;
|
this.minWidth = Theme.controlHeight;
|
||||||
this.minHeight = Theme.controlHeight;
|
this.minHeight = Theme.controlHeight;
|
||||||
this.addChild(this.box);
|
this.addChild(this.box, this.tick);
|
||||||
this.trackHover();
|
this.trackHover();
|
||||||
this.on('pointertap', () => this.toggle());
|
this.on('pointertap', () => this.toggle());
|
||||||
}
|
}
|
||||||
@@ -51,8 +53,9 @@ export class Checkbox extends Widget {
|
|||||||
radius: 3,
|
radius: 3,
|
||||||
y,
|
y,
|
||||||
});
|
});
|
||||||
|
this.tick.clear();
|
||||||
if (this.checked) {
|
if (this.checked) {
|
||||||
this.box
|
this.tick
|
||||||
.moveTo(4, y + 8.5)
|
.moveTo(4, y + 8.5)
|
||||||
.lineTo(7, y + 11.5)
|
.lineTo(7, y + 11.5)
|
||||||
.lineTo(12.5, y + 5)
|
.lineTo(12.5, y + 5)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { CanvasTextMetrics, Graphics, Text } from 'pixi.js';
|
import { CanvasTextMetrics, Graphics, type Text } from 'pixi.js';
|
||||||
import { centreY } from '../math';
|
import { centreY } from '../math';
|
||||||
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
||||||
import { type Size, Widget } from '../Widget';
|
import { type Size, Widget } from '../Widget';
|
||||||
|
import { crispText } from '../pixelGrid';
|
||||||
|
|
||||||
export interface LabelOptions {
|
export interface LabelOptions {
|
||||||
style?: TextStyleToken;
|
style?: TextStyleToken;
|
||||||
@@ -28,7 +29,7 @@ export class Label extends Widget {
|
|||||||
this.padding = options.padding ?? 0;
|
this.padding = options.padding ?? 0;
|
||||||
this.fixedWidth = options.width ?? 0;
|
this.fixedWidth = options.width ?? 0;
|
||||||
this.minWidth = this.fixedWidth;
|
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.addChild(this.textNode);
|
||||||
this.minHeight = Theme.rowHeight;
|
this.minHeight = Theme.rowHeight;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { centreY } from '../math';
|
||||||
import { drawSvg } from '../svg';
|
import { drawSvg } from '../svg';
|
||||||
import { Theme, textStyle } from '../Theme';
|
import { Theme, textStyle } from '../Theme';
|
||||||
import { Widget } from '../Widget';
|
import { Widget } from '../Widget';
|
||||||
import { truncate } from './Label';
|
import { truncate } from './Label';
|
||||||
import type { ListItem } from './ListView';
|
import type { ListItem } from './ListView';
|
||||||
|
import { crisp, crispText } from '../pixelGrid';
|
||||||
|
|
||||||
/** Width reserved in front of every row of a tree list for the chevron. */
|
/** Width reserved in front of every row of a tree list for the chevron. */
|
||||||
const CHEVRON_SLOT = 14;
|
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. */
|
/** One row of a `ListView`. Purely visual: pointer handling and selection state live in the list. */
|
||||||
export class ListRow extends Widget {
|
export class ListRow extends Widget {
|
||||||
private readonly bg = new Graphics();
|
private readonly bg = crisp(new Graphics());
|
||||||
private readonly labelText = new Text({ text: '', style: textStyle('body') });
|
private readonly labelText = crispText({ text: '', style: textStyle('body') });
|
||||||
private readonly hint = new Text({ text: '', style: textStyle('small') });
|
private readonly hint = crispText({ text: '', style: textStyle('small') });
|
||||||
private readonly icon = new Graphics();
|
private readonly icon = new Graphics();
|
||||||
private readonly marks: Graphics[] = [];
|
private readonly marks: Graphics[] = [];
|
||||||
private selected = false;
|
private selected = false;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||||
import { pressCount } from '../clicks';
|
import { pressCount } from '../clicks';
|
||||||
import { centreY } from '../math';
|
import { centreY } from '../math';
|
||||||
import { cancelFrameWork, onFrame } from '../render';
|
import { cancelFrameWork, onFrame } from '../render';
|
||||||
@@ -6,6 +6,7 @@ import { Theme, textStyle } from '../Theme';
|
|||||||
import { type Size, Widget } from '../Widget';
|
import { type Size, Widget } from '../Widget';
|
||||||
import { ListRow } from './ListRow';
|
import { ListRow } from './ListRow';
|
||||||
import type { Popup } from './Popup';
|
import type { Popup } from './Popup';
|
||||||
|
import { crisp, crispText } from '../pixelGrid';
|
||||||
|
|
||||||
export interface ListItem {
|
export interface ListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -107,9 +108,9 @@ export class ListView extends Widget {
|
|||||||
onToggle?: (id: string, expand: boolean) => void;
|
onToggle?: (id: string, expand: boolean) => void;
|
||||||
rowHeight: number = Theme.rowHeight;
|
rowHeight: number = Theme.rowHeight;
|
||||||
emptyText = 'Nothing here yet';
|
emptyText = 'Nothing here yet';
|
||||||
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
|
private readonly emptyLabel = crispText({ text: '', style: textStyle('muted') });
|
||||||
private readonly dropMarker = new Graphics();
|
private readonly dropMarker = crisp(new Graphics());
|
||||||
private readonly pingMarker = new Graphics();
|
private readonly pingMarker = crisp(new Graphics());
|
||||||
private drag: DragState | null = null;
|
private drag: DragState | null = null;
|
||||||
/** The row that glows after `ping`, and when it started. */
|
/** The row that glows after `ping`, and when it started. */
|
||||||
private pinged: { id: string; start: number } | null = null;
|
private pinged: { id: string; start: number } | null = null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
import { Graphics } from 'pixi.js';
|
||||||
import { HStack, VStack } from '../layout/Stack';
|
import { HStack, VStack } from '../layout/Stack';
|
||||||
|
import { crisp, hairline } from '../pixelGrid';
|
||||||
import { Theme } from '../Theme';
|
import { Theme } from '../Theme';
|
||||||
import { type Size, Widget } from '../Widget';
|
import { type Size, Widget } from '../Widget';
|
||||||
import { Label } from './Label';
|
import { Label } from './Label';
|
||||||
@@ -16,9 +17,9 @@ export interface PanelOptions {
|
|||||||
export class Panel extends Widget {
|
export class Panel extends Widget {
|
||||||
/** Height of the title row – the floor for a panel that must stay grabbable when dragged shut. */
|
/** Height of the title row – the floor for a panel that must stay grabbable when dragged shut. */
|
||||||
static readonly HEADER_HEIGHT = Theme.rowHeight + 4;
|
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. */
|
/** 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 header: HStack;
|
||||||
readonly content: VStack;
|
readonly content: VStack;
|
||||||
private readonly titleLabel: Label | null = null;
|
private readonly titleLabel: Label | null = null;
|
||||||
@@ -67,6 +68,9 @@ export class Panel extends Widget {
|
|||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
this.bg.rect(0, 0, this.w, this.h).fill(c.panel);
|
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);
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
|
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
|
||||||
import { clamp } from '../math';
|
import { clamp } from '../math';
|
||||||
|
import { crisp } from '../pixelGrid';
|
||||||
import { Theme } from '../Theme';
|
import { Theme } from '../Theme';
|
||||||
import { Widget } from '../Widget';
|
import { Widget } from '../Widget';
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import { Widget } from '../Widget';
|
|||||||
* rather than on top of its right edge.
|
* rather than on top of its right edge.
|
||||||
*/
|
*/
|
||||||
export class ScrollContainer extends Widget {
|
export class ScrollContainer extends Widget {
|
||||||
private readonly maskShape = new Graphics();
|
private readonly maskShape = crisp(new Graphics());
|
||||||
private readonly scrollbar = new Graphics();
|
private readonly scrollbar = new Graphics();
|
||||||
private readonly viewportNode = new Container();
|
private readonly viewportNode = new Container();
|
||||||
private content: Widget | null = null;
|
private content: Widget | null = null;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
import { Graphics } from 'pixi.js';
|
||||||
|
import { crisp, hairline } from '../pixelGrid';
|
||||||
import { Theme } from '../Theme';
|
import { Theme } from '../Theme';
|
||||||
import { Widget } from '../Widget';
|
import { Widget } from '../Widget';
|
||||||
|
|
||||||
/** A hairline between two groups of entries. Takes no pointer events and answers no search. */
|
/** A hairline between two groups of entries. Takes no pointer events and answers no search. */
|
||||||
export class Separator extends Widget {
|
export class Separator extends Widget {
|
||||||
private readonly line = new Graphics();
|
private readonly line = crisp(new Graphics());
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -16,7 +17,7 @@ export class Separator extends Widget {
|
|||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
this.line
|
this.line
|
||||||
.clear()
|
.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 });
|
.fill({ color: Theme.colors.border });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 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.
|
* 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 { pressCount } from '../clicks';
|
||||||
import {
|
import {
|
||||||
drawFoldMark,
|
drawFoldMark,
|
||||||
@@ -29,6 +29,7 @@ import { type Size, Widget } from '../Widget';
|
|||||||
import { truncate } from './Label';
|
import { truncate } from './Label';
|
||||||
import { NumberField } from './NumberField';
|
import { NumberField } from './NumberField';
|
||||||
import { TextField } from './TextField';
|
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. */
|
/** Height of one row: the picture plus the air a hovered row needs to look like a row. */
|
||||||
export const SORTABLE_ROW_HEIGHT = 30;
|
export const SORTABLE_ROW_HEIGHT = 30;
|
||||||
@@ -174,9 +175,9 @@ class RowNameField extends TextField {
|
|||||||
|
|
||||||
export class SortableList extends Widget implements SectionStart {
|
export class SortableList extends Widget implements SectionStart {
|
||||||
private collapsed: boolean;
|
private collapsed: boolean;
|
||||||
private readonly background = new Graphics();
|
private readonly background = crisp(new Graphics());
|
||||||
private readonly marker = new Graphics();
|
private readonly marker = crisp(new Graphics());
|
||||||
private readonly heading = new Text({ text: '', style: textStyle('title') });
|
private readonly heading = crispText({ text: '', style: textStyle('title') });
|
||||||
private readonly plus = new Graphics();
|
private readonly plus = new Graphics();
|
||||||
private readonly rows: Row[] = [];
|
private readonly rows: Row[] = [];
|
||||||
/** Display order, top first. */
|
/** Display order, top first. */
|
||||||
@@ -376,8 +377,8 @@ export class SortableList extends Widget implements SectionStart {
|
|||||||
const box = new Container();
|
const box = new Container();
|
||||||
const art = new Graphics();
|
const art = new Graphics();
|
||||||
const clip = new Graphics();
|
const clip = new Graphics();
|
||||||
const label = new Text({ text: '', style: textStyle('body') });
|
const label = crispText({ text: '', style: textStyle('body') });
|
||||||
const hint = new Text({ text: '', style: textStyle('small') });
|
const hint = crispText({ text: '', style: textStyle('small') });
|
||||||
const eye = new Graphics();
|
const eye = new Graphics();
|
||||||
const duplicate = new Graphics();
|
const duplicate = new Graphics();
|
||||||
const cross = new Graphics();
|
const cross = new Graphics();
|
||||||
@@ -699,7 +700,7 @@ const suffixWidths = new Map<string, number>();
|
|||||||
function suffixWidth(suffix: string): number {
|
function suffixWidth(suffix: string): number {
|
||||||
let width = suffixWidths.get(suffix);
|
let width = suffixWidths.get(suffix);
|
||||||
if (width === undefined) {
|
if (width === undefined) {
|
||||||
const text = new Text({ text: suffix, style: textStyle('small') });
|
const text = crispText({ text: suffix, style: textStyle('small') });
|
||||||
width = text.width;
|
width = text.width;
|
||||||
text.destroy();
|
text.destroy();
|
||||||
suffixWidths.set(suffix, width);
|
suffixWidths.set(suffix, width);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||||
|
import { crisp } from '../pixelGrid';
|
||||||
import { Theme } from '../Theme';
|
import { Theme } from '../Theme';
|
||||||
import { Widget } from '../Widget';
|
import { Widget } from '../Widget';
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@ import { Widget } from '../Widget';
|
|||||||
export class Splitter extends Widget {
|
export class Splitter extends Widget {
|
||||||
/** Size across the drag axis; the visible bar fills it, so this is also the grab area. */
|
/** Size across the drag axis; the visible bar fills it, so this is also the grab area. */
|
||||||
static readonly THICKNESS = 5;
|
static readonly THICKNESS = 5;
|
||||||
private readonly bar = new Graphics();
|
private readonly bar = crisp(new Graphics());
|
||||||
private dragging = false;
|
private dragging = false;
|
||||||
private last = 0;
|
private last = 0;
|
||||||
onDrag?: (delta: number) => void;
|
onDrag?: (delta: number) => void;
|
||||||
|
|||||||
@@ -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 { pressCount } from '../clicks';
|
||||||
import { drawControlBox } from '../draw';
|
import { drawControlBox } from '../draw';
|
||||||
import type { TextEditTarget } from '../KeyboardBridge';
|
import type { TextEditTarget } from '../KeyboardBridge';
|
||||||
@@ -6,6 +6,7 @@ import { centreY, clamp } from '../math';
|
|||||||
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
||||||
import { ui } from '../UIContext';
|
import { ui } from '../UIContext';
|
||||||
import { type Size, Widget } from '../Widget';
|
import { type Size, Widget } from '../Widget';
|
||||||
|
import { crisp, crispText, hairline } from '../pixelGrid';
|
||||||
|
|
||||||
export interface TextFieldOptions {
|
export interface TextFieldOptions {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -34,15 +35,15 @@ const BULLET = '•';
|
|||||||
*/
|
*/
|
||||||
export class TextField extends Widget implements TextEditTarget {
|
export class TextField extends Widget implements TextEditTarget {
|
||||||
protected readonly bg = new Graphics();
|
protected readonly bg = new Graphics();
|
||||||
protected readonly selectionShape = new Graphics();
|
protected readonly selectionShape = crisp(new Graphics());
|
||||||
protected readonly caret = new Graphics();
|
protected readonly caret = crisp(new Graphics());
|
||||||
protected readonly textNode: Text;
|
protected readonly textNode: Text;
|
||||||
protected readonly placeholderNode: Text;
|
protected readonly placeholderNode: Text;
|
||||||
protected valueText = '';
|
protected valueText = '';
|
||||||
protected editing = false;
|
protected editing = false;
|
||||||
protected selStart = 0;
|
protected selStart = 0;
|
||||||
protected selEnd = 0;
|
protected selEnd = 0;
|
||||||
private readonly clipShape = new Graphics();
|
private readonly clipShape = crisp(new Graphics());
|
||||||
private originalValue = '';
|
private originalValue = '';
|
||||||
private scrollX = 0;
|
private scrollX = 0;
|
||||||
private caretVisible = true;
|
private caretVisible = true;
|
||||||
@@ -68,8 +69,8 @@ export class TextField extends Widget implements TextEditTarget {
|
|||||||
this.cursor = this.readOnly ? 'default' : 'text';
|
this.cursor = this.readOnly ? 'default' : 'text';
|
||||||
this.minHeight = Theme.controlHeight;
|
this.minHeight = Theme.controlHeight;
|
||||||
this.minWidth = options.width ?? 80;
|
this.minWidth = options.width ?? 80;
|
||||||
this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') });
|
this.textNode = crispText({ text: value, style: textStyle(options.style ?? 'body') });
|
||||||
this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') });
|
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
|
// 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.
|
// straight edges and left it showing at the rounded corners alone.
|
||||||
const content = new Container();
|
const content = new Container();
|
||||||
@@ -293,7 +294,7 @@ export class TextField extends Widget implements TextEditTarget {
|
|||||||
.fill({ color: c.accent, alpha: 0.45 });
|
.fill({ color: c.accent, alpha: 0.45 });
|
||||||
}
|
}
|
||||||
if (this.caretVisible) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ describe('where bands and rows go', () => {
|
|||||||
expect(height).toBe(ys[2]! + SECTION_BAND_HEIGHT);
|
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', () => {
|
it('starts inside the padding without a band on top', () => {
|
||||||
expect(sectionOffsets([plain, plain], GAP, PADDING).ys).toEqual([PADDING, PADDING + 24 + GAP]);
|
expect(sectionOffsets([plain, plain], GAP, PADDING).ys).toEqual([PADDING, PADDING + 24 + GAP]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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() }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ import { clipAreas } from './58-clip-areas.mjs';
|
|||||||
import { whilePlaying } from './59-while-playing.mjs';
|
import { whilePlaying } from './59-while-playing.mjs';
|
||||||
import { release } from './60-release.mjs';
|
import { release } from './60-release.mjs';
|
||||||
import { fullscreen } from './61-fullscreen.mjs';
|
import { fullscreen } from './61-fullscreen.mjs';
|
||||||
|
import { devicePixels } from './62-device-pixels.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -127,4 +128,5 @@ export const STEPS = [
|
|||||||
whilePlaying,
|
whilePlaying,
|
||||||
release,
|
release,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
|
devicePixels,
|
||||||
];
|
];
|
||||||
@@ -541,6 +541,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
|||||||
|
|
||||||
export async function runChecks(options, body) {
|
export async function runChecks(options, body) {
|
||||||
const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options;
|
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 });
|
const { proc: chrome, stop: stopBrowser, where } = await launchBrowser({ port, width, height });
|
||||||
console.log(`browser: ${where}`);
|
console.log(`browser: ${where}`);
|
||||||
|
|
||||||
@@ -603,7 +604,7 @@ export async function runChecks(options, body) {
|
|||||||
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
deviceScaleFactor: 1,
|
deviceScaleFactor,
|
||||||
mobile: false,
|
mobile: false,
|
||||||
});
|
});
|
||||||
await requireGpu(cdp);
|
await requireGpu(cdp);
|
||||||
|
|||||||
Reference in new issue
Block a user