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
+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 { ui } from './UIContext';
|
||||
import { crispText } from './pixelGrid';
|
||||
|
||||
/** What is being dragged. `kind` lets a drop target ignore payloads it does not take. */
|
||||
export interface DragPayload {
|
||||
@@ -74,7 +75,7 @@ function buildGhost(label: string): Container {
|
||||
const ghost = new Container();
|
||||
ghost.eventMode = 'none';
|
||||
ghost.alpha = 0.85;
|
||||
const text = new Text({ text: label, style: textStyle('body') });
|
||||
const text = crispText({ text: label, style: textStyle('body') });
|
||||
const box = new Graphics()
|
||||
.roundRect(0, 0, text.width + 16, Theme.rowHeight, 4)
|
||||
.fill(Theme.colors.panel)
|
||||
|
||||
+10
-6
@@ -1,4 +1,5 @@
|
||||
import type { Graphics } from 'pixi.js';
|
||||
import { crisp, hairline } from './pixelGrid';
|
||||
import { Theme } from './Theme';
|
||||
|
||||
export interface ControlBoxOptions {
|
||||
@@ -10,19 +11,22 @@ export interface ControlBoxOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared look of every control. The stroke is inset by half a pixel so it lands on whole
|
||||
* device pixels and stays crisp instead of blurring over two.
|
||||
* The shared look of every control. The stroke is a hairline inset by half its width, so it lands
|
||||
* on whole device pixels and stays crisp instead of blurring over two. Only for a `g` that holds
|
||||
* nothing but boxes – see `crisp`.
|
||||
*/
|
||||
export function drawControlBox(g: Graphics, w: number, h: number, options: ControlBoxOptions): void {
|
||||
const { fill, border, radius = Theme.radius, x = 0, y = 0 } = options;
|
||||
g.roundRect(x, y, w, h, radius).fill(fill);
|
||||
g.roundRect(x + 0.5, y + 0.5, w - 1, h - 1, radius).stroke({ width: 1, color: border });
|
||||
const line = hairline();
|
||||
crisp(g).roundRect(x, y, w, h, radius).fill(fill);
|
||||
g.roundRect(x + line / 2, y + line / 2, w - line, h - line, radius).stroke({ width: line, color: border });
|
||||
}
|
||||
|
||||
/** A bar across the top of something – the toolbar, a tab's header, the breadcrumb: header colour, a line below. */
|
||||
export function drawHeaderBar(g: Graphics, w: number, h: number): void {
|
||||
g.clear().rect(0, 0, w, h).fill(Theme.colors.panelHeader);
|
||||
g.rect(0, h - 1, w, 1).fill(Theme.colors.border);
|
||||
const line = hairline();
|
||||
crisp(g).clear().rect(0, 0, w, h).fill(Theme.colors.panelHeader);
|
||||
g.rect(0, h - line, w, line).fill(Theme.colors.border);
|
||||
}
|
||||
|
||||
export interface GridOptions {
|
||||
|
||||
@@ -11,6 +11,15 @@ export {
|
||||
} from './sections';
|
||||
export { Theme, textStyle, type TextStyleToken } from './Theme';
|
||||
export { loadEditorFonts } from './fonts';
|
||||
export {
|
||||
crisp,
|
||||
crispText,
|
||||
hairline,
|
||||
hairlineFor,
|
||||
initPixelGrid,
|
||||
offDeviceGrid,
|
||||
tieBreakFor,
|
||||
} from './pixelGrid';
|
||||
export { initUI, ui, type UIContext } from './UIContext';
|
||||
export {
|
||||
afterFrames,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hairline } from '../pixelGrid';
|
||||
import { hiddenBySections, sectionOffsets, type SectionRow, sectionStartOf } from '../sections';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { type StackOptions, VStack } from './Stack';
|
||||
@@ -51,7 +52,7 @@ export class SectionStack extends VStack {
|
||||
this.applyFolds();
|
||||
const sizes = this.items.map((item) => item.measure());
|
||||
const w = Math.max(0, ...sizes.map((size) => size.w)) + this.padding * 2;
|
||||
const { height } = sectionOffsets(this.rows(sizes), this.gap, this.padding);
|
||||
const { height } = sectionOffsets(this.rows(sizes), this.gap, this.padding, hairline());
|
||||
return { w: Math.max(w, this.minWidth), h: Math.max(height, this.minHeight) };
|
||||
}
|
||||
|
||||
@@ -59,10 +60,10 @@ export class SectionStack extends VStack {
|
||||
this.applyFolds();
|
||||
const items = this.items;
|
||||
const sizes = items.map((item) => item.measure());
|
||||
const { ys } = sectionOffsets(this.rows(sizes), this.gap, this.padding);
|
||||
const { ys } = sectionOffsets(this.rows(sizes), this.gap, this.padding, hairline());
|
||||
items.forEach((item, i) => {
|
||||
const band = sectionStartOf(item)?.sectionStart === 'band';
|
||||
item.position.set(band ? 0 : this.padding, Math.round(ys[i]!));
|
||||
item.position.set(band ? 0 : this.padding, ys[i] ?? 0);
|
||||
item.resize(Math.max(0, band ? this.w : this.w - this.padding * 2), Math.round(sizes[i]!.h));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
import type { Graphics } from 'pixi.js';
|
||||
import { crisp, hairline } from './pixelGrid';
|
||||
import { Theme } from './Theme';
|
||||
|
||||
/** Height of a band, here and at the head of a list that carries its own. */
|
||||
@@ -48,30 +49,33 @@ export interface SectionRow {
|
||||
/**
|
||||
* Where the shown children of a column go. Bands stand flush on each other and on the top edge,
|
||||
* a group keeps the column's padding to the band after it, and rows keep the gap between them.
|
||||
* `line` is how wide the bands draw their lines.
|
||||
*/
|
||||
export function sectionOffsets(
|
||||
rows: readonly SectionRow[],
|
||||
gap: number,
|
||||
padding: number,
|
||||
line = 1,
|
||||
): { ys: number[]; height: number } {
|
||||
const ys: number[] = [];
|
||||
let y = rows[0]?.start ? 0 : padding;
|
||||
rows.forEach((row, i) => {
|
||||
const prev = rows[i - 1];
|
||||
// One pixel shared, so two bands show one line between them, not two.
|
||||
if (prev) y += row.start ? (prev.band ? -1 : padding) : prev.band ? gap + 2 : gap;
|
||||
// One line shared, so two bands show one between them, not two.
|
||||
if (prev) y += row.start ? (prev.band ? -line : padding) : prev.band ? gap + 2 : gap;
|
||||
ys.push(y);
|
||||
y += row.h;
|
||||
});
|
||||
const last = rows.at(-1);
|
||||
return { ys, height: y + (last && !last.band ? padding : 0) };
|
||||
return { ys, height: Math.ceil(y + (last && !last.band ? padding : 0)) };
|
||||
}
|
||||
|
||||
/** The band itself: header colour between two lines, from `x` on. `g` is cleared by the caller. */
|
||||
export function drawSectionBand(g: Graphics, x: number, y: number, w: number, h: number): void {
|
||||
g.rect(x, y, w, h).fill(Theme.colors.panelHeader);
|
||||
g.rect(x, y, w, 1).fill(Theme.colors.border);
|
||||
g.rect(x, y + h - 1, w, 1).fill(Theme.colors.border);
|
||||
const line = hairline();
|
||||
crisp(g).rect(x, y, w, h).fill(Theme.colors.panelHeader);
|
||||
g.rect(x, y, w, line).fill(Theme.colors.border);
|
||||
g.rect(x, y + h - line, w, line).fill(Theme.colors.border);
|
||||
}
|
||||
|
||||
/** The mark in front of a band's title, centred on `x`/`y`: pointing down while open, right while folded. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type ColorSource,
|
||||
type FederatedPointerEvent,
|
||||
Graphics,
|
||||
Text,
|
||||
type Text,
|
||||
CanvasTextMetrics,
|
||||
} from 'pixi.js';
|
||||
import { drawControlBox } from '../draw';
|
||||
@@ -12,6 +12,7 @@ import { drawSvg } from '../svg';
|
||||
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { truncate } from './Label';
|
||||
import { crisp, crispText, hairline } from '../pixelGrid';
|
||||
|
||||
export interface ButtonOptions {
|
||||
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
||||
@@ -36,7 +37,7 @@ const ICON_SIZE = 16;
|
||||
const ICON_GAP = 6;
|
||||
|
||||
export class Button extends Widget {
|
||||
protected readonly bg = new Graphics();
|
||||
protected readonly bg = crisp(new Graphics());
|
||||
protected readonly labelText: Text;
|
||||
/** The label as set; `labelText` holds the possibly truncated version on screen. */
|
||||
private fullText: string;
|
||||
@@ -70,7 +71,10 @@ export class Button extends Widget {
|
||||
this.fullText = text;
|
||||
this.style = options.style ?? 'body';
|
||||
this.toggle = options.toggle ?? false;
|
||||
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : this.style) });
|
||||
this.labelText = crispText({
|
||||
text,
|
||||
style: textStyle(this.variant === 'primary' ? 'accent' : this.style),
|
||||
});
|
||||
this.icon = options.icon ?? null;
|
||||
this.addChild(this.bg, this.labelText);
|
||||
if (this.icon) this.addChild(this.iconGfx);
|
||||
@@ -252,7 +256,10 @@ export class Button extends Widget {
|
||||
} else if (fill !== null) {
|
||||
this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(fill);
|
||||
} else if (stroke !== null) {
|
||||
this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke });
|
||||
const line = hairline();
|
||||
this.bg
|
||||
.roundRect(line / 2, line / 2, this.w - line, this.h - line, Theme.radius)
|
||||
.stroke({ width: line, color: stroke });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Widget } from '../Widget';
|
||||
|
||||
export class Checkbox extends Widget {
|
||||
private readonly box = new Graphics();
|
||||
/** Apart from the box: its corners go on whole pixels, a slanted stroke must not. */
|
||||
private readonly tick = new Graphics();
|
||||
onChange?: (checked: boolean) => void;
|
||||
|
||||
constructor(public checked = false) {
|
||||
@@ -14,7 +16,7 @@ export class Checkbox extends Widget {
|
||||
this.cursor = Theme.hoverCursor;
|
||||
this.minWidth = Theme.controlHeight;
|
||||
this.minHeight = Theme.controlHeight;
|
||||
this.addChild(this.box);
|
||||
this.addChild(this.box, this.tick);
|
||||
this.trackHover();
|
||||
this.on('pointertap', () => this.toggle());
|
||||
}
|
||||
@@ -51,8 +53,9 @@ export class Checkbox extends Widget {
|
||||
radius: 3,
|
||||
y,
|
||||
});
|
||||
this.tick.clear();
|
||||
if (this.checked) {
|
||||
this.box
|
||||
this.tick
|
||||
.moveTo(4, y + 8.5)
|
||||
.lineTo(7, y + 11.5)
|
||||
.lineTo(12.5, y + 5)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CanvasTextMetrics, Graphics, Text } from 'pixi.js';
|
||||
import { CanvasTextMetrics, Graphics, type Text } from 'pixi.js';
|
||||
import { centreY } from '../math';
|
||||
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { crispText } from '../pixelGrid';
|
||||
|
||||
export interface LabelOptions {
|
||||
style?: TextStyleToken;
|
||||
@@ -28,7 +29,7 @@ export class Label extends Widget {
|
||||
this.padding = options.padding ?? 0;
|
||||
this.fixedWidth = options.width ?? 0;
|
||||
this.minWidth = this.fixedWidth;
|
||||
this.textNode = new Text({ text, style: textStyle(options.style ?? 'body') });
|
||||
this.textNode = crispText({ text, style: textStyle(options.style ?? 'body') });
|
||||
this.addChild(this.textNode);
|
||||
this.minHeight = Theme.rowHeight;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Color, type ColorSource, Graphics, Text } from 'pixi.js';
|
||||
import { Color, type ColorSource, Graphics } from 'pixi.js';
|
||||
import { centreY } from '../math';
|
||||
import { drawSvg } from '../svg';
|
||||
import { Theme, textStyle } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
import { truncate } from './Label';
|
||||
import type { ListItem } from './ListView';
|
||||
import { crisp, crispText } from '../pixelGrid';
|
||||
|
||||
/** Width reserved in front of every row of a tree list for the chevron. */
|
||||
const CHEVRON_SLOT = 14;
|
||||
@@ -13,9 +14,9 @@ const ICON_SIZE = 16;
|
||||
|
||||
/** One row of a `ListView`. Purely visual: pointer handling and selection state live in the list. */
|
||||
export class ListRow extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly labelText = new Text({ text: '', style: textStyle('body') });
|
||||
private readonly hint = new Text({ text: '', style: textStyle('small') });
|
||||
private readonly bg = crisp(new Graphics());
|
||||
private readonly labelText = crispText({ text: '', style: textStyle('body') });
|
||||
private readonly hint = crispText({ text: '', style: textStyle('small') });
|
||||
private readonly icon = new Graphics();
|
||||
private readonly marks: Graphics[] = [];
|
||||
private selected = false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||
import { pressCount } from '../clicks';
|
||||
import { centreY } from '../math';
|
||||
import { cancelFrameWork, onFrame } from '../render';
|
||||
@@ -6,6 +6,7 @@ import { Theme, textStyle } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { ListRow } from './ListRow';
|
||||
import type { Popup } from './Popup';
|
||||
import { crisp, crispText } from '../pixelGrid';
|
||||
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
@@ -107,9 +108,9 @@ export class ListView extends Widget {
|
||||
onToggle?: (id: string, expand: boolean) => void;
|
||||
rowHeight: number = Theme.rowHeight;
|
||||
emptyText = 'Nothing here yet';
|
||||
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly dropMarker = new Graphics();
|
||||
private readonly pingMarker = new Graphics();
|
||||
private readonly emptyLabel = crispText({ text: '', style: textStyle('muted') });
|
||||
private readonly dropMarker = crisp(new Graphics());
|
||||
private readonly pingMarker = crisp(new Graphics());
|
||||
private drag: DragState | null = null;
|
||||
/** The row that glows after `ping`, and when it started. */
|
||||
private pinged: { id: string; start: number } | null = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { HStack, VStack } from '../layout/Stack';
|
||||
import { crisp, hairline } from '../pixelGrid';
|
||||
import { Theme } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { Label } from './Label';
|
||||
@@ -16,9 +17,9 @@ export interface PanelOptions {
|
||||
export class Panel extends Widget {
|
||||
/** Height of the title row – the floor for a panel that must stay grabbable when dragged shut. */
|
||||
static readonly HEADER_HEIGHT = Theme.rowHeight + 4;
|
||||
private readonly bg = new Graphics();
|
||||
private readonly bg = crisp(new Graphics());
|
||||
/** Drawn above the content: a row flush with the edge (padding 0) would otherwise paint over it. */
|
||||
private readonly frame = new Graphics();
|
||||
private readonly frame = crisp(new Graphics());
|
||||
readonly header: HStack;
|
||||
readonly content: VStack;
|
||||
private readonly titleLabel: Label | null = null;
|
||||
@@ -67,6 +68,9 @@ export class Panel extends Widget {
|
||||
const c = Theme.colors;
|
||||
this.bg.rect(0, 0, this.w, this.h).fill(c.panel);
|
||||
if (this.titleLabel) this.bg.rect(0, 0, this.w, this.header.minHeight).fill(c.panelHeader);
|
||||
this.frame.rect(0.5, 0.5, this.w - 1, this.h - 1).stroke({ width: 1, color: c.border });
|
||||
const line = hairline();
|
||||
this.frame
|
||||
.rect(line / 2, line / 2, this.w - line, this.h - line)
|
||||
.stroke({ width: line, color: c.border });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
|
||||
import { clamp } from '../math';
|
||||
import { crisp } from '../pixelGrid';
|
||||
import { Theme } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
@@ -9,7 +10,7 @@ import { Widget } from '../Widget';
|
||||
* rather than on top of its right edge.
|
||||
*/
|
||||
export class ScrollContainer extends Widget {
|
||||
private readonly maskShape = new Graphics();
|
||||
private readonly maskShape = crisp(new Graphics());
|
||||
private readonly scrollbar = new Graphics();
|
||||
private readonly viewportNode = new Container();
|
||||
private content: Widget | null = null;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { crisp, hairline } from '../pixelGrid';
|
||||
import { Theme } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
/** A hairline between two groups of entries. Takes no pointer events and answers no search. */
|
||||
export class Separator extends Widget {
|
||||
private readonly line = new Graphics();
|
||||
private readonly line = crisp(new Graphics());
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -16,7 +17,7 @@ export class Separator extends Widget {
|
||||
override redraw(): void {
|
||||
this.line
|
||||
.clear()
|
||||
.rect(Theme.padding, Math.floor(this.h / 2), Math.max(0, this.w - Theme.padding * 2), 1)
|
||||
.rect(Theme.padding, Math.floor(this.h / 2), Math.max(0, this.w - Theme.padding * 2), hairline())
|
||||
.fill({ color: Theme.colors.border });
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* The list never changes its items itself. A drop, a cross, a name or a number reports to the owner,
|
||||
* the owner runs its command, and the next `setItems` shows the result.
|
||||
*/
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js';
|
||||
import { pressCount } from '../clicks';
|
||||
import {
|
||||
drawFoldMark,
|
||||
@@ -29,6 +29,7 @@ import { type Size, Widget } from '../Widget';
|
||||
import { truncate } from './Label';
|
||||
import { NumberField } from './NumberField';
|
||||
import { TextField } from './TextField';
|
||||
import { crisp, crispText } from '../pixelGrid';
|
||||
|
||||
/** Height of one row: the picture plus the air a hovered row needs to look like a row. */
|
||||
export const SORTABLE_ROW_HEIGHT = 30;
|
||||
@@ -174,9 +175,9 @@ class RowNameField extends TextField {
|
||||
|
||||
export class SortableList extends Widget implements SectionStart {
|
||||
private collapsed: boolean;
|
||||
private readonly background = new Graphics();
|
||||
private readonly marker = new Graphics();
|
||||
private readonly heading = new Text({ text: '', style: textStyle('title') });
|
||||
private readonly background = crisp(new Graphics());
|
||||
private readonly marker = crisp(new Graphics());
|
||||
private readonly heading = crispText({ text: '', style: textStyle('title') });
|
||||
private readonly plus = new Graphics();
|
||||
private readonly rows: Row[] = [];
|
||||
/** Display order, top first. */
|
||||
@@ -376,8 +377,8 @@ export class SortableList extends Widget implements SectionStart {
|
||||
const box = new Container();
|
||||
const art = new Graphics();
|
||||
const clip = new Graphics();
|
||||
const label = new Text({ text: '', style: textStyle('body') });
|
||||
const hint = new Text({ text: '', style: textStyle('small') });
|
||||
const label = crispText({ text: '', style: textStyle('body') });
|
||||
const hint = crispText({ text: '', style: textStyle('small') });
|
||||
const eye = new Graphics();
|
||||
const duplicate = new Graphics();
|
||||
const cross = new Graphics();
|
||||
@@ -699,7 +700,7 @@ const suffixWidths = new Map<string, number>();
|
||||
function suffixWidth(suffix: string): number {
|
||||
let width = suffixWidths.get(suffix);
|
||||
if (width === undefined) {
|
||||
const text = new Text({ text: suffix, style: textStyle('small') });
|
||||
const text = crispText({ text: suffix, style: textStyle('small') });
|
||||
width = text.width;
|
||||
text.destroy();
|
||||
suffixWidths.set(suffix, width);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||
import { crisp } from '../pixelGrid';
|
||||
import { Theme } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
@@ -6,7 +7,7 @@ import { Widget } from '../Widget';
|
||||
export class Splitter extends Widget {
|
||||
/** Size across the drag axis; the visible bar fills it, so this is also the grab area. */
|
||||
static readonly THICKNESS = 5;
|
||||
private readonly bar = new Graphics();
|
||||
private readonly bar = crisp(new Graphics());
|
||||
private dragging = false;
|
||||
private last = 0;
|
||||
onDrag?: (delta: number) => void;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CanvasTextMetrics, Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import { CanvasTextMetrics, Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js';
|
||||
import { pressCount } from '../clicks';
|
||||
import { drawControlBox } from '../draw';
|
||||
import type { TextEditTarget } from '../KeyboardBridge';
|
||||
@@ -6,6 +6,7 @@ import { centreY, clamp } from '../math';
|
||||
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
||||
import { ui } from '../UIContext';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { crisp, crispText, hairline } from '../pixelGrid';
|
||||
|
||||
export interface TextFieldOptions {
|
||||
placeholder?: string;
|
||||
@@ -34,15 +35,15 @@ const BULLET = '•';
|
||||
*/
|
||||
export class TextField extends Widget implements TextEditTarget {
|
||||
protected readonly bg = new Graphics();
|
||||
protected readonly selectionShape = new Graphics();
|
||||
protected readonly caret = new Graphics();
|
||||
protected readonly selectionShape = crisp(new Graphics());
|
||||
protected readonly caret = crisp(new Graphics());
|
||||
protected readonly textNode: Text;
|
||||
protected readonly placeholderNode: Text;
|
||||
protected valueText = '';
|
||||
protected editing = false;
|
||||
protected selStart = 0;
|
||||
protected selEnd = 0;
|
||||
private readonly clipShape = new Graphics();
|
||||
private readonly clipShape = crisp(new Graphics());
|
||||
private originalValue = '';
|
||||
private scrollX = 0;
|
||||
private caretVisible = true;
|
||||
@@ -68,8 +69,8 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
this.cursor = this.readOnly ? 'default' : 'text';
|
||||
this.minHeight = Theme.controlHeight;
|
||||
this.minWidth = options.width ?? 80;
|
||||
this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') });
|
||||
this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') });
|
||||
this.textNode = crispText({ text: value, style: textStyle(options.style ?? 'body') });
|
||||
this.placeholderNode = crispText({ text: this.placeholder, style: textStyle('muted') });
|
||||
// Only the contents are clipped: a mask on the whole field cut its border away along the
|
||||
// straight edges and left it showing at the rounded corners alone.
|
||||
const content = new Container();
|
||||
@@ -293,7 +294,7 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
.fill({ color: c.accent, alpha: 0.45 });
|
||||
}
|
||||
if (this.caretVisible) {
|
||||
this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text);
|
||||
this.caret.rect(Math.round(textX + caretX), textY, hairline(), this.textNode.height).fill(c.text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user