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>
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
/**
|
|
* Groups of rows with a band as their head (#290): which rows a folded group hides, where the bands
|
|
* and rows of a column go, what a click on a band does, how a fold is remembered, and which groups
|
|
* the inspector builds.
|
|
*/
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { CanvasTextMetrics } from 'pixi.js';
|
|
import { findAsset } from '../../src/core/model/Project';
|
|
import { buildAssetSection } from '../../src/editor/inspector/assetSection';
|
|
import { addTitle, type InspectorHost, sectionHeader } from '../../src/editor/inspector/host';
|
|
import { buildObjectSection } from '../../src/editor/inspector/objectSection';
|
|
import { foldKey, isFolded, listFold, resetFolds, setFolded } from '../../src/editor/sectionFolds';
|
|
import { EditorState } from '../../src/editor/state';
|
|
import {
|
|
Box,
|
|
hiddenBySections,
|
|
Label,
|
|
SECTION_BAND_HEIGHT,
|
|
SectionHeader,
|
|
sectionOffsets,
|
|
SectionStack,
|
|
SORTABLE_ROW_HEIGHT,
|
|
SortableList,
|
|
type Widget,
|
|
} from '../../src/ui';
|
|
import { makeBehavior, makeSpriteScene, place } from '../helpers';
|
|
|
|
beforeAll(() => {
|
|
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
|
});
|
|
|
|
const GAP = 4;
|
|
const PADDING = 8;
|
|
|
|
function row(h = 24): Box {
|
|
const box = new Box();
|
|
box.minHeight = h;
|
|
return box;
|
|
}
|
|
|
|
function column(...children: Widget[]): SectionStack {
|
|
const stack = new SectionStack({ gap: GAP, padding: PADDING });
|
|
for (const child of children) stack.add(child);
|
|
const size = stack.measure();
|
|
stack.resize(200, size.h);
|
|
return stack;
|
|
}
|
|
|
|
function memoryStore(initial: Record<string, string> = {}) {
|
|
const items = new Map(Object.entries(initial));
|
|
return {
|
|
items,
|
|
getItem: (key: string) => items.get(key) ?? null,
|
|
setItem: (key: string, value: string) => void items.set(key, value),
|
|
};
|
|
}
|
|
|
|
describe('the rows a folded group hides', () => {
|
|
it('are the ones down to the next head, never a head', () => {
|
|
expect(hiddenBySections([false, null, null, true, null, null, false, null])).toEqual([
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
]);
|
|
});
|
|
|
|
it('are none in front of the first head', () => {
|
|
expect(hiddenBySections([null, true, null])).toEqual([false, false, true]);
|
|
});
|
|
});
|
|
|
|
describe('where bands and rows go', () => {
|
|
const band = { h: SECTION_BAND_HEIGHT, start: true, band: true };
|
|
const plain = { h: 24, start: false, band: false };
|
|
|
|
it('puts the first band on the top edge and keeps the padding under the last row', () => {
|
|
const { ys, height } = sectionOffsets([band, plain, plain], GAP, PADDING);
|
|
expect(ys).toEqual([0, SECTION_BAND_HEIGHT + GAP + 2, SECTION_BAND_HEIGHT + GAP + 2 + 24 + GAP]);
|
|
expect(height).toBe(ys[2]! + 24 + PADDING);
|
|
});
|
|
|
|
it('keeps the padding between a group and the next band', () => {
|
|
const { ys } = sectionOffsets([band, plain, band], GAP, PADDING);
|
|
expect(ys[2]! - (ys[1]! + 24)).toBe(PADDING);
|
|
});
|
|
|
|
it('stands folded bands on each other with one line between them', () => {
|
|
const { ys, height } = sectionOffsets([band, band, band], GAP, PADDING);
|
|
expect(ys).toEqual([0, SECTION_BAND_HEIGHT - 1, 2 * (SECTION_BAND_HEIGHT - 1)]);
|
|
expect(height).toBe(ys[2]! + SECTION_BAND_HEIGHT);
|
|
});
|
|
|
|
it('shares exactly the line the bands draw, and still ends on a whole pixel', () => {
|
|
const line = 2 / 3;
|
|
const { ys, height } = sectionOffsets([band, band], GAP, PADDING, line);
|
|
expect(ys[1]).toBeCloseTo(SECTION_BAND_HEIGHT - line, 10);
|
|
expect(height).toBe(Math.ceil(2 * SECTION_BAND_HEIGHT - line));
|
|
});
|
|
|
|
it('starts inside the padding without a band on top', () => {
|
|
expect(sectionOffsets([plain, plain], GAP, PADDING).ys).toEqual([PADDING, PADDING + 24 + GAP]);
|
|
});
|
|
});
|
|
|
|
describe('a column of groups', () => {
|
|
it('runs a band across the padding and keeps the rows inside it', () => {
|
|
const head = new SectionHeader('POSITION');
|
|
const first = row();
|
|
const stack = column(head, first);
|
|
expect([head.x, head.w, head.h]).toEqual([0, 200, SECTION_BAND_HEIGHT]);
|
|
expect([first.x, first.w]).toEqual([PADDING, 200 - 2 * PADDING]);
|
|
expect(first.y).toBe(SECTION_BAND_HEIGHT + GAP + 2);
|
|
expect(stack.measure().h).toBe(first.y + 24 + PADDING);
|
|
});
|
|
|
|
it('hides the rows of a folded group, takes their room and gives both back', () => {
|
|
const a = new SectionHeader('A');
|
|
const b = new SectionHeader('B');
|
|
const rows = [row(), row(), row()];
|
|
const stack = column(a, rows[0]!, rows[1]!, b, rows[2]!);
|
|
const open = stack.measure().h;
|
|
let told = 0;
|
|
stack.onFold = () => (told += 1);
|
|
|
|
a.toggle();
|
|
expect(rows.map((r) => r.visible)).toEqual([false, false, true]);
|
|
expect(stack.measure().h).toBe(open - 2 * 24 - 2 * GAP - 2 - PADDING - 1);
|
|
expect(told).toBe(1);
|
|
stack.layout();
|
|
expect(b.y).toBe(SECTION_BAND_HEIGHT - 1);
|
|
|
|
a.toggle();
|
|
expect(rows.map((r) => r.visible)).toEqual([true, true, true]);
|
|
expect(stack.measure().h).toBe(open);
|
|
});
|
|
|
|
it('leaves a row hidden that was hidden for its own reason', () => {
|
|
const head = new SectionHeader('A');
|
|
const own = row();
|
|
own.visible = false;
|
|
const other = row();
|
|
column(head, own, other);
|
|
head.toggle();
|
|
head.toggle();
|
|
expect([own.visible, other.visible]).toEqual([false, true]);
|
|
});
|
|
|
|
it('starts with a group folded that is built folded', () => {
|
|
const rows = [row()];
|
|
column(new SectionHeader('A', { collapsed: true }), rows[0]!);
|
|
expect(rows[0]!.visible).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('a band', () => {
|
|
it('holds its title and puts a control at its right end, inside it', () => {
|
|
const head = new SectionHeader('VARIABLES');
|
|
const control = head.add(row());
|
|
control.minWidth = 40;
|
|
column(head);
|
|
const texts = head.children.flatMap((c) => c.children).filter((c): c is Label => c instanceof Label);
|
|
expect(texts.map((label) => label.text)).toEqual(['VARIABLES']);
|
|
const right = control.parent!.x + control.x + control.w;
|
|
expect(right).toBe(200 - PADDING);
|
|
expect(control.w).toBe(40);
|
|
});
|
|
|
|
it('knows where its controls stand, so a press a disabled one lets through folds nothing', () => {
|
|
const head = new SectionHeader('PROPERTIES');
|
|
const control = head.add(row());
|
|
control.minWidth = 40;
|
|
control.setEnabled(false);
|
|
column(head);
|
|
const y = SECTION_BAND_HEIGHT / 2;
|
|
expect(head.controlAt({ x: 200 - PADDING - 20, y })).toBe(true);
|
|
expect(head.controlAt({ x: 200 - PADDING - 41, y })).toBe(false);
|
|
expect(head.controlAt({ x: 60, y })).toBe(false);
|
|
});
|
|
|
|
it('tells its owner each time it is folded or unfolded', () => {
|
|
const told: boolean[] = [];
|
|
const head = new SectionHeader('A', { onToggle: (on) => told.push(on) });
|
|
head.toggle();
|
|
head.toggle();
|
|
expect(told).toEqual([true, false]);
|
|
expect(head.sectionCollapsed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('a list with a title', () => {
|
|
const options = { onPick: () => {}, onMove: () => {} };
|
|
|
|
it('folds down to its band and opens a group in its column', () => {
|
|
const told: boolean[] = [];
|
|
const list = new SortableList({
|
|
...options,
|
|
title: 'Layers',
|
|
collapsed: true,
|
|
onFold: (on) => told.push(on),
|
|
});
|
|
list.setItems(
|
|
[
|
|
{ id: 'a', label: 'A' },
|
|
{ id: 'b', label: 'B' },
|
|
],
|
|
[],
|
|
);
|
|
const note = row();
|
|
column(list, note);
|
|
expect(list.measure().h).toBe(SECTION_BAND_HEIGHT);
|
|
expect(list.sectionStart).toBe('inset');
|
|
expect(note.visible).toBe(false);
|
|
expect(list.x).toBe(PADDING);
|
|
});
|
|
|
|
it('shows its rows while it is open', () => {
|
|
const list = new SortableList({ ...options, title: 'Layers' });
|
|
list.setItems([{ id: 'a', label: 'A' }], []);
|
|
expect(list.measure().h).toBe(SECTION_BAND_HEIGHT + SORTABLE_ROW_HEIGHT);
|
|
});
|
|
|
|
it('is no group without a title, and cannot start folded', () => {
|
|
const list = new SortableList({ ...options, collapsed: true });
|
|
expect(list.sectionStart).toBeNull();
|
|
expect(list.sectionCollapsed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('the memory of folded groups', () => {
|
|
beforeEach(() => resetFolds());
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
resetFolds();
|
|
});
|
|
|
|
it('names a group by panel and title, whatever the title counts', () => {
|
|
expect(foldKey('inspector', 'KEY AT FRAME 12')).toBe(foldKey('inspector', 'KEY AT FRAME 3'));
|
|
expect(foldKey('inspector', '2 OBJECTS SELECTED')).toBe(foldKey('inspector', '14 OBJECTS SELECTED'));
|
|
expect(foldKey('inspector', 'LAYERS')).not.toBe(foldKey('tilemap', 'LAYERS'));
|
|
expect(foldKey('inspector', 'POSITION')).not.toBe(foldKey('inspector', 'TRANSFORM'));
|
|
});
|
|
|
|
it('writes a fold to the browser and reads it back on a new page', () => {
|
|
const store = memoryStore();
|
|
vi.stubGlobal('window', { localStorage: store });
|
|
const key = foldKey('inspector', 'TRANSFORM');
|
|
expect(isFolded(key)).toBe(false);
|
|
setFolded(key, true);
|
|
expect(isFolded(key)).toBe(true);
|
|
resetFolds();
|
|
expect(isFolded(key)).toBe(true);
|
|
setFolded(key, false);
|
|
resetFolds();
|
|
expect(isFolded(key)).toBe(false);
|
|
});
|
|
|
|
it('remembers for the page where the browser gives no storage, and reads rubbish as nothing', () => {
|
|
const key = foldKey('inspector', 'TRANSFORM');
|
|
setFolded(key, true);
|
|
expect(isFolded(key)).toBe(true);
|
|
resetFolds();
|
|
vi.stubGlobal('window', { localStorage: memoryStore({ 'game-maker.folded-sections': '{broken' }) });
|
|
expect(isFolded(key)).toBe(false);
|
|
});
|
|
|
|
it('builds a band folded that was folded before, and remembers a click on it', () => {
|
|
const store = memoryStore();
|
|
vi.stubGlobal('window', { localStorage: store });
|
|
const host = makeHost();
|
|
setFolded(foldKey('inspector', 'TRANSFORM'), true);
|
|
expect(sectionHeader(host, 'TRANSFORM').sectionCollapsed).toBe(true);
|
|
const position = sectionHeader(host, 'POSITION');
|
|
expect(position.sectionCollapsed).toBe(false);
|
|
position.toggle();
|
|
expect(isFolded(foldKey('inspector', 'POSITION'))).toBe(true);
|
|
// Another panel's group of the same name is its own.
|
|
expect(sectionHeader({ ...host, foldScope: 'tilemap' }, 'TRANSFORM').sectionCollapsed).toBe(false);
|
|
});
|
|
|
|
it('gives a list its fold under the title in capitals', () => {
|
|
vi.stubGlobal('window', { localStorage: memoryStore() });
|
|
listFold('pixelart', 'Layers').onFold(true);
|
|
expect(isFolded(foldKey('pixelart', 'LAYERS'))).toBe(true);
|
|
expect(listFold('pixelart', 'Layers').collapsed).toBe(true);
|
|
expect(listFold('pixelart', 'Frames').collapsed).toBe(false);
|
|
});
|
|
});
|
|
|
|
function makeHost(state = new EditorState()): InspectorHost {
|
|
return {
|
|
state,
|
|
body: new SectionStack({ gap: GAP, padding: PADDING }),
|
|
fields: new Map(),
|
|
nameField: null,
|
|
project: () => state.project,
|
|
liveVariables: () => null,
|
|
};
|
|
}
|
|
|
|
/** What the body shows, top to bottom: a band by its title in brackets, a row by the label it starts with. */
|
|
function outline(host: InspectorHost): string[] {
|
|
const firstLabel = (node: Widget): string => {
|
|
if (node instanceof Label) return node.text;
|
|
for (const child of node.children) {
|
|
const found = firstLabel(child as Widget);
|
|
if (found) return found;
|
|
}
|
|
return '';
|
|
};
|
|
return (host.body.children as Widget[]).map((child) =>
|
|
child instanceof SectionHeader ? `[${child.title}]` : firstLabel(child),
|
|
);
|
|
}
|
|
|
|
describe('the groups of the inspector', () => {
|
|
beforeEach(() => resetFolds());
|
|
|
|
function fixture() {
|
|
const f = makeSpriteScene();
|
|
const state = new EditorState();
|
|
state.loadProject(f.project, 1);
|
|
return { f, state };
|
|
}
|
|
|
|
it('gives the switches of a placed object a group of their own, under its head', () => {
|
|
const { f, state } = fixture();
|
|
const objectId = place(f, f.spriteId, { x: 0, y: 0 });
|
|
const host = makeHost(state);
|
|
buildObjectSection(host, f.sceneId, objectId);
|
|
const shown = outline(host);
|
|
expect(shown.slice(0, 7)).toEqual([
|
|
'[SPRITE OBJECT]',
|
|
'Name',
|
|
'Asset',
|
|
'Parent',
|
|
'[SWITCHES]',
|
|
'Enabled',
|
|
'Keep on scene load',
|
|
]);
|
|
expect(shown[7]).toBe('[POSITION]');
|
|
expect(shown.filter((line) => line.startsWith('['))).toEqual([
|
|
'[SPRITE OBJECT]',
|
|
'[SWITCHES]',
|
|
'[POSITION]',
|
|
'[TRANSFORM]',
|
|
'[EDITOR OFFSET (SCENE VIEW ONLY)]',
|
|
'[PROPERTIES]',
|
|
]);
|
|
});
|
|
|
|
it('puts the switches of a behavior there too, and its Open button into the band of its variables', () => {
|
|
const { f, state } = fixture();
|
|
const behavior = makeBehavior(f, 'Mover', ['speed']);
|
|
const objectId = place(f, behavior.id, { x: 0, y: 0 });
|
|
const host = makeHost(state);
|
|
buildObjectSection(host, f.sceneId, objectId);
|
|
const shown = outline(host);
|
|
const from = shown.indexOf('[SWITCHES]');
|
|
const to = shown.indexOf('[POSITION]');
|
|
expect(shown.slice(from + 1, to)).toEqual([
|
|
'Enabled',
|
|
'Keep on scene load',
|
|
'Run in editor',
|
|
'Run while paused',
|
|
]);
|
|
const variables = host.body.children.find(
|
|
(child): child is SectionHeader => child instanceof SectionHeader && child.title === 'VARIABLES',
|
|
)!;
|
|
const texts: string[] = [];
|
|
const walk = (node: Widget): void => {
|
|
if ('text' in node && typeof node.text === 'string') texts.push(node.text);
|
|
for (const child of node.children) walk(child as Widget);
|
|
};
|
|
walk(variables);
|
|
expect(texts).toContain('Open');
|
|
});
|
|
|
|
it('splits the rows of an asset into switches and properties', () => {
|
|
const { f, state } = fixture();
|
|
const host = makeHost(state);
|
|
buildAssetSection(host, f.spriteId);
|
|
const shown = outline(host);
|
|
expect(shown.slice(0, 5)).toEqual([
|
|
'[SPRITE ASSET]',
|
|
'Name',
|
|
'[SWITCHES]',
|
|
'Enabled',
|
|
'Keep on scene load',
|
|
]);
|
|
expect(shown[5]).toBe('[PROPERTIES]');
|
|
expect(shown.length).toBeGreaterThan(6);
|
|
});
|
|
|
|
it('heads the rows of a behavior asset as switches, with no empty group after them', () => {
|
|
const { f, state } = fixture();
|
|
const behavior = makeBehavior(f, 'Mover', ['speed']);
|
|
const host = makeHost(state);
|
|
buildAssetSection(host, behavior.id);
|
|
expect(findAsset(f.project, behavior.id)?.type).toBe('behavior');
|
|
expect(outline(host).filter((line) => line.startsWith('['))).toEqual([
|
|
'[BEHAVIOR ASSET]',
|
|
'[SWITCHES]',
|
|
'[VARIABLES]',
|
|
]);
|
|
});
|
|
|
|
it('builds a title as a band, in any body', () => {
|
|
const host = makeHost();
|
|
addTitle(host, 'CONTENT');
|
|
expect(host.body.children[0]).toBeInstanceOf(SectionHeader);
|
|
});
|
|
});
|