Load the 500 weight and draw missing glyphs as SVG icons (#214)
game-maker / gate (push) Successful in 2m47s
game-maker / suite (push) Successful in 8m41s

The theme asks for Source Sans 3 at weight 500, but fonts.ts only
loaded 400, 600 and 700. The browser synthesised the 500 text. The
issue comment also asked for the symbols the font lacks to become SVG
icons. A scan of src/ against the font's cmap found 20 such characters
in 30 places.

- fonts.ts loads source-sans-3-latin-500-normal.woff2 as well.
- Menus draw their ticks and the "current" dot as icons. The dot is a
  new MenuItem.current field and no longer part of the label text.
- Inspector action buttons (reset, apply, fit width/height) are
  icons. The menu arrow behind them is the drawn Dropdown chevron.
- Kind marks come from a table in markIcons.ts. Picture kinds use an
  icon, # and Aa stay text because the font has them.
- Scene marks in the asset panel, sound play/stop and the transition
  arrows are icons too.
- Apply is an icon although the font has the arrow. It sits next to
  the reset icon and would look different as a glyph.
- A new Icon widget draws on redraw like Button, so Node tests need no
  DOMParser.
- Tests find icons through the SVG id. The new hook
  __gm.locateIcons(id) and the harness helpers findIcon/clickIcon
  replace locate() on the old characters.

Findings:
- Weight 700 is loaded but the theme never uses it. Left as is.
- The old scene-mark check in module 35 accepted any play glyph on
  screen. It now checks the mark on the start scene's row.
- Module 44 needs the textures from module 37 and fails without it.

Tests:
- ./check.sh: 145 files, 2187 tests, exit 0 (before 144 / 2143).
- New tests/ui/fonts.test.ts: every theme weight is loaded, and every
  character in src/ exists in every shipped face. The cmap reader
  matches fontTools (231 characters).
- Icon tests cover the 19 new files. Each kind mark is either an
  icon or text, never both.
- e2e 1-16,22,30,34,35,43,44: 586 green, 0 FAIL, then 44 stopped
  for lack of module 37. e2e 1-16,37,44,47,48: 618 green.
  e2e 1-14 on the final build: 454 green.
- Two new e2e checks: loaded weights in 14, a reset icon pixel in 04.
- Counter-probes all fail as expected: 500 removed, a play glyph put
  back, the struct icon removed (one unit test each). In the browser,
  500 removed and reset icon hidden fail exactly the two new checks.
- Editor-suite check sum 1312 (1310 measured by CI at 056a622, +2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 08:13:22 +02:00
1 parent 056a622524
commit 0e8585665a
64 files changed
+769 -192

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1310 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1312 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+6 -11
View File
@@ -13,6 +13,7 @@ import { booleanOf, colorOf, type PropertyValue, textOf } from '../model/Propert
interface KindRow {
readonly kind: string;
readonly label: string;
/** A mark in letters; the other kinds are marked by a picture (`KIND_ICON_SVG` in the editor). */
readonly mark?: string;
readonly travels: PortType;
readonly empty?: number | string | boolean;
@@ -58,7 +59,6 @@ export const KINDS = [
{
kind: 'boolean',
label: 'Boolean',
mark: '✓',
travels: 'boolean',
empty: false,
coerce: bool,
@@ -69,7 +69,6 @@ export const KINDS = [
{
kind: 'color',
label: 'Color',
mark: '◧',
travels: 'number',
empty: 0,
coerce: color,
@@ -82,7 +81,6 @@ export const KINDS = [
{
kind: 'texture',
label: 'Texture',
mark: '▦',
travels: 'string',
empty: '',
coerce: text,
@@ -96,7 +94,6 @@ export const KINDS = [
{
kind: 'statemachine',
label: 'State machine',
mark: '◈',
travels: 'string',
empty: '',
coerce: text,
@@ -110,7 +107,6 @@ export const KINDS = [
{
kind: 'sound',
label: 'Sound',
mark: '♪',
travels: 'string',
empty: '',
coerce: text,
@@ -124,7 +120,6 @@ export const KINDS = [
{
kind: 'scene',
label: 'Scene',
mark: '▣',
travels: 'string',
empty: '',
coerce: text,
@@ -138,7 +133,6 @@ export const KINDS = [
{
kind: 'animation',
label: 'Animation',
mark: '▶',
travels: 'string',
empty: '',
coerce: text,
@@ -148,9 +142,9 @@ export const KINDS = [
field: true,
port: true,
},
{ kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true },
{ kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true },
{ kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true },
{ kind: 'object', label: 'Object', travels: 'object', port: true },
{ kind: 'list', label: 'List', travels: 'list', variable: true, port: true },
{ kind: 'struct', label: 'Struct', travels: 'struct', variable: true, port: true },
// Schema-only kinds: a setting has them, nothing a user declares does.
{ kind: 'enum', label: 'Choice', travels: 'string', empty: '', coerce: text },
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
@@ -208,7 +202,8 @@ export function kindLabel(kind: string): string {
export function kindMark(kind: string): string {
const row = rowOf(kind);
return row && 'mark' in row ? row.mark : '?';
if (!row) return '?';
return 'mark' in row ? row.mark : '';
}
/** A flat row as its kind's value type – what the table promises and a generic `K` cannot see. */
+2 -1
View File
@@ -128,7 +128,8 @@ export class ProjectSettingsDialog extends FormDialog {
private openPlayFromMenu(): void {
const entry = (from: PlayFrom) => ({
label: this.playFrom === from ? `● ${PLAY_FROM_LABEL[from]}` : PLAY_FROM_LABEL[from],
label: PLAY_FROM_LABEL[from],
current: this.playFrom === from,
onSelect: () => {
this.playFrom = from;
this.playFromButton.setText(PLAY_FROM_LABEL[from]);
+2 -1
View File
@@ -123,7 +123,8 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
.filter((a) => ANIMATION_ROOT_TYPES.includes(a.type))
.sort((a, b) => a.name.localeCompare(b.name))
.map((candidate) => ({
label: `${candidate.id === asset.root ? '● ' : ''}${candidate.name} (${candidate.type})`,
label: `${candidate.name} (${candidate.type})`,
current: candidate.id === asset.root,
onSelect: () => this.state.execute(new SetAnimationRootCommand(asset.id, candidate.id)),
}));
if (items.length === 0) items.push({ label: 'Nothing to animate yet', onSelect: () => undefined });
+4 -2
View File
@@ -115,7 +115,8 @@ export class RowDialog extends FormDialog {
private openTargets(): void {
const items: MenuItem[] = this.targets().map((choice) => ({
label: choice.id === this.target ? `● ${choice.label}` : choice.label,
label: choice.label,
current: choice.id === this.target,
onSelect: () => {
this.target = choice.id;
// Another object may not have the property; fall back to its first.
@@ -129,7 +130,8 @@ export class RowDialog extends FormDialog {
private openProperties(): void {
const items: MenuItem[] = this.properties().map((def) => ({
label: def.key === this.property ? `● ${def.label}` : def.label,
label: def.label,
current: def.key === this.property,
onSelect: () => {
this.property = def.key;
this.refresh();
+10 -1
View File
@@ -68,7 +68,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
h: snap.button.h,
toggled: snap.button.toggled,
});
const { eachText, locate, locateAll } = textProbe(app);
const { eachText, locate, locateAll, locateIcons } = textProbe(app);
/** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */
const textColor = (text: string): string | null => {
let found: string | null = null;
@@ -209,6 +209,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
locate,
locateAll,
/** Pictures on screen drawn from the SVG file with this id (#214), e.g. `reset`, `kind-struct`. */
locateIcons,
/** The first such picture (or the topmost with `last`), or `null`. */
locateIcon: (name: string, last = false) => {
const hits = locateIcons(name);
return (last ? hits.at(-1) : hits[0]) ?? null;
},
inspectorField: fieldOf,
/** The inspector panel on screen – what tells whether a row of it can be clicked at all. */
inspectorRect: () => rectOf(inspector),
@@ -229,6 +236,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
/** Per entry of `menuRows`, whether an icon stands in front of it. */
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
/** Per entry of `menuRows`, its tick box: `true`/`false`, `null` for an entry without one. */
menuTicks: () => currentMenu()?.ticksDrawn() ?? null,
/** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */
menuEnabled: () => currentMenu()?.enabledDrawn() ?? null,
textOverflows,
+7 -3
View File
@@ -12,11 +12,15 @@ import {
texturePixelSize,
} from '../../core/properties/aspect';
import { coerceValue, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import { ROW_ACTION_ICON_SVG } from '../panels/markIcons';
import type { FieldAction, FieldActionGroup } from './fieldFactory';
import type { InspectorHost } from './host';
/** ↔ sets the width, ↕ the height – the side the button changes, not the one it reads. */
const AXIS_GLYPH: Record<SizeAxis, string> = { width: '↔', height: '↕' };
/** A sideways arrow sets the width, an upright one the height – the side the button changes, not the one it reads. */
const AXIS_ICON: Record<SizeAxis, string> = {
width: ROW_ACTION_ICON_SVG.fitWidth,
height: ROW_ACTION_ICON_SVG.fitHeight,
};
/** What a size action writes: one side for a fit, both for the picture's pixel size. */
export type SizeValues = Partial<Record<SizeAxis, number>>;
@@ -61,7 +65,7 @@ export function aspectActions(
const axis: SizeAxis = def.key;
return [
{
glyph: AXIS_GLYPH[axis],
icon: AXIS_ICON[axis],
available: () => {
const bag = props();
const size = bag && texturePixelSize(host.project(), bag);
+58 -20
View File
@@ -1,3 +1,4 @@
import { Graphics } from 'pixi.js';
import { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import {
@@ -24,14 +25,17 @@ import {
import { NO_TEXTURE_LABEL, pictureChoices, textureRefLabel } from '../../core/properties/textureRef';
import {
Button,
CHEVRON_SLOT,
Checkbox,
ColorField,
drawChevron,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
NumberField,
type Size,
Spacer,
TextField,
Theme,
@@ -39,6 +43,7 @@ import {
VStack,
type Widget,
} from '../../ui';
import { ROW_ACTION_ICON_SVG } from '../panels/markIcons';
/**
* What a field needs beyond its own value: the project, to resolve an asset reference to a name
@@ -99,9 +104,9 @@ export type FieldActions = () => FieldAction[];
export const actionIf = (offered: unknown, label: string, onSelect: () => void): FieldAction[] =>
offered ? [{ label, onSelect }] : [];
/** One trailing button of a row: its glyph, and what it currently offers. */
/** One trailing button of a row: its picture (an SVG file), and what it currently offers. */
export interface FieldActionGroup {
glyph: string;
icon: string;
available: FieldActions;
}
@@ -122,12 +127,11 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
return { row, label };
}
/** Adds the trailing ↺/↑/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */
/** Adds the trailing reset/apply/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */
export function attachRowActions(row: HStack, rowActions: RowActions): () => void {
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
const addActions = (glyph: string, available: FieldActions): void => {
// A floor, not a width: the same button also carries the two-glyph menu form "↺ ▾".
const button = new Button(glyph, { variant: 'ghost', minWidth: Theme.controlHeight });
const actions: { button: RowActionButton; available: FieldActions }[] = [];
const addActions = (icon: string, available: FieldActions): void => {
const button = new RowActionButton(icon);
// Asked again on click: the menu must not act on a state that has moved on since the
// last refresh (an undo between refreshes, a value edited in the viewport).
button.onClick = () => {
@@ -137,27 +141,23 @@ export function attachRowActions(row: HStack, rowActions: RowActions): () => voi
new Menu(items.map((a) => ({ label: a.label, onSelect: a.onSelect }))).openBelow(button);
};
button.visible = false;
actions.push({ button, glyph, available });
actions.push({ button, available });
row.add(button);
};
for (const group of rowActions.extra ?? []) addActions(group.glyph, group.available);
if (rowActions.apply) addActions('↑', rowActions.apply);
if (rowActions.reset) addActions('↺', rowActions.reset);
for (const group of rowActions.extra ?? []) addActions(group.icon, group.available);
if (rowActions.apply) addActions(ROW_ACTION_ICON_SVG.apply, rowActions.apply);
if (rowActions.reset) addActions(ROW_ACTION_ICON_SVG.reset, rowActions.reset);
const refreshActions = (): void => {
let changed = false;
for (const { button, glyph, available } of actions) {
for (const { button, available } of actions) {
const count = available().length;
const text = count > 1 ? `${glyph} ▾` : glyph;
const visible = count > 0;
if (button.visible !== visible) {
button.visible = visible;
changed = true;
}
if (visible && button.text !== text) {
button.setText(text);
changed = true;
}
if (visible && button.setMenu(count > 1)) changed = true;
}
if (changed) row.relayout();
};
@@ -165,6 +165,44 @@ export function attachRowActions(row: HStack, rowActions: RowActions): () => voi
return refreshActions;
}
/** A row action: its picture alone, or with a chevron once it offers more than one thing. */
class RowActionButton extends Button {
private readonly chevron = new Graphics();
private menu = false;
constructor(icon: string) {
super('', { variant: 'ghost', minWidth: Theme.controlHeight, icon });
this.addChild(this.chevron);
}
/** Whether the menu form changed – the row has to be laid out again then. */
setMenu(menu: boolean): boolean {
if (menu === this.menu) return false;
this.menu = menu;
this.invalidate();
return true;
}
override get opensMenu(): boolean {
return this.menu;
}
override measure(): Size {
const size = super.measure();
return this.menu ? { w: size.w + CHEVRON_SLOT, h: size.h } : size;
}
protected override trailingRoom(): number {
return this.menu ? CHEVRON_SLOT : 0;
}
override redraw(): void {
super.redraw();
if (this.menu) drawChevron(this.chevron, this.w, this.h, Theme.colors.textMuted);
else this.chevron.clear();
}
}
/**
* Maps one schema entry to a labelled row. Action groups are asked again on each refresh: none means
* no button, one a button, several a menu.
@@ -286,13 +324,13 @@ function referenceField(
let current = textOf(value);
const button = new Dropdown(ref.label(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
const items: MenuItem[] = [
{ label: mark('', ref.none), onSelect: () => handlers.onChange('') },
{ label: ref.none, current: current === '', onSelect: () => handlers.onChange('') },
// Built on click, not once per row: assets are created, renamed and deleted while the
// inspector stands. Searchable: one sliced sheet alone contributes an entry per frame.
...ref.choices(ctx.project(), def).map((choice) => ({
label: mark(choice.id, choice.label),
label: choice.label,
current: choice.id === current,
onSelect: () => handlers.onChange(choice.id),
})),
];
+2 -1
View File
@@ -33,7 +33,8 @@ export function parentRow(host: InspectorHost, holder: ObjectHolder, object: Sce
if (isSelfOrAncestor(current, object.id, candidate.id)) continue;
const label = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({
label: candidate.id === object.parentId ? `● ${label}` : label,
label,
current: candidate.id === object.parentId,
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
});
}
+2 -2
View File
@@ -27,7 +27,7 @@ import {
} from '../../core/graph';
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
import { DECLARED_PORT_KINDS, kindMark, VARIABLE_KINDS } from '../../core/properties/kinds';
import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds';
import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import {
Checkbox,
@@ -257,7 +257,7 @@ function portRow(
): void {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(markLabel(kindMark(port.kind)));
row.add(markLabel(port.kind));
row.add(name, 1);
for (const extra of extras) row.add(extra);
row.add(new Spacer());
+3 -2
View File
@@ -16,7 +16,8 @@ import {
} from '../../core/graph';
import { graphLookup } from '../../core/model/graphLookup';
import { textOf } from '../../core/model/Properties';
import { Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { Dropdown, HStack, Icon, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { ARROW_ICON_SVG } from '../panels/markIcons';
import { NAME_WIDTH, removeButton } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
@@ -117,7 +118,7 @@ function group(
const far = findNode(graph, outgoing ? edge.to.node : edge.from.node);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(new Label(outgoing ? '→' : '←', { style: 'muted' }));
row.add(new Icon(outgoing ? ARROW_ICON_SVG.out : ARROW_ICON_SVG.in));
row.add(new Label(far ? stateName(far) : '?', { style: 'muted' }));
row.add(new Spacer());
row.add(
+26 -8
View File
@@ -1,10 +1,17 @@
import type { PropertyValue } from '../../core/model/Properties';
import { kindLabel, kindMark, STRUCT_FIELD_KINDS, type StructFieldKind } from '../../core/properties/kinds';
import {
type Kind,
kindLabel,
kindMark,
STRUCT_FIELD_KINDS,
type StructFieldKind,
} from '../../core/properties/kinds';
import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema';
import {
Button,
Dropdown,
HStack,
Icon,
Label,
Menu,
type MenuItem,
@@ -28,6 +35,7 @@ import {
type RowActions,
structFieldValue,
} from './fieldFactory';
import { KIND_ICON_SVG } from '../panels/markIcons';
import { addNote, type InspectorHost } from './host';
/** The menu every "add a kind" button opens: one entry per kind, named as the kind table names it. */
@@ -63,7 +71,7 @@ export function buildVariable(
): FieldRow {
const { edit, actions } = options;
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
const head = gridRow(0, markLabel(kindMark(def.kind)), name);
const head = gridRow(0, markLabel(def.kind), name);
let setValue: (next: PropertyValue) => void = () => {};
if (def.kind === 'struct') {
head.add(new Spacer(), 1);
@@ -116,7 +124,7 @@ function structFieldRows(
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
return gridRow(1, markLabel(kindMark(field.kind)), name);
return gridRow(1, markLabel(field.kind), name);
}
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
@@ -141,13 +149,17 @@ function nameCell(text: string, depth: number, rename: ((next: string) => void)
: new Label(text, { style: 'muted', width, padding: Theme.fieldPadding });
}
export function markLabel(glyph: string): Label {
return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH });
/** A kind's mark: its picture, or its letters for the kinds that have no picture (`#`, `Aa`). */
export function markLabel(kind: string): Widget {
const icon = KIND_ICON_SVG[kind as Kind];
return icon
? new Icon(icon, MARK_WIDTH)
: new Label(kindMark(kind), { style: 'small', align: 'center', width: MARK_WIDTH });
}
/**
* A kind mark that can be clicked – the List tab's way of saying what an item holds. The label may
* take the button's whole width: the column is sized for its widest glyph, and the usual inset
* take the button's whole width: the column is sized for its widest mark, and the usual inset
* would cut `Aa` to `A…`.
*/
class MarkButton extends Button {
@@ -157,8 +169,14 @@ class MarkButton extends Button {
}
/** The mark of a kind, as a button that offers the others. */
export function markPicker(glyph: string, items: MenuItem[]): Button {
const picker = new MarkButton(glyph, { variant: 'ghost', width: MARK_WIDTH, style: 'small' });
export function markPicker(kind: string, items: MenuItem[]): Button {
const icon = KIND_ICON_SVG[kind as Kind];
const picker = new MarkButton(icon ? '' : kindMark(kind), {
variant: 'ghost',
width: MARK_WIDTH,
style: 'small',
icon,
});
picker.onClick = () => new Menu(items).openBelow(picker);
return picker;
}
+3 -9
View File
@@ -26,13 +26,7 @@ import {
untag,
} from '../../core/model/Properties';
import { nextFieldName } from '../../core/commands/naming';
import {
FIELD_KINDS,
type FieldKind,
kindMark,
LIST_ITEM_KINDS,
type ListItemKind,
} from '../../core/properties/kinds';
import { FIELD_KINDS, type FieldKind, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds';
import {
type ListItem,
itemKindOf,
@@ -213,7 +207,7 @@ export class ListEditor extends VStack {
if (tall) label.minHeight = Theme.rowHeight;
row.add(label);
const mark = markPicker(
kindMark(kind),
kind,
// Through `untag`, so a color keeps its number when it becomes one.
kindMenu(
access.kinds.filter((next) => next !== kind),
@@ -256,7 +250,7 @@ export class ListEditor extends VStack {
const line = new HStack({ gap: Theme.gap, align: 'center' });
line.minHeight = Theme.rowHeight;
const fieldMark = markPicker(
kindMark(field.kind),
field.kind,
kindMenu(
FIELD_KINDS.filter((next) => next !== field.kind),
(next) =>
+10 -5
View File
@@ -31,10 +31,12 @@ import {
Panel,
ScrollContainer,
startDrag,
UI_ICON_SVG,
} from '../../ui';
import { ASSET_GROUP_LABELS, groupAssets } from './assetGroups';
import { ASSET_ICON_SVG } from './assetIcons';
import { assetMenuGroups } from './assetMenu';
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
import { confirmDeleteAsset } from '../actions';
import type { EditorState } from '../state';
import { openFontPicker } from '../font/FontPicker';
@@ -108,13 +110,16 @@ export class AssetPanel extends Panel {
}
/**
* What a scene row says about itself: `●` the one the editor shows, `▶` the one a run begins
* with. Two marks rather than one, because they are two different questions and often two
* What a scene row says about itself: a dot for the one the editor shows, play for the one a run
* begins with. Two marks rather than one, because they are two different questions and often two
* different scenes – the level you are editing versus the title screen.
*/
private sceneMarks(id: string): string {
private sceneMarks(id: string): string[] {
const { activeSceneId, startSceneId } = this.state.project;
return `${id === startSceneId ? '▶' : ''}${id === activeSceneId ? '●' : ''}`;
return [
...(id === startSceneId ? [TOOLBAR_ICON_SVG.play] : []),
...(id === activeSceneId ? [UI_ICON_SVG.current] : []),
];
}
private refresh(): void {
@@ -135,7 +140,7 @@ export class AssetPanel extends Panel {
items.push({
id: asset.id,
label: asset.name,
hint: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined,
marks: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined,
depth: 1,
overridden: assetDeviates(project, asset),
});
+2 -1
View File
@@ -260,7 +260,8 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
onClick: () =>
new Menu(
FOCUS.map((f) => ({
label: f.focus === paint.focus ? `● ${f.label}` : f.label,
label: f.label,
current: f.focus === paint.focus,
onSelect: () => state.setTilemapPaint({ focus: f.focus }),
})),
).openBelow(focus),
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="apply" viewBox="-8 -8 16 16" width="16" height="16">
<line x1="0" y1="6" x2="0" y2="-2" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-4.5 -1 L0 -6 L4.5 -1 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 257 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="arrow-in" viewBox="-8 -8 16 16" width="16" height="16">
<line x1="6" y1="0" x2="-1" y2="0" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-6 0 L-1 -3.5 L-1 3.5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 260 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="arrow-out" viewBox="-8 -8 16 16" width="16" height="16">
<line x1="-6" y1="0" x2="1" y2="0" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M6 0 L1 -3.5 L1 3.5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 258 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="fit-height" viewBox="-8 -8 16 16" width="16" height="16">
<line x1="0" y1="-3" x2="0" y2="3" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M0 -7 L-3.5 -3 L3.5 -3 Z" fill="currentColor" />
<path d="M0 7 L-3.5 3 L3.5 3 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="fit-width" viewBox="-8 -8 16 16" width="16" height="16">
<line x1="-3" y1="0" x2="3" y2="0" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-7 0 L-3 -3.5 L-3 3.5 Z" fill="currentColor" />
<path d="M7 0 L3 -3.5 L3 3.5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 318 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-animation" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-3 -4.5 L4.5 0 L-3 4.5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 174 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-boolean" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-4 0 L-1.2 3 L4 -3.5" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 201 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-color" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-4.5" y="-4.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.2" />
<rect x="-4.5" y="-4.5" width="4.5" height="9" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-list" viewBox="-8 -8 16 16" width="16" height="16">
<line x1="-4.5" y1="-3" x2="4.5" y2="-3" fill="none" stroke="currentColor" stroke-width="1.2" />
<line x1="-4.5" y1="0" x2="4.5" y2="0" fill="none" stroke="currentColor" stroke-width="1.2" />
<line x1="-4.5" y1="3" x2="4.5" y2="3" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>

After

Width:  |  Height:  |  Size: 401 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-object" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M0 -5.5 L5.5 0 L0 5.5 L-5.5 0 Z" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>

After

Width:  |  Height:  |  Size: 210 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-scene" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-4.5" y="-4.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.2" />
<rect x="-2" y="-2" width="4" height="4" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 278 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-sound" viewBox="-8 -8 16 16" width="16" height="16">
<ellipse cx="-1.8" cy="3.2" rx="2.3" ry="1.7" fill="currentColor" />
<line x1="0" y1="3" x2="0" y2="-5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M0 -5 C1.5 -3.5, 3.5 -3, 3.2 -0.5" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-statemachine" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M0 -5.5 L5.5 0 L0 5.5 L-5.5 0 Z" fill="none" stroke="currentColor" stroke-width="1.2" />
<path d="M0 -2.5 L2.5 0 L0 2.5 L-2.5 0 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 283 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-struct" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-4.5" y="-4.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.2" />
<line x1="0" y1="-4.5" x2="0" y2="4.5" fill="none" stroke="currentColor" stroke-width="1.2" />
<line x1="-4.5" y1="0" x2="4.5" y2="0" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="kind-texture" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-4.5" y="-4.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.2" />
<line x1="-1.5" y1="-4.5" x2="-1.5" y2="4.5" fill="none" stroke="currentColor" stroke-width="1.2" />
<line x1="1.5" y1="-4.5" x2="1.5" y2="4.5" fill="none" stroke="currentColor" stroke-width="1.2" />
<line x1="-4.5" y1="-1.5" x2="4.5" y2="-1.5" fill="none" stroke="currentColor" stroke-width="1.2" />
<line x1="-4.5" y1="1.5" x2="4.5" y2="1.5" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>

After

Width:  |  Height:  |  Size: 622 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<svg xmlns="http://www.w3.org/2000/svg" id="play" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-4.5 -6 L6 0 L-4.5 6 Z" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 162 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="reset" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M0.00 -4.00 C1.27 -4.00, 2.49 -3.49, 3.39 -2.59 C4.29 -1.69, 4.80 -0.47, 4.80 0.80 C4.80 2.07, 4.29 3.29, 3.39 4.19 C2.49 5.09, 1.27 5.60, 0.00 5.60 C-1.27 5.60, -2.49 5.09, -3.39 4.19 C-4.29 3.29, -4.80 2.07, -4.80 0.80" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-4.2 -4 L0 -6.8 L0 -1.2 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 455 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<svg xmlns="http://www.w3.org/2000/svg" id="stop" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-5.5" y="-5.5" width="11" height="11" rx="0.5" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 174 B

After

Width:  |  Height:  |  Size: 184 B

+40
View File
@@ -0,0 +1,40 @@
/**
* The small symbols in inspector rows and lists (#214): kind marks, row actions, transition arrows.
* They used to be characters, which the bundled Latin font does not have.
*/
import type { Kind } from '../../core/properties/kinds';
import apply from './icons/apply.svg?raw';
import arrowIn from './icons/arrow-in.svg?raw';
import arrowOut from './icons/arrow-out.svg?raw';
import fitHeight from './icons/fit-height.svg?raw';
import fitWidth from './icons/fit-width.svg?raw';
import animation from './icons/kind-animation.svg?raw';
import boolean from './icons/kind-boolean.svg?raw';
import color from './icons/kind-color.svg?raw';
import list from './icons/kind-list.svg?raw';
import object from './icons/kind-object.svg?raw';
import scene from './icons/kind-scene.svg?raw';
import sound from './icons/kind-sound.svg?raw';
import statemachine from './icons/kind-statemachine.svg?raw';
import struct from './icons/kind-struct.svg?raw';
import texture from './icons/kind-texture.svg?raw';
import reset from './icons/reset.svg?raw';
/** Kinds shown as a picture; the rest keep their mark in letters (`#`, `Aa`). */
export const KIND_ICON_SVG: Partial<Record<Kind, string>> = {
animation,
boolean,
color,
list,
object,
scene,
sound,
statemachine,
struct,
texture,
};
export const ROW_ACTION_ICON_SVG = { apply, reset, fitWidth, fitHeight };
/** A transition row's direction: one leaving the state, one coming in. */
export const ARROW_ICON_SVG = { out: arrowOut, in: arrowIn };
+11 -9
View File
@@ -1,6 +1,6 @@
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import type { StoredSound } from '../../core/store/SoundStore';
import { formatBytes, textStyle, truncate } from '../../ui';
import { drawSvg, formatBytes, textStyle, Theme, truncate } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import {
currentPicker,
@@ -11,30 +11,31 @@ import {
RowList,
withAssetNames,
} from '../FilePicker';
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
import { type SoundImportDeps, importStoredSound, pickAndImportSounds } from '../soundImport';
const ROW_HEIGHT = 44;
const ROW_PAD = 8;
/** The play glyph's column at the left of a row: a press in here plays instead of selecting. */
/** The play mark's column at the left of a row: a press in here plays instead of selecting. */
const PLAY_WIDTH = 34;
const IDLE_HINT = 'Press ▶ to hear one.';
const IDLE_HINT = 'Press play to hear one.';
type SoundItem = PickerItem<StoredSound>;
interface SoundRow extends PickerRow {
play: Text;
play: Graphics;
detail: Text;
}
/**
* A sound has nothing to show, so each row shows what is known about the file and plays it on
* demand: the ▶ at its left is the preview, the rest of the row is the pick.
* demand: the play mark at its left is the preview, the rest of the row is the pick.
*/
class SoundList extends RowList<SoundItem, SoundRow> {
protected readonly rowHeight = ROW_HEIGHT;
/** The last file a ▶ started, so a check can tell a preview from a pick (test hook). */
/** The last file a play mark started, so a check can tell a preview from a pick (test hook). */
lastPlayed: string | null = null;
/** Global centre of a row's ▶ – what a check presses to hear one without choosing it. */
/** Global centre of a row's play mark – what a check presses to hear one without choosing it. */
playGlobal(file: string): { x: number; y: number } | null {
const found = this.rowOf(file);
if (!found) return null;
@@ -45,7 +46,8 @@ class SoundList extends RowList<SoundItem, SoundRow> {
protected buildRow(): SoundRow {
const box = new Container();
const background = new Graphics();
const play = new Text({ text: '▶', style: textStyle('accent') });
const play = new Graphics();
drawSvg(play, TOOLBAR_ICON_SVG.play, Theme.colors.accentText);
const caption = new Text({ style: textStyle('body') });
const detail = new Text({ style: textStyle('small') });
box.addChild(background, play, caption, detail);
@@ -54,7 +56,7 @@ class SoundList extends RowList<SoundItem, SoundRow> {
protected redrawRow(row: SoundRow, item: SoundItem, selected: boolean): void {
this.drawBox(row, this.w, ROW_HEIGHT, selected);
row.play.position.set(ROW_PAD + 2, (ROW_HEIGHT - row.play.height) / 2);
row.play.position.set(PLAY_WIDTH / 2, ROW_HEIGHT / 2);
const width = this.w - PLAY_WIDTH - ROW_PAD;
row.caption.style = textStyle(item.assetName ? 'accent' : 'body');
+3 -2
View File
@@ -1,5 +1,6 @@
import { Button, HStack, Label, Theme } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
/**
* What a sound asset looks like: a button that plays it and the length the decoder reports. There
@@ -14,10 +15,10 @@ export class SoundPreview extends HStack {
constructor(private readonly file: string) {
super({ gap: Theme.gap, align: 'center' });
const play = new Button('▶ Play');
const play = new Button('Play', { icon: TOOLBAR_ICON_SVG.play });
play.onClick = () => soundLibrary.play(this.file, 1, false);
this.add(play);
const stop = new Button('■ Stop');
const stop = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop });
stop.onClick = () => soundLibrary.stop(this.file);
this.add(stop);
this.length = new Label('', { style: 'muted' });
+40 -10
View File
@@ -1,5 +1,5 @@
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
import { Widget } from '../ui';
import { type Application, type Container, Graphics, Rectangle, Text } from 'pixi.js';
import { Button, Widget } from '../ui';
/** The global box of a widget: where it starts, and how large it is. */
export function rectOf(widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }): {
@@ -24,6 +24,13 @@ function intersect(a: Rectangle, b: Rectangle): Rectangle {
);
}
/** A picture on screen, found by the id of its file. */
export interface IconHit {
x: number;
y: number;
menu: boolean;
}
/**
* Finding widgets by the text they show – the ground floor of the test hook, and the only part of
* it the sign-in screen needs, since it exists before there is an editor to ask.
@@ -32,6 +39,7 @@ export function textProbe(app: Application): {
eachText: (root: Container, visit: (node: Text, point: { x: number; y: number }) => void) => void;
locateAll: (text: string) => { x: number; y: number }[];
locate: (text: string, last?: boolean) => { x: number; y: number } | null;
locateIcons: (name: string) => IconHit[];
} {
/**
* Every text under `root` that is really on screen, in drawing order (the topmost last), handed
@@ -50,7 +58,10 @@ export function textProbe(app: Application): {
* graphics that serves as a mask to the mask effect, and `getBounds()` on it then reports an
* empty rectangle at the origin – which would throw away every masked text in the editor.
*/
const eachText = (root: Container, visit: (node: Text, point: { x: number; y: number }) => void): void => {
const eachShown = (
root: Container,
visit: (node: Container, clip: Rectangle | null, owner: Widget | null) => boolean,
): void => {
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => {
if (!node.visible) return;
const holder = node instanceof Widget ? node : owner;
@@ -60,17 +71,36 @@ export function textProbe(app: Application): {
const box = new Rectangle(r.x, r.y, r.w, r.h);
inner = clip ? intersect(clip, box) : box;
}
if (node instanceof Text) {
if (visit(node, inner, holder)) return;
for (const child of node.children) walk(child, inner, holder);
};
walk(root, null, null);
};
const eachText = (root: Container, visit: (node: Text, point: { x: number; y: number }) => void): void => {
eachShown(root, (node, clip) => {
if (!(node instanceof Text)) return false;
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
const box = node.getBounds();
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
if (!inner || inner.contains(point.x, point.y)) visit(node, point);
return;
}
for (const child of node.children) walk(child, inner, holder);
if (!clip || clip.contains(point.x, point.y)) visit(node, point);
return true;
});
};
walk(root, null, null);
/**
* Every picture drawn from the file with this id (`icon:<id>`, see `drawSvg`), topmost last. The
* point is the file's origin, its centre; `menu` says whether the button it sits on opens a menu.
*/
const locateIcons = (name: string): IconHit[] => {
const hits: IconHit[] = [];
eachShown(app.stage, (node, clip, owner) => {
if (!(node instanceof Graphics) || node.label !== `icon:${name}`) return false;
const p = node.getGlobalPosition();
if (!clip || clip.contains(p.x, p.y))
hits.push({ x: p.x, y: p.y, menu: owner instanceof Button && owner.opensMenu });
return true;
});
return hits;
};
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
const locateAll = (text: string): { x: number; y: number }[] => {
@@ -84,5 +114,5 @@ export function textProbe(app: Application): {
const hits = locateAll(text);
return (last ? hits.at(-1) : hits[0]) ?? null;
};
return { eachText, locateAll, locate };
return { eachText, locateAll, locate, locateIcons };
}
+2 -1
View File
@@ -80,7 +80,8 @@ export class TileEditor extends AssetTabEditor<TileAsset, TileCanvas> {
if (!asset) return;
const current = tileKind(asset);
const items: MenuItem[] = TILE_KINDS.map((kind) => ({
label: `${kind === current ? '● ' : ''}${optionLabel(this.state, asset, kind)}`,
label: optionLabel(this.state, asset, kind),
current: kind === current,
onSelect: () => this.state.execute(new SetAssetPropertyCommand(asset.id, TILE_KIND_KEY, kind)),
}));
new Menu(items).openBelow(this.kindPicker);
+3 -1
View File
@@ -1,10 +1,12 @@
import codePro400 from './fonts/source-code-pro-latin-400-normal.woff2?url';
import sans400 from './fonts/source-sans-3-latin-400-normal.woff2?url';
import sans500 from './fonts/source-sans-3-latin-500-normal.woff2?url';
import sans600 from './fonts/source-sans-3-latin-600-normal.woff2?url';
import sans700 from './fonts/source-sans-3-latin-700-normal.woff2?url';
const FACES: readonly [family: string, url: string, weight: string][] = [
export const FACES: readonly [family: string, url: string, weight: string][] = [
['Source Sans 3', sans400, '400'],
['Source Sans 3', sans500, '500'],
['Source Sans 3', sans600, '600'],
['Source Sans 3', sans700, '700'],
['Source Code Pro', codePro400, '400'],
+9
View File
@@ -0,0 +1,9 @@
/**
* Pictures the toolkit itself draws (#214). Symbols outside the Latin subset of the bundled font would
* come from whatever font the system has, so they are SVG files like every other icon.
*/
import current from './icons/current.svg?raw';
import ticked from './icons/ticked.svg?raw';
import unticked from './icons/unticked.svg?raw';
export const UI_ICON_SVG = { current, ticked, unticked };
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" id="current" viewBox="-8 -8 16 16" width="16" height="16">
<circle cx="0" cy="0" r="3" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 159 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="ticked" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-5.5" y="-5.5" width="11" height="11" rx="1" fill="none" stroke="currentColor" stroke-width="1.2" />
<path d="M-3 0.2 L-0.8 2.6 L3.2 -2.6" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 313 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" id="unticked" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-5.5" y="-5.5" width="11" height="11" rx="1" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>

After

Width:  |  Height:  |  Size: 219 B

+3 -1
View File
@@ -25,7 +25,9 @@ export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep,
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
export { Label, truncate } from './widgets/Label';
export { Button, type ButtonOptions } from './widgets/Button';
export { Dropdown } from './widgets/Dropdown';
export { CHEVRON_SLOT, Dropdown, drawChevron } from './widgets/Dropdown';
export { Icon } from './widgets/Icon';
export { UI_ICON_SVG } from './icons';
export { Checkbox } from './widgets/Checkbox';
export { Panel } from './widgets/Panel';
export { Slider } from './widgets/Slider';
+5 -1
View File
@@ -12,7 +12,10 @@ export function tintSvg(svg: string, color: number): string {
/** Parsed once per file and colour: a scene symbol is redrawn with every frame. */
const parsed = new Map<string, Map<number, SVGElement>>();
/** Adds the file to what `node` already draws. */
/**
* Adds the file to what `node` already draws. A file with an `id` names the graphics after it
* (`icon:<id>`), so a test finds a picture the way it finds a text (`locateIcon`).
*/
export function drawSvg(node: Graphics, svg: string, color: number): void {
let byColor = parsed.get(svg);
if (!byColor) parsed.set(svg, (byColor = new Map<number, SVGElement>()));
@@ -22,5 +25,6 @@ export function drawSvg(node: Graphics, svg: string, color: number): void {
element = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement as unknown as SVGElement;
byColor.set(color, element);
}
if (element.id) node.label = `icon:${element.id}`;
SVGParser(element, node.context);
}
+14 -2
View File
@@ -174,11 +174,23 @@ export class Button extends Widget {
return this.icon !== null;
}
/** Whether a click opens a menu rather than acting – what the chevron says (test hook). */
get opensMenu(): boolean {
return false;
}
/** Room at the right edge that is neither label nor icon; a centred label centres left of it. */
protected trailingRoom(): number {
return 0;
}
/** Width the label may occupy; subclasses that draw inside the button take their room off here. */
protected labelSlot(): number {
// The padding is an inset, not a claim: on a 24 px icon button it would leave 8 px and
// truncate the glyph away entirely. From 48 px up this is the full padding again.
return this.w - Math.min(this.padding, Math.floor(this.w / 6)) * 2 - this.iconSpace();
return (
this.w - Math.min(this.padding, Math.floor(this.w / 6)) * 2 - this.iconSpace() - this.trailingRoom()
);
}
override onKeyDown(event: KeyboardEvent): boolean {
@@ -232,7 +244,7 @@ export class Button extends Widget {
const label = this.labelText;
label.text = truncate(this.fullText, label.style, this.labelSlot());
const content = this.iconSpace() + label.width;
const x = this.align === 'left' ? this.padding : (this.w - content) / 2;
const x = this.align === 'left' ? this.padding : (this.w - this.trailingRoom() - content) / 2;
label.position.set(Math.round(x + this.iconSpace()), centreY(this.h, label.height));
if (this.icon) {
const color = this.iconTint();
+24 -11
View File
@@ -4,7 +4,18 @@ import type { Size } from '../Widget';
import { Button, type ButtonOptions } from './Button';
/** Space reserved at the right edge for the chevron. */
const CHEVRON_SLOT = 16;
export const CHEVRON_SLOT = 16;
/** The downward triangle of a button that opens a menu, centred on the chevron slot of a `w` wide button. */
export function drawChevron(g: Graphics, w: number, h: number, color: number): void {
const x = w - CHEVRON_SLOT / 2 - Theme.padding / 2;
const y = h / 2;
g.clear()
.moveTo(x - 4, y - 2)
.lineTo(x + 4, y - 2)
.lineTo(x, y + 3)
.fill(color);
}
/**
* A button that opens a menu: value flush left, chevron flush right. The chevron is drawn rather
@@ -18,6 +29,10 @@ export class Dropdown extends Button {
this.addChild(this.chevron);
}
override get opensMenu(): boolean {
return true;
}
override measure(): Size {
const size = super.measure();
// A fixed width already includes the chevron's room; only a measured one has to grow.
@@ -25,19 +40,17 @@ export class Dropdown extends Button {
}
/** The chevron's room is not the label's: a long value is shortened, not drawn under it. */
protected override labelSlot(): number {
return super.labelSlot() - CHEVRON_SLOT;
protected override trailingRoom(): number {
return CHEVRON_SLOT;
}
override redraw(): void {
super.redraw();
const x = this.w - CHEVRON_SLOT / 2 - Theme.padding / 2;
const y = this.h / 2;
this.chevron
.clear()
.moveTo(x - 4, y - 2)
.lineTo(x + 4, y - 2)
.lineTo(x, y + 3)
.fill(this.onAccent ? Theme.colors.accentText : Theme.colors.textMuted);
drawChevron(
this.chevron,
this.w,
this.h,
this.onAccent ? Theme.colors.accentText : Theme.colors.textMuted,
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Graphics } from 'pixi.js';
import { drawSvg } from '../svg';
import { Theme } from '../Theme';
import { Widget } from '../Widget';
/** An SVG file on its own, centred in its box – where a row used to hold a symbol as text (#214). */
export class Icon extends Widget {
private readonly picture = new Graphics();
private drawn = false;
constructor(
private readonly svg: string,
width = 16,
private readonly color: number = Theme.colors.textMuted,
) {
super();
this.minWidth = width;
this.minHeight = 16;
this.eventMode = 'none';
this.addChild(this.picture);
}
override redraw(): void {
// On the first redraw, like a button's icon: the file is parsed there, not when the row is built.
if (!this.drawn) {
drawSvg(this.picture, this.svg, this.color);
this.drawn = true;
}
this.picture.position.set(Math.round(this.w / 2), Math.round(this.h / 2));
}
}
+15 -5
View File
@@ -17,6 +17,7 @@ export class ListRow extends Widget {
private readonly labelText = new Text({ text: '', style: textStyle('body') });
private readonly hint = new Text({ text: '', style: textStyle('small') });
private readonly icon = new Graphics();
private readonly marks: Graphics[] = [];
private selected = false;
private listFocused = false;
@@ -35,6 +36,12 @@ export class ListRow extends Widget {
drawSvg(this.icon, item.icon, Theme.colors.textMuted);
this.addChild(this.icon);
}
for (const svg of item.marks ?? []) {
const mark = new Graphics();
drawSvg(mark, svg, Theme.colors.textMuted);
this.marks.push(mark);
this.addChild(mark);
}
this.trackHover();
}
@@ -103,18 +110,21 @@ export class ListRow extends Widget {
this.icon.position.set(x + ICON_SIZE / 2, this.h / 2);
x += ICON_SIZE + Theme.gap;
}
let right = this.w - Theme.padding;
for (const mark of [...this.marks].reverse()) {
mark.position.set(right - ICON_SIZE / 2, this.h / 2);
right -= ICON_SIZE;
}
const marksW = this.w - Theme.padding - right;
this.hint.text = this.item.hint ?? '';
let hintW = this.hint.text ? this.hint.width + Theme.padding : 0;
let hintW = (this.hint.text ? this.hint.width + Theme.padding : 0) + marksW;
if (iconAtEnd) {
this.icon.position.set(this.w - Theme.padding - hintW - ICON_SIZE / 2, this.h / 2);
hintW += ICON_SIZE + Theme.padding;
}
this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding);
this.labelText.position.set(x, centreY(this.h, this.labelText.height));
this.hint.position.set(
Math.round(this.w - Theme.padding - this.hint.width),
centreY(this.h, this.hint.height),
);
this.hint.position.set(Math.round(right - this.hint.width), centreY(this.h, this.hint.height));
}
/** Drawn rather than a glyph, so it stays crisp at any size. */
+2
View File
@@ -20,6 +20,8 @@ export interface ListItem {
icon?: string;
/** Draws `icon` at the right edge, where a hint would stand, instead of in front of the label. */
iconAtEnd?: boolean;
/** Small pictures (SVG files) at the right edge, right of the hint – states of the row, like a scene's start mark. */
marks?: readonly string[];
/**
* A heading over the rows that follow: a press anywhere on it folds it, it is never selected,
* and ↑/↓ pass over it. Give it `expanded` for the chevron.
+24 -10
View File
@@ -1,4 +1,5 @@
import { VStack } from '../layout/Stack';
import { UI_ICON_SVG } from '../icons';
import { searchRank } from '../search';
import { Theme } from '../Theme';
import { ui } from '../UIContext';
@@ -19,13 +20,12 @@ export interface MenuItem {
icon?: string;
/** Draws a tick box in front of the label and asks again after every pick. */
checked?: () => boolean;
/** Marks the entry that is picked now with a dot – for a menu that chooses one of several. */
current?: boolean;
/** Picking this does not close the menu – for a set you tick several entries in. */
keepOpen?: true;
}
const TICKED = '☑';
const UNTICKED = '☐';
/** A hairline between two groups of entries; it is not selectable and matches no search. */
export interface MenuSeparator {
separator: true;
@@ -43,8 +43,8 @@ interface Row {
/** Null for a separator: it is decoration between groups, not an entry. */
button: Button | null;
label: string;
/** The text as it stands, tick box and all – asked again whenever a ticked entry was picked. */
text?: () => string;
/** The tick box as it stands – asked again whenever a ticked entry was picked. */
ticked?: () => boolean;
}
/** A vertical list of clickable actions. Picking one closes the menu before it runs. */
@@ -75,11 +75,10 @@ export class Menu extends Popup {
continue;
}
const { checked } = item;
const text = () => (checked ? `${checked() ? TICKED : UNTICKED} ${item.label}` : item.label);
const button = new Button(text(), {
const button = new Button(item.label, {
variant: item.danger ? 'danger' : 'ghost',
align: 'left',
icon: item.icon,
icon: checked ? tickIcon(checked()) : item.current ? UI_ICON_SVG.current : item.icon,
});
button.setEnabled(!item.disabled);
button.onClick = item.keepOpen
@@ -88,7 +87,7 @@ export class Menu extends Popup {
this.retick();
}
: this.closeThen(item.onSelect);
this.rows.push({ widget: button, button, label: item.label.toLowerCase(), text });
this.rows.push({ widget: button, button, label: item.label.toLowerCase(), ticked: checked });
}
this.scroll.setContent(this.list);
this.content.add(this.scroll);
@@ -97,7 +96,7 @@ export class Menu extends Popup {
/** Re-reads every tick box: one entry may switch off another, so all of them are asked. */
private retick(): void {
for (const row of this.rows) if (row.button && row.text) row.button.setText(row.text());
for (const row of this.rows) if (row.button && row.ticked) row.button.setIcon(tickIcon(row.ticked()));
}
override open(globalX: number, globalY: number): void {
@@ -126,6 +125,17 @@ export class Menu extends Popup {
.map((child) => (child instanceof Button ? child.enabled : null));
}
/** Per entry of `rowsDrawn`: its tick box, `null` for an entry without one or a separator (test hook). */
ticksDrawn(): (boolean | null)[] {
const byButton = new Map(this.rows.map((row) => [row.button, row.ticked]));
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => {
const ticked = child instanceof Button ? byButton.get(child) : undefined;
return ticked ? ticked() : null;
});
}
/** Per entry of `rowsDrawn`, whether it shows an icon; `null` for a separator (test hook). */
iconsDrawn(): (boolean | null)[] {
return this.list.children
@@ -174,6 +184,10 @@ export class Menu extends Popup {
}
}
function tickIcon(ticked: boolean): string {
return ticked ? UI_ICON_SVG.ticked : UI_ICON_SVG.unticked;
}
let current: Menu | null = null;
function setCurrent(menu: Menu): void {
+4 -6
View File
@@ -34,13 +34,11 @@ const TAGGED = FIELD_KINDS.filter(isTaggedKind);
const sampleOf = (kind: (typeof TAGGED)[number]) => (KIND[kind].travels === 'number' ? 0xff0000 : 'walk-up');
describe('the kind table', () => {
it('has a word and a mark for every kind a user can pick, and no two marks alike', () => {
// The marks are held with the pictures that stand in for most of them (tests/ui/svg-icons.test.ts).
it('has a word for every kind a user can pick', () => {
const picked = new Set([...VARIABLE_KINDS, ...FIELD_KINDS, ...DECLARED_PORT_KINDS]);
for (const kind of picked) {
expect(kindLabel(kind), kind).not.toBe(kind);
expect(kindMark(kind), kind).not.toBe('?');
}
expect(new Set([...picked].map(kindMark)).size).toBe(picked.size);
for (const kind of picked) expect(kindLabel(kind), kind).not.toBe(kind);
expect(kindMark('no such kind')).toBe('?');
});
it('derives the kind lists from the roles, the depth rules on top', () => {
+143
View File
@@ -0,0 +1,143 @@
/**
* The editor draws with the faces it ships (#202). A weight without a file falls back to the next
* one the browser has, and a character outside the Latin subset comes from the system font – in CI
* a different one than on the desk (#214). Both are held here against the files themselves.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { brotliDecompressSync } from 'node:zlib';
import { describe, expect, it } from 'vitest';
import { FACES } from '../../src/ui/fonts';
const FONT_DIR = 'src/ui/fonts';
/** Every `.ts` file under `dir`. */
function sources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return sources(path);
return entry.name.endsWith('.ts') ? [path] : [];
});
}
/** The code of a file without its comments – a character in a comment is never drawn. */
function codeLines(file: string): { line: number; code: string }[] {
return readFileSync(file, 'utf8')
.split('\n')
.map((text, index) => ({ line: index + 1, text: text.trim() }))
.filter(({ text }) => !text.startsWith('//') && !text.startsWith('*') && !text.startsWith('/*'))
.map(({ line, text }) => ({ line, code: text.replace(/\s\/\/.*$/, '') }));
}
/** Reads a WOFF2 variable-length number (UIntBase128). */
function base128(bytes: Uint8Array, at: { i: number }): number {
let value = 0;
for (let n = 0; n < 5; n++) {
const byte = bytes[at.i++]!;
value = value * 128 + (byte & 0x7f);
if ((byte & 0x80) === 0) return value;
}
throw new Error('UIntBase128 longer than five bytes');
}
/** The code points a WOFF2 file maps to a glyph, read from its `cmap` table (format 4 and 12). */
function codePoints(file: string): Set<number> {
const bytes = new Uint8Array(readFileSync(file));
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
expect(view.getUint32(0)).toBe(0x774f4632); // 'wOF2'
const numTables = view.getUint16(12);
const at = { i: 48 };
let cmap: { offset: number; length: number } | null = null;
let offset = 0;
for (let t = 0; t < numTables; t++) {
const flags = bytes[at.i++]!;
const tag = flags & 0x3f;
if (tag === 0x3f) at.i += 4;
const version = flags >> 6;
const origLength = base128(bytes, at);
// glyf (10) and loca (11) are transformed at version 0, every other table at any other version.
const transformed = tag === 10 || tag === 11 ? version === 0 : version !== 0;
const length = transformed ? base128(bytes, at) : origLength;
if (tag === 0) cmap = { offset, length };
offset += length;
}
const tables = brotliDecompressSync(bytes.subarray(at.i, at.i + view.getUint32(20)));
if (!cmap) throw new Error(`${file} has no cmap`);
const table = new DataView(tables.buffer, tables.byteOffset + cmap.offset, cmap.length);
const found = new Set<number>();
for (let s = 0; s < table.getUint16(2); s++) {
const sub = table.getUint32(4 + s * 8 + 4);
const format = table.getUint16(sub);
if (format === 12) {
for (let g = 0; g < table.getUint32(sub + 12); g++) {
const start = table.getUint32(sub + 16 + g * 12);
const end = table.getUint32(sub + 20 + g * 12);
for (let c = start; c <= end; c++) found.add(c);
}
} else if (format === 4) {
const segments = table.getUint16(sub + 6) / 2;
const ends = sub + 14;
const starts = ends + segments * 2 + 2;
const deltas = starts + segments * 2;
const ranges = deltas + segments * 2;
for (let g = 0; g < segments; g++) {
const start = table.getUint16(starts + g * 2);
const end = table.getUint16(ends + g * 2);
const delta = table.getUint16(deltas + g * 2);
const range = table.getUint16(ranges + g * 2);
for (let c = start; c <= end && c !== 0xffff; c++) {
const glyph =
range === 0 ? (c + delta) & 0xffff : table.getUint16(ranges + g * 2 + range + (c - start) * 2);
if (glyph !== 0) found.add(c);
}
}
}
}
return found;
}
describe('editor fonts', () => {
it('loads a face for every weight the editor sets (#214)', () => {
const loaded = new Set(
FACES.filter(([family]) => family === 'Source Sans 3').map(([, , weight]) => weight),
);
const used = new Set(['400']);
for (const file of sources('src'))
for (const [, setting] of readFileSync(file, 'utf8').matchAll(/fontWeight:([^,\n}]*)/g))
for (const [, weight] of setting!.matchAll(/'(\d{3}|bold|normal)'/g))
used.add(weight === 'bold' ? '700' : weight === 'normal' ? '400' : weight!);
expect(used.has('500')).toBe(true);
for (const weight of used) expect(loaded, `weight ${weight}`).toContain(weight);
});
it('ships a file for every face it loads', () => {
const files = readdirSync(FONT_DIR);
for (const [, url, weight] of FACES)
expect(
files.some((f) => url.endsWith(f)),
`${url} (${weight})`,
).toBe(true);
});
it('reads the Latin subset from the files themselves', () => {
const sans = codePoints(join(FONT_DIR, 'source-sans-3-latin-400-normal.woff2'));
expect(sans.has('A'.charCodeAt(0))).toBe(true);
expect(sans.has('é'.charCodeAt(0))).toBe(true);
expect(sans.has('—'.charCodeAt(0))).toBe(true);
expect(sans.has('▶'.charCodeAt(0))).toBe(false);
});
// A symbol the subset lacks is drawn from whatever the system has – so it is an SVG icon instead.
it('has every character the editor writes in each shipped face of its UI font (#214)', () => {
const faces = readdirSync(FONT_DIR).filter((f) => f.startsWith('source-sans-3-'));
const covered = faces.map((f) => codePoints(join(FONT_DIR, f)));
const missing: string[] = [];
for (const file of sources('src'))
for (const { line, code } of codeLines(file))
for (const char of new Set(code))
if (char.codePointAt(0)! > 0x7e && covered.some((set) => !set.has(char.codePointAt(0)!)))
missing.push(`${file}:${line} ${char}`);
expect(missing).toEqual([]);
});
});
+32
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { ASSET_TYPES } from '../../src/core/model/AssetTypes';
import { DECLARED_PORT_KINDS, FIELD_KINDS, kindMark, VARIABLE_KINDS } from '../../src/core/properties/kinds';
import {
ASSET_ICON_SVG,
GAME_ICON_SVG,
@@ -7,7 +8,9 @@ import {
PLUGINS_ICON_SVG,
} from '../../src/editor/panels/assetIcons';
import { HEADER_ICON_SVG } from '../../src/editor/panels/headerIcons';
import { ARROW_ICON_SVG, KIND_ICON_SVG, ROW_ACTION_ICON_SVG } from '../../src/editor/panels/markIcons';
import { TOOLBAR_ICON_SVG } from '../../src/editor/panels/toolbarIcons';
import { UI_ICON_SVG } from '../../src/ui/icons';
import { tintSvg } from '../../src/ui/svg';
import { SYMBOL_SVG } from '../../src/viewport/symbolIcons';
@@ -43,8 +46,37 @@ describe('icon files', () => {
['plugins tab', PLUGINS_ICON_SVG],
...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]),
...Object.entries(HEADER_ICON_SVG).map(([name, svg]) => [`header ${name}`, svg]),
...Object.entries(UI_ICON_SVG).map(([name, svg]) => [`ui ${name}`, svg]),
...Object.entries(KIND_ICON_SVG).map(([name, svg]) => [`kind ${name}`, svg]),
...Object.entries(ROW_ACTION_ICON_SVG).map(([name, svg]) => [`row action ${name}`, svg]),
...Object.entries(ARROW_ICON_SVG).map(([name, svg]) => [`arrow ${name}`, svg]),
];
// The symbols that were characters until #214: the font the editor ships has no such glyphs.
it('marks every kind a user can pick once – by a picture, or by letters where it has none (#214)', () => {
const picked = [...new Set([...VARIABLE_KINDS, ...FIELD_KINDS, ...DECLARED_PORT_KINDS])];
const marks = picked.map((kind) => {
const icon = KIND_ICON_SVG[kind];
const letters = kindMark(kind);
expect(Boolean(icon) !== Boolean(letters), `${kind}: a picture or letters, not both`).toBe(true);
return icon ?? letters;
});
expect(new Set(marks).size).toBe(picked.length);
});
// A test finds a picture by the id of its file (`locateIcon`), so every id names one file.
it('names each picture a test looks for after its own file (#214)', () => {
const named = icons.map(([, svg]) => tags(svg!)[0]!.attrs.id).filter((id) => id !== undefined);
expect(new Set(named).size).toBe(named.length);
for (const svg of [
...Object.values(UI_ICON_SVG),
...Object.values(KIND_ICON_SVG),
...Object.values(ROW_ACTION_ICON_SVG),
...Object.values(ARROW_ICON_SVG),
])
expect(tags(svg)[0]!.attrs.id).toBeDefined();
});
// The magnet stands alone in the header, without the "Snap" it replaced (#194): a picture another
// button already wears would say that button's thing.
it('draws the snap switch as a magnet of its own, bent without arcs (#194)', () => {
+18 -7
View File
@@ -1,3 +1,5 @@
import { colourDistance } from '../lib/harness.mjs';
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
export async function assets(t) {
const {
@@ -224,9 +226,18 @@ export async function assets(t) {
// so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's
// reset is the first on screen and colour's the last.
await select({ kind: 'asset', assetId: 'crate' });
const heightReset = await evaluate(`__gm.locate('↺')`);
const colorReset = await evaluate(`__gm.locate('↺', true)`);
const heightReset = await evaluate(`__gm.locateIcon('reset')`);
const colorReset = await evaluate(`__gm.locateIcon('reset', true)`);
check('asset inspector shows a reset per deviating property', Boolean(heightReset));
// The reset is a picture since #214, no longer a character from the system font: its circle
// passes 4.8 units right of its centre, drawn in the muted text colour.
const onResetRing = await json(`__gm.pixelAt(${heightReset.x + 4}, ${heightReset.y + 1})`);
const muted = await json("__gm.themeColor('textMuted')");
check(
'the reset button draws its picture',
colourDistance(onResetRing, muted) < 40,
JSON.stringify({ onResetRing, muted }),
);
check(
'the two deviations have two separate resets',
Boolean(colorReset) && colorReset.y > heightReset.y,
@@ -238,12 +249,12 @@ export async function assets(t) {
(await asset('crate')).props.height === 100,
`height=${(await asset('crate')).props.height}`,
);
const remaining = await evaluate(`__gm.locate('↺')`);
const remaining = await evaluate(`__gm.locateIcon('reset')`);
check(
'the reset of the property now at its default is gone, the other one stays',
Boolean(remaining) &&
remaining.y === colorReset.y &&
remaining.y === (await evaluate(`__gm.locate('↺', true)`)).y,
remaining.y === (await evaluate(`__gm.locateIcon('reset', true)`)).y,
JSON.stringify({ remaining, colorReset }),
);
await shot('15-asset-reset');
@@ -255,8 +266,8 @@ export async function assets(t) {
);
check(
'the height reset is back after undo',
(await evaluate(`__gm.locate('↺')`))?.y === heightReset.y,
JSON.stringify(await evaluate(`__gm.locate('↺')`)),
(await evaluate(`__gm.locateIcon('reset')`))?.y === heightReset.y,
JSON.stringify(await evaluate(`__gm.locateIcon('reset')`)),
);
// 4f. The same gold in the asset list: a row is marked while the asset carries anything the
@@ -279,7 +290,7 @@ export async function assets(t) {
(await assetColor('crate')) === GOLD,
await assetColor('crate'),
);
const colorOnly = await evaluate(`__gm.locate('↺')`);
const colorOnly = await evaluate(`__gm.locateIcon('reset')`);
await click(colorOnly.x, colorOnly.y);
check(
'resetting the last deviation clears the mark, without a reload',
+11 -5
View File
@@ -189,17 +189,23 @@ export async function colliders(t) {
const collidesWith = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Collides with')");
await click(collidesWith.x, collidesWith.y);
await shot('22c-collides-with');
const ticks = await json('__gm.menuRows()');
const ticks = await t.menuTicked();
const both = (a, b) => [
{ label: 'Default', ticked: a },
{ label: 'Shots', ticked: b },
];
check(
'"Collides with" lists every layer of the project, all ticked',
JSON.stringify(ticks) === JSON.stringify(['☑ Default', '☑ Shots']),
JSON.stringify(ticks) === JSON.stringify(both(true, true)),
JSON.stringify(ticks),
);
await clickText('☑ Default');
const unticked = await json('__gm.menuRows()');
// The last "Default" on screen: the Layer row above says it too, the menu is drawn on top.
const defaultEntry = await t.locate('Default', true);
await click(defaultEntry.x, defaultEntry.y);
const unticked = await t.menuTicked();
check(
'picking one unticks it without closing the menu',
JSON.stringify(unticked) === JSON.stringify(['☐ Default', '☑ Shots']),
JSON.stringify(unticked) === JSON.stringify(both(false, true)),
JSON.stringify(unticked),
);
await press('Escape');
+17 -10
View File
@@ -36,6 +36,7 @@ export async function containers(t) {
sceneOf,
navigate,
projectId,
findIcon,
} = t;
const center = await json('__gm.viewportCenter()');
/** Types a value into an inspector field, which runs the real command behind that row. */
@@ -478,14 +479,20 @@ export async function containers(t) {
await spawnParam('crate');
// 9m. Apply / revert on resolved content. The red crate of instance B was moved on x, the one
// of instance A still matches the template; ↑ writes the value into the template and A
// follows, ↺ drops the deviation so B follows again.
// of instance A still matches the template; apply writes the value into the template and A
// follows, reset drops the deviation so B follows again.
const templateRed = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === redAPath);
await selectObject(redA);
check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺')));
check(
'no apply/revert while the instance matches its template',
!(await findIcon('apply', false)) && !(await findIcon('reset', false)),
);
await selectObject(redB);
const applyButton = await evaluate(`__gm.locate('↑')`);
check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && (await has('↺')));
const applyButton = await findIcon('apply', false);
check(
'a moved instance shows apply and revert on its X row',
Boolean(applyButton) && (await findIcon('reset', false)),
);
await shot('27-apply-buttons');
await click(applyButton.x, applyButton.y);
check(
@@ -493,13 +500,13 @@ export async function containers(t) {
(await templateRed()).position.x === -100 && (await object(redA)).position.x === -100,
JSON.stringify({ template: (await templateRed()).position, a: (await object(redA)).position }),
);
check('apply hides the buttons again', !(await has('↑')));
check('apply hides the buttons again', !(await findIcon('apply', false)));
await undo();
check(
'undo restores template and instances',
(await templateRed()).position.x === -135 && (await object(redA)).position.x === -135,
);
const revertButton = await evaluate(`__gm.locate('↺')`);
const revertButton = await findIcon('reset', false);
await click(revertButton.x, revertButton.y);
const revertedB = (await storedObjects()).find((o) => o.id === instanceB.id);
check(
@@ -521,11 +528,11 @@ export async function containers(t) {
(await object(redA)).overrides.width === undefined,
JSON.stringify(editedWidth.instanceEdits),
);
const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
const applyMenu = await findIcon('apply', true);
check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
check(
'reset stays a single button – inside a container it only ever goes to the template',
!(await has('↺ ▾')),
!(await findIcon('reset', true)),
);
await click(applyMenu.x, applyMenu.y);
check(
@@ -557,7 +564,7 @@ export async function containers(t) {
await sleep(200);
check(
'with the template carrying the value there is nothing left to reset, only an apply upwards',
(await has('↑')) && !(await has('↑ ▾')) && !(await has('↺')),
(await findIcon('apply', false)) && !(await findIcon('apply', true)) && !(await findIcon('reset', false)),
);
await shot('28b-single-apply');
const revertedEdits = (await storedObjects()).find((o) => o.id === instanceB.id);
+10
View File
@@ -243,6 +243,16 @@ export async function overflow(t) {
onVariables.length === 0,
JSON.stringify(onVariables),
);
// The weights the theme draws with (#214): a missing face falls back to the next one the browser
// has, and 500 then draws as 400 – no check on widths notices, the text just looks lighter.
const faces = await json(
'[...document.fonts].filter((f) => f.family.replaceAll(\'"\', "") === "Source Sans 3" && f.status === "loaded").map((f) => f.weight).sort()',
);
check(
'the editor font is loaded in every weight the theme uses, 500 included',
['400', '500', '600'].every((weight) => faces.includes(weight)),
JSON.stringify(faces),
);
for (const doc of (await json('__gm.documents()')).docs)
await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
}
+28 -24
View File
@@ -1,5 +1,5 @@
/**
* The ↔ / ↕ buttons beside a sprite's Width and Height (absent without a picture). Runs on the imported
* The fit buttons (sideways and upright arrow) beside a sprite's Width and Height (absent without a picture). Runs on the imported
* 64 × 48 sheet plus a frame of a different shape, so "sheet or frame?" shows in the numbers.
*/
@@ -9,15 +9,16 @@ const near = (value, expected) => Math.abs(value - expected) < 0.01;
const spriteProp = (key) => `__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.${key}`;
export async function aspect(t) {
const { check, sleep, json, evaluate, shot, clickText, press, undo, mutate, select } = t;
const { check, sleep, json, evaluate, shot, clickText, clickIcon, findIcon, press, undo, mutate, select } =
t;
const WIDTH = 'fit-width';
const HEIGHT = 'fit-height';
/** Whether the size button of a row is there at all, in either its plain or its menu form. */
const sizeButton = async (glyph) =>
(await json(`__gm.locate('${glyph}')`))
? 'plain'
: (await json(`__gm.locate('${glyph} ▾')`))
? 'menu'
: '';
const sizeButton = async (icon) => {
const hit = await findIcon(icon);
return !hit ? '' : hit.menu ? 'menu' : 'plain';
};
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
@@ -35,7 +36,10 @@ export async function aspect(t) {
await select({ kind: 'asset', assetId: 'tex-sprite' });
await sleep(600);
check('the sprite asset is in the inspector', Boolean(await json("__gm.locate('Width')")));
check('no texture, no aspect buttons', (await sizeButton('↔')) === '' && (await sizeButton('↕')) === '');
check(
'no texture, no aspect buttons',
(await sizeButton(WIDTH)) === '' && (await sizeButton(HEIGHT)) === '',
);
await shot('40-aspect-without-a-texture');
// 15b. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size,
@@ -44,23 +48,23 @@ export async function aspect(t) {
await sleep(500);
check(
'a texture brings both buttons out, offering fit and pixel size',
(await sizeButton('↔')) === 'menu' && (await sizeButton('↕')) === 'menu',
(await sizeButton(WIDTH)) === 'menu' && (await sizeButton(HEIGHT)) === 'menu',
);
await shot('41-aspect-buttons');
// 15c. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
await clickText('↔ ▾');
await clickIcon(WIDTH, true);
await clickText('Fit to 64 × 48');
await sleep(500);
const fitted = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check(
'↔ sets the width from the height and the sheet ratio',
'the width button sets the width from the height and the sheet ratio',
near(fitted.w, (100 * 64) / 48) && fitted.h === 100,
JSON.stringify(fitted),
);
check(
'and the fit drops out of the menu once the sprite is proportional',
(await sizeButton('↔')) === 'plain' && (await sizeButton('↕')) === 'plain',
(await sizeButton(WIDTH)) === 'plain' && (await sizeButton(HEIGHT)) === 'plain',
);
await shot('42-aspect-fitted');
@@ -68,13 +72,13 @@ export async function aspect(t) {
await undo();
await sleep(500);
check('undo takes the width back', (await json(spriteProp('width'))) === 100);
check('and the buttons return with it', (await sizeButton('↔')) === 'menu');
check('and the buttons return with it', (await sizeButton(WIDTH)) === 'menu');
await t.redo();
await sleep(500);
// 15e. What is left over is the pixel size, now a direct button: it writes *both* sides, and
// the whole button goes because there is nothing left to offer.
await clickText('↔');
await clickIcon(WIDTH, false);
await sleep(500);
const pixels = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check(
@@ -84,7 +88,7 @@ export async function aspect(t) {
);
check(
'and nothing is left to offer, so no button is drawn',
(await sizeButton('↔')) === '' && (await sizeButton('↕')) === '',
(await sizeButton(WIDTH)) === '' && (await sizeButton(HEIGHT)) === '',
);
await undo();
await sleep(500);
@@ -102,30 +106,30 @@ export async function aspect(t) {
asset.frames.push({ id: 'aspect-frame', name: 'Wide', x: 0, y: 0, w: 40, h: 10 });
${spriteProp('texture')} = 'aspect-frame'`);
await sleep(500);
check('a frame of another shape brings the buttons back', (await sizeButton('↕')) === 'menu');
check('a frame of another shape brings the buttons back', (await sizeButton(HEIGHT)) === 'menu');
const before = await json(spriteProp('width'));
await clickText('↕ ▾');
await clickIcon(HEIGHT, true);
await clickText('Fit to 40 × 10');
await sleep(500);
const framed = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check(
'↕ sets the height from the frame ratio, not the sheet it belongs to',
'the height button sets the height from the frame ratio, not the sheet it belongs to',
near(framed.h, (before * 10) / 40) && framed.w === before,
JSON.stringify({ framed, sheetWouldGive: (before * 48) / 64 }),
);
check('and only the pixel size is left to offer', (await sizeButton('↕')) === 'plain');
check('and only the pixel size is left to offer', (await sizeButton(HEIGHT)) === 'plain');
// 15g. The same rows on a placed object write an override rather than the asset value.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await select({ kind: 'object', holderId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
await sleep(600);
await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90");
check('the object row offers the fit as well', (await sizeButton('↔')) === 'menu');
// The crowded case: an overridden, out-of-proportion row carries ↔ ▾ next to ↑ and ↺.
check('the object row offers the fit as well', (await sizeButton(WIDTH)) === 'menu');
// The crowded case: an overridden, out-of-proportion row carries the width menu next to apply and reset.
check('and shows it beside the override buttons', (await json('__gm.textOverflows()')).length === 0);
await shot('43-aspect-beside-the-override-buttons');
await clickText('↔ ▾');
await clickIcon(WIDTH, true);
await clickText('Fit to 64 × 48');
await sleep(500);
const override = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
@@ -137,7 +141,7 @@ export async function aspect(t) {
await shot('44-aspect-on-an-object');
// 15h. The pixel size on an object writes both overrides, and as one undo step.
await clickText('↔');
await clickIcon(WIDTH, false);
await sleep(500);
const sized = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
check(
+16 -2
View File
@@ -3,7 +3,21 @@
* same row, and writing one field keeps the others – the trap a shared snapshot would fall into.
*/
export async function structVariable(t) {
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, select, undo } = t;
const {
check,
sleep,
json,
find,
findIcon,
shot,
click,
clickText,
press,
typeText,
mutate,
select,
undo,
} = t;
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
@@ -173,7 +187,7 @@ export async function structVariable(t) {
// 22h. It reads exactly like the asset does: a head carrying the struct mark, then one row per
// field with its kind mark, its name and its value. What is missing is the shape editing –
// a placed object overrides values, the declaration stays with the asset that owns it.
const bundleMark = await find('⊞');
const bundleMark = await findIcon('kind-struct');
const headName = await find('piece');
const fieldMark = await find('#');
const fieldName = await find('column');
+13 -5
View File
@@ -170,13 +170,21 @@ export async function loadScene(t) {
(await json('__gm.state.project.startSceneId')) === 'ls-scene' && (await playFrom()).scene === 'Level 2',
JSON.stringify(await playFrom()),
);
// Under the Scenes heading the row no longer repeats its type – the marks are all it says.
const marksOn = async (id) => {
const row = await json(`__gm.assetRow(${JSON.stringify(id)})`);
const on = async (name) =>
((await json(`__gm.locateIcons(${JSON.stringify(name)})`)) ?? []).some(
(hit) => row && Math.abs(hit.y - row.y) < 8,
);
return { start: await on('play'), current: await on('current') };
};
const startMarks = await marksOn('ls-scene');
const activeMarks = await marksOn(await json('__gm.state.project.activeSceneId'));
check(
'the assets panel marks the start scene apart from the active one',
// Under the Scenes heading the row no longer repeats its type – the marks are all it says.
await has('▶'),
JSON.stringify(
await json('__gm.state.project.assets.filter((a) => a.type === "scene").map((a) => a.name)'),
),
startMarks.start && !startMarks.current && activeMarks.current && !activeMarks.start,
JSON.stringify({ startMarks, activeMarks }),
);
await shot('35-start-scene');
+4 -3
View File
@@ -364,14 +364,15 @@ export async function queries(t) {
JSON.stringify({ layersRow, allLayers: await t.has('All layers') }),
);
await click(layersRow.x, layersRow.y);
const layerTicks = await json('__gm.menuRows()');
const layerTicks = await t.menuTicked();
const names = await json('__gm.state.project.collisionLayers');
check(
'the setting lists the layers of the project, all of them ticked',
layerTicks.length === names.length && layerTicks.every((row) => row.startsWith('☑')),
layerTicks.length === names.length && layerTicks.every((row) => row.ticked === true),
JSON.stringify({ layerTicks, names }),
);
await t.clickText(layerTicks[1]);
const secondLayer = await t.locate(layerTicks[1].label, true);
await click(secondLayer.x, secondLayer.y);
await shot('43-ray-layers');
await t.press('Escape');
await settle();
+5 -7
View File
@@ -583,15 +583,14 @@ export async function vectorArt(t) {
JSON.stringify(picked),
);
// The menu ticks its entries in the text itself, so a row reads "☐ Corner" or "☑ Corner".
const openPointMenu = async () => {
const button = await pointType();
await click(button.x, button.y);
return (await json('__gm.menuRows()')) ?? [];
return t.menuTicked();
};
const choose = async (label) => {
const rows = await openPointMenu();
const text = rows.find((row) => row.endsWith(` ${label}`));
const text = rows.find((row) => row.label === label)?.label;
const row = text && (await inOverlay(text));
if (!row) throw new Error(`no "${label}" in the point type menu: ${JSON.stringify(rows)}`);
await click(row.x, row.y);
@@ -601,7 +600,8 @@ export async function vectorArt(t) {
const offered = await openPointMenu();
check(
'the point type menu offers the four types and ticks the one the point is',
offered.join('|') === '☐ Straight|☐ Corner|☐ Smooth|☑ Symmetric',
offered.map((row) => `${row.ticked ? '+' : '-'}${row.label}`).join('|') ===
'-Straight|-Corner|-Smooth|+Symmetric',
JSON.stringify(offered),
);
await press('Escape');
@@ -1033,9 +1033,7 @@ export async function vectorArt(t) {
const handles = await json('__gm.vectorArt.paintTarget()');
await click(handles.x, handles.y);
await settle();
const outlineRow = ((await json('__gm.menuRows()')) ?? []).find((rowPaint) =>
rowPaint.endsWith(' Outline'),
);
const outlineRow = ((await json('__gm.menuRows()')) ?? []).find((rowPaint) => rowPaint === 'Outline');
const outlineEntry = outlineRow && (await inOverlay(outlineRow));
if (outlineEntry) await click(outlineEntry.x, outlineEntry.y);
await settle();
+23
View File
@@ -186,6 +186,16 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
const find = async (text, last = false) =>
(await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`)) || null;
const has = async (text) => Boolean(await find(text));
/**
* A picture by the id of its SVG file (#214) – `{ x, y, menu }` or null. `menu` picks the form:
* true only a button that opens a menu, false only one that acts, left out either.
*/
const findIcon = async (name, menu) =>
(await evaluate(
`window.__gm && (__gm.locateIcons(${JSON.stringify(name)}).find((hit) => ${
menu === undefined ? 'true' : `hit.menu === ${menu}`
}) ?? null)`,
)) || null;
const locate = async (text, last = false) => {
const p = await find(text, last);
if (!p) throw new Error(`widget with text "${text}" not found`);
@@ -239,6 +249,16 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
const p = await locate(text);
await click(p.x, p.y, button);
};
/** The open menu's entries with their tick box, `ticked` null where an entry has none (#214). */
const menuTicked = async () =>
(await json(
'(__gm.menuRows() ?? []).map((label, i) => label === null ? null : { label, ticked: __gm.menuTicks()[i] })',
)) ?? [];
const clickIcon = async (name, menu) => {
const p = await findIcon(name, menu);
if (!p) throw new Error(`picture "${name}" not found`);
await click(p.x, p.y);
};
/** CDP reports held buttons as a bitmask, which is not the button name it takes elsewhere. */
const BUTTON_MASK = { left: 1, right: 2, middle: 4 };
/** The fifth argument is a step count or `{ steps, button, modifiers }`. */
@@ -457,12 +477,15 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
waitUntil,
find,
has,
findIcon,
locate,
shot,
mouse,
click,
doubleClick,
clickText,
clickIcon,
menuTicked,
clickToolbar,
wheel,
drag,