diff --git a/README.md b/README.md index c79c0cf7..6ab7f662 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 897bf453..dc2a85c7 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -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. */ diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts index f03ddfbd..11a76b4c 100644 --- a/src/editor/ProjectSettingsDialog.ts +++ b/src/editor/ProjectSettingsDialog.ts @@ -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]); diff --git a/src/editor/animation/AnimationEditor.ts b/src/editor/animation/AnimationEditor.ts index 52f9054d..bfb744ff 100644 --- a/src/editor/animation/AnimationEditor.ts +++ b/src/editor/animation/AnimationEditor.ts @@ -123,7 +123,8 @@ export class AnimationEditor extends AssetTabEditor 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 }); diff --git a/src/editor/animation/RowDialog.ts b/src/editor/animation/RowDialog.ts index daa12b16..3b4215d2 100644 --- a/src/editor/animation/RowDialog.ts +++ b/src/editor/animation/RowDialog.ts @@ -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(); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 4c5795bb..d95906ad 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/src/editor/inspector/aspectAction.ts b/src/editor/inspector/aspectAction.ts index 87b12d22..72e1555e 100644 --- a/src/editor/inspector/aspectAction.ts +++ b/src/editor/inspector/aspectAction.ts @@ -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 = { 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 = { + 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>; @@ -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); diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index cb9201d2..a95cd85a 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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), })), ]; diff --git a/src/editor/inspector/parentRow.ts b/src/editor/inspector/parentRow.ts index 0a27bd09..9b5937f9 100644 --- a/src/editor/inspector/parentRow.ts +++ b/src/editor/inspector/parentRow.ts @@ -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)), }); } diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 8ca73b8b..3bf93395 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -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()); diff --git a/src/editor/inspector/transitionSection.ts b/src/editor/inspector/transitionSection.ts index 71e9190f..7cba39eb 100644 --- a/src/editor/inspector/transitionSection.ts +++ b/src/editor/inspector/transitionSection.ts @@ -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( diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index 2dd0fe25..4fcf605a 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -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; } diff --git a/src/editor/list/ListEditor.ts b/src/editor/list/ListEditor.ts index 256f6d3c..c5562aea 100644 --- a/src/editor/list/ListEditor.ts +++ b/src/editor/list/ListEditor.ts @@ -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) => diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 3d3021b4..7f81cf73 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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), }); diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index bb8b67fe..a85cc76a 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -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), diff --git a/src/editor/panels/icons/apply.svg b/src/editor/panels/icons/apply.svg new file mode 100644 index 00000000..89c449a9 --- /dev/null +++ b/src/editor/panels/icons/apply.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/arrow-in.svg b/src/editor/panels/icons/arrow-in.svg new file mode 100644 index 00000000..58d549d6 --- /dev/null +++ b/src/editor/panels/icons/arrow-in.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/arrow-out.svg b/src/editor/panels/icons/arrow-out.svg new file mode 100644 index 00000000..c7205fd9 --- /dev/null +++ b/src/editor/panels/icons/arrow-out.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/fit-height.svg b/src/editor/panels/icons/fit-height.svg new file mode 100644 index 00000000..b8a94a1d --- /dev/null +++ b/src/editor/panels/icons/fit-height.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/fit-width.svg b/src/editor/panels/icons/fit-width.svg new file mode 100644 index 00000000..143cd762 --- /dev/null +++ b/src/editor/panels/icons/fit-width.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/kind-animation.svg b/src/editor/panels/icons/kind-animation.svg new file mode 100644 index 00000000..b8df9f77 --- /dev/null +++ b/src/editor/panels/icons/kind-animation.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/icons/kind-boolean.svg b/src/editor/panels/icons/kind-boolean.svg new file mode 100644 index 00000000..fef04804 --- /dev/null +++ b/src/editor/panels/icons/kind-boolean.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/icons/kind-color.svg b/src/editor/panels/icons/kind-color.svg new file mode 100644 index 00000000..2cff4b3a --- /dev/null +++ b/src/editor/panels/icons/kind-color.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/kind-list.svg b/src/editor/panels/icons/kind-list.svg new file mode 100644 index 00000000..5c79bff1 --- /dev/null +++ b/src/editor/panels/icons/kind-list.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/kind-object.svg b/src/editor/panels/icons/kind-object.svg new file mode 100644 index 00000000..69119f1a --- /dev/null +++ b/src/editor/panels/icons/kind-object.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/icons/kind-scene.svg b/src/editor/panels/icons/kind-scene.svg new file mode 100644 index 00000000..b58ddacb --- /dev/null +++ b/src/editor/panels/icons/kind-scene.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/kind-sound.svg b/src/editor/panels/icons/kind-sound.svg new file mode 100644 index 00000000..c7a04f83 --- /dev/null +++ b/src/editor/panels/icons/kind-sound.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/kind-statemachine.svg b/src/editor/panels/icons/kind-statemachine.svg new file mode 100644 index 00000000..031190ff --- /dev/null +++ b/src/editor/panels/icons/kind-statemachine.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/kind-struct.svg b/src/editor/panels/icons/kind-struct.svg new file mode 100644 index 00000000..3d706837 --- /dev/null +++ b/src/editor/panels/icons/kind-struct.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/kind-texture.svg b/src/editor/panels/icons/kind-texture.svg new file mode 100644 index 00000000..f9ced150 --- /dev/null +++ b/src/editor/panels/icons/kind-texture.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/editor/panels/icons/play.svg b/src/editor/panels/icons/play.svg index 36d1b94e..e56e4e80 100644 --- a/src/editor/panels/icons/play.svg +++ b/src/editor/panels/icons/play.svg @@ -1,3 +1,3 @@ - + diff --git a/src/editor/panels/icons/reset.svg b/src/editor/panels/icons/reset.svg new file mode 100644 index 00000000..c452ae8e --- /dev/null +++ b/src/editor/panels/icons/reset.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/stop.svg b/src/editor/panels/icons/stop.svg index a24e2434..bf3cf9ed 100644 --- a/src/editor/panels/icons/stop.svg +++ b/src/editor/panels/icons/stop.svg @@ -1,3 +1,3 @@ - + diff --git a/src/editor/panels/markIcons.ts b/src/editor/panels/markIcons.ts new file mode 100644 index 00000000..07623b75 --- /dev/null +++ b/src/editor/panels/markIcons.ts @@ -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> = { + 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 }; diff --git a/src/editor/sound/SoundPicker.ts b/src/editor/sound/SoundPicker.ts index 943efdf1..b2f6ad63 100644 --- a/src/editor/sound/SoundPicker.ts +++ b/src/editor/sound/SoundPicker.ts @@ -1,6 +1,6 @@ import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; import 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; 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 { 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 { 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 { 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'); diff --git a/src/editor/sound/SoundPreview.ts b/src/editor/sound/SoundPreview.ts index c99ee1e4..82a9abe5 100644 --- a/src/editor/sound/SoundPreview.ts +++ b/src/editor/sound/SoundPreview.ts @@ -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' }); diff --git a/src/editor/textProbe.ts b/src/editor/textProbe.ts index 6f05d033..6ec1c87f 100644 --- a/src/editor/textProbe.ts +++ b/src/editor/textProbe.ts @@ -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,18 +71,37 @@ 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) { - // 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; - } + 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 (!clip || clip.contains(point.x, point.y)) visit(node, point); + return true; + }); + }; + /** + * Every picture drawn from the file with this id (`icon:`, 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 }[] => { const hits: { 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 }; } diff --git a/src/editor/tile/TileEditor.ts b/src/editor/tile/TileEditor.ts index b5f363e8..7dcbacd1 100644 --- a/src/editor/tile/TileEditor.ts +++ b/src/editor/tile/TileEditor.ts @@ -80,7 +80,8 @@ export class TileEditor extends AssetTabEditor { 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); diff --git a/src/ui/fonts.ts b/src/ui/fonts.ts index 886222a7..c8bb7463 100644 --- a/src/ui/fonts.ts +++ b/src/ui/fonts.ts @@ -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'], diff --git a/src/ui/icons.ts b/src/ui/icons.ts new file mode 100644 index 00000000..747c454c --- /dev/null +++ b/src/ui/icons.ts @@ -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 }; diff --git a/src/ui/icons/current.svg b/src/ui/icons/current.svg new file mode 100644 index 00000000..c3651d82 --- /dev/null +++ b/src/ui/icons/current.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/icons/ticked.svg b/src/ui/icons/ticked.svg new file mode 100644 index 00000000..8fcfff70 --- /dev/null +++ b/src/ui/icons/ticked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/icons/unticked.svg b/src/ui/icons/unticked.svg new file mode 100644 index 00000000..6cbb70de --- /dev/null +++ b/src/ui/icons/unticked.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/index.ts b/src/ui/index.ts index 6cd9ebfa..0436d30e 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -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'; diff --git a/src/ui/svg.ts b/src/ui/svg.ts index a0ca7216..c5f9e6ca 100644 --- a/src/ui/svg.ts +++ b/src/ui/svg.ts @@ -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>(); -/** 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:`), 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())); @@ -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); } diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index d2faa6d9..e70e7d86 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -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(); diff --git a/src/ui/widgets/Dropdown.ts b/src/ui/widgets/Dropdown.ts index 3f5fbdef..16f4799d 100644 --- a/src/ui/widgets/Dropdown.ts +++ b/src/ui/widgets/Dropdown.ts @@ -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, + ); } } diff --git a/src/ui/widgets/Icon.ts b/src/ui/widgets/Icon.ts new file mode 100644 index 00000000..157d6d8a --- /dev/null +++ b/src/ui/widgets/Icon.ts @@ -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)); + } +} diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts index f7017206..4612275a 100644 --- a/src/ui/widgets/ListRow.ts +++ b/src/ui/widgets/ListRow.ts @@ -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. */ diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index fead52c6..889b2561 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -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. diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts index 4c8a6c69..1aed0266 100644 --- a/src/ui/widgets/Menu.ts +++ b/src/ui/widgets/Menu.ts @@ -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 { diff --git a/tests/core/kinds.test.ts b/tests/core/kinds.test.ts index 5f5381d4..14d309f4 100644 --- a/tests/core/kinds.test.ts +++ b/tests/core/kinds.test.ts @@ -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', () => { diff --git a/tests/ui/fonts.test.ts b/tests/ui/fonts.test.ts new file mode 100644 index 00000000..d05eede3 --- /dev/null +++ b/tests/ui/fonts.test.ts @@ -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 { + 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(); + 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([]); + }); +}); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index 9c632b1e..1be7c1ef 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -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)', () => { diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index e1ecdf4f..08947406 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -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', diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs index 245e8723..4cabe8b7 100644 --- a/tools/e2e/08-colliders.mjs +++ b/tools/e2e/08-colliders.mjs @@ -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'); diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs index 614cd5bb..6a1f143d 100644 --- a/tools/e2e/09-containers.mjs +++ b/tools/e2e/09-containers.mjs @@ -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); diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index a51e3b90..0b1a919e 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -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)})`); } diff --git a/tools/e2e/15-aspect.mjs b/tools/e2e/15-aspect.mjs index 0bdaaa2a..471fe17e 100644 --- a/tools/e2e/15-aspect.mjs +++ b/tools/e2e/15-aspect.mjs @@ -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( diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs index 639cb205..61375bb9 100644 --- a/tools/e2e/22-struct.mjs +++ b/tools/e2e/22-struct.mjs @@ -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'); diff --git a/tools/e2e/35-load-scene.mjs b/tools/e2e/35-load-scene.mjs index afbc82f9..35472689 100644 --- a/tools/e2e/35-load-scene.mjs +++ b/tools/e2e/35-load-scene.mjs @@ -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'); diff --git a/tools/e2e/43-queries.mjs b/tools/e2e/43-queries.mjs index 13574922..325718a7 100644 --- a/tools/e2e/43-queries.mjs +++ b/tools/e2e/43-queries.mjs @@ -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(); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index a18ea9b3..0680d810 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -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(); diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 06e837a0..2acadb89 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -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,