diff --git a/README.md b/README.md index 54659dd4..3e55b857 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, 1184 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1188 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/editor/debugHook.ts b/src/editor/debugHook.ts index 9442ec54..68b4a0c7 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -33,6 +33,7 @@ import { currentSoundPicker } from './sound/SoundPicker'; import { currentImagePicker } from './texture/ImagePicker'; import { currentTilemapWidgets } from './panels/TilemapPanel'; import { currentShapeList } from './inspector/shapeSection'; +import type { SnapToggle } from './graph/SnapToggle'; import type { EditorState, TilemapTool } from './state'; import { rectOf, textProbe } from './textProbe'; @@ -56,6 +57,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const p = widget.getGlobalPosition(); return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; }; + /** A tab's magnet (#194): where to click it, how big it is drawn, and whether it is on. */ + const snapButton = (snap: SnapToggle) => ({ + ...centreOf(snap.button), + w: snap.button.w, + h: snap.button.h, + toggled: snap.button.toggled, + }); const { eachText, locate, locateAll } = 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 => { @@ -338,6 +346,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E // drawing area. center: () => centreOf(graphEditor.canvas), snapping: () => graphEditor.canvas.snapEnabled, + snapButton: () => snapButton(graphEditor.snap), zoom: () => graphEditor.canvas.zoomLevel, crumbs: () => graphEditor.breadcrumb.crumbsDrawn(), }, @@ -349,6 +358,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(), center: () => centreOf(editor.stateMachineEditor.canvas), snapping: () => editor.stateMachineEditor.canvas.snapEnabled, + snapButton: () => snapButton(editor.stateMachineEditor.snap), /** Where a state's hidden pin sits; it cannot be grabbed – `portGrabbable` is the check. */ portAt: (nodeId: string, port: string, isOutput: boolean) => editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput), @@ -372,6 +382,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E particles: { current: () => editor.particlesEditor.currentAssetId, info: () => editor.particlesEditor.shownInfo, + snapButton: () => snapButton(editor.particlesEditor.snap), nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId), nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId), nodeRect: (nodeId: string) => editor.particlesEditor.canvas.nodeRect(nodeId), @@ -405,6 +416,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E emptySpot: () => editor.shaderEditor.canvas.emptySpotGlobal(), center: () => centreOf(editor.shaderEditor.canvas), snapping: () => editor.shaderEditor.canvas.snapEnabled, + snapButton: () => snapButton(editor.shaderEditor.snap), }, /** The console panel: its lines as kept, its drawn height, and the divider above it. */ console: { @@ -456,6 +468,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E rect: () => vectorArtEditor.canvasRect(), tool: () => vectorArtEditor.canvas.currentTool, snapping: () => vectorArtEditor.canvas.snapping, + snapButton: () => snapButton(vectorArtEditor.snap), /** Whether the pen has a line open that the next click would add a point to. */ drawing: () => vectorArtEditor.canvas.drawing, /** The shapes as the model holds them, so a check can read points and handles exactly. */ diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 5a1fb4b6..8943fb70 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -11,7 +11,7 @@ import { backTo, descend, follow, type Trail } from './trail'; const INFO_WIDTH = 150; export class GraphEditor extends AssetTabEditor { - private readonly snap = new SnapToggle(() => this.canvas); + readonly snap = new SnapToggle(() => this.canvas); readonly breadcrumb = new Breadcrumb(); private trail: Trail = []; /** A call followed into another behavior: the tab goes on to that document (#171). */ diff --git a/src/editor/graph/SnapToggle.ts b/src/editor/graph/SnapToggle.ts index d8df8837..cb3abfc4 100644 --- a/src/editor/graph/SnapToggle.ts +++ b/src/editor/graph/SnapToggle.ts @@ -1,17 +1,40 @@ -import { Checkbox, Label, type Widget } from '../../ui'; -import type { GraphCanvas } from './GraphCanvas'; +import { Button, Theme } from '../../ui'; +import { HEADER_ICON_SVG } from '../panels/headerIcons'; -/** The "Snap" checkbox every tab on the graph canvas carries in its header. */ +/** Whatever a tab snaps: the graph canvas, the vector art canvas. */ +export interface Snappable { + setSnapping(on: boolean): void; +} + +/** The magnet every tab that snaps carries in its header (#194): an icon toggle, off at first. */ export class SnapToggle { - private readonly box = new Checkbox(); - readonly widgets: readonly Widget[]; + readonly button: Button; + private on = false; - constructor(canvas: () => GraphCanvas) { - this.box.onChange = (on) => canvas().setSnapping(on); - this.widgets = [new Label('Snap', { style: 'muted' }), this.box]; + constructor(private readonly target: () => Snappable) { + this.button = new Button('', { + width: Theme.controlHeight, + icon: HEADER_ICON_SVG.snap, + toggle: true, + onClick: () => this.set(!this.on), + }); + } + + get widgets(): readonly Button[] { + return [this.button]; + } + + get snapping(): boolean { + return this.on; + } + + set(on: boolean): void { + this.on = on; + this.target().setSnapping(on); + this.button.setToggled(on); } setEnabled(on: boolean): void { - this.box.setEnabled(on); + this.button.setEnabled(on); } } diff --git a/src/editor/panels/headerIcons.ts b/src/editor/panels/headerIcons.ts new file mode 100644 index 00000000..3eb939d8 --- /dev/null +++ b/src/editor/panels/headerIcons.ts @@ -0,0 +1,4 @@ +/** Icons of the tab headers' switches (#194): a picture instead of a caption. */ +import magnet from './icons/magnet.svg?raw'; + +export const HEADER_ICON_SVG = { snap: magnet }; diff --git a/src/editor/panels/icons/magnet.svg b/src/editor/panels/icons/magnet.svg new file mode 100644 index 00000000..dd7a8451 --- /dev/null +++ b/src/editor/panels/icons/magnet.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/editor/particles/ParticlesEditor.ts b/src/editor/particles/ParticlesEditor.ts index daa7d283..e88a51f9 100644 --- a/src/editor/particles/ParticlesEditor.ts +++ b/src/editor/particles/ParticlesEditor.ts @@ -27,7 +27,7 @@ const HOLD_WIDTH = 52; */ export class ParticlesEditor extends AssetTabEditor { readonly preview: ParticlePreview; - private readonly snap = new SnapToggle(() => this.canvas); + readonly snap = new SnapToggle(() => this.canvas); /** Public for the check scripts: the one control they have to hit rather than read. */ readonly slider = new Slider(0, 0, MIN_SPAN, 120); private readonly holdButton = new Button('Hold', { diff --git a/src/editor/shader/ShaderEditor.ts b/src/editor/shader/ShaderEditor.ts index 7032a80c..4fd43acc 100644 --- a/src/editor/shader/ShaderEditor.ts +++ b/src/editor/shader/ShaderEditor.ts @@ -14,7 +14,7 @@ const INFO_WIDTH = 220; * on every change (see `ShaderLibrary`); the readout says whether it did. */ export class ShaderEditor extends AssetTabEditor { - private readonly snap = new SnapToggle(() => this.canvas); + readonly snap = new SnapToggle(() => this.canvas); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); diff --git a/src/editor/statemachine/StateMachineEditor.ts b/src/editor/statemachine/StateMachineEditor.ts index a6a7f417..c292d227 100644 --- a/src/editor/statemachine/StateMachineEditor.ts +++ b/src/editor/statemachine/StateMachineEditor.ts @@ -14,7 +14,7 @@ const INFO_WIDTH = 90; * and undo are all the graph's. */ export class StateMachineEditor extends AssetTabEditor { - private readonly snap = new SnapToggle(() => this.canvas); + readonly snap = new SnapToggle(() => this.canvas); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index 6b5cde46..383ce766 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -171,7 +171,7 @@ export class VectorArtCanvas extends Widget { return this.tool; } - setSnap(on: boolean): void { + setSnapping(on: boolean): void { this.snap = on; } diff --git a/src/editor/vectorart/VectorArtEditor.ts b/src/editor/vectorart/VectorArtEditor.ts index ce5b6da0..e6e9c7f1 100644 --- a/src/editor/vectorart/VectorArtEditor.ts +++ b/src/editor/vectorart/VectorArtEditor.ts @@ -2,6 +2,7 @@ import type { Asset, VectorArtAsset } from '../../core/model/Asset'; import { vectorCanvasSize } from '../../core/model/vectorart'; import { Button } from '../../ui'; import { AssetTabEditor } from '../AssetTabEditor'; +import { SnapToggle } from '../graph/SnapToggle'; import type { EditorState } from '../state'; import { VectorArtCanvas, type VectorTool } from './VectorArtCanvas'; @@ -14,20 +15,16 @@ const INFO_WIDTH = 190; * a panel of its own is #191. */ export class VectorArtEditor extends AssetTabEditor { - /** Public for the check scripts: the two tools and the grid switch. */ + /** Public for the check scripts: the two tools and the magnet. */ readonly selectButton: Button; readonly penButton: Button; - readonly snapButton: Button; + readonly snap = new SnapToggle(() => this.canvas); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') }); this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') }); - this.snapButton = new Button('Grid', { - toggle: true, - onClick: () => this.setSnap(!this.canvas.snapping), - }); - this.mount(new VectorArtCanvas(state), [this.selectButton, this.penButton, this.snapButton]); + this.mount(new VectorArtCanvas(state), [this.selectButton, this.penButton, ...this.snap.widgets]); this.setTool('select'); } @@ -37,11 +34,6 @@ export class VectorArtEditor extends AssetTabEditor ({ drawSvg: () => undefined, tintSvg: (svg: string) => svg })); + +beforeAll(() => { + CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; +}); + +function target() { + const calls: boolean[] = []; + return { calls, setSnapping: (on: boolean) => calls.push(on) }; +} + +describe('SnapToggle', () => { + it('is a square icon button without a caption', () => { + const snap = new SnapToggle(() => target()); + expect(snap.widgets).toEqual([snap.button]); + expect(snap.button.measure().w).toBe(Theme.controlHeight); + expect(snap.button.text).toBe(''); + }); + + it('starts off and switches the target on and off again, lit while on', () => { + const canvas = target(); + const snap = new SnapToggle(() => canvas); + expect(snap.snapping).toBe(false); + expect(snap.button.toggled).toBe(false); + + snap.button.onClick?.(); + expect(snap.snapping).toBe(true); + expect(snap.button.toggled).toBe(true); + + snap.button.onClick?.(); + expect(snap.snapping).toBe(false); + expect(snap.button.toggled).toBe(false); + expect(canvas.calls).toEqual([true, false]); + }); + + it('asks for the target at the click, so a canvas mounted later is the one switched', () => { + let canvas = target(); + const snap = new SnapToggle(() => canvas); + const later = target(); + canvas = later; + snap.set(true); + expect(later.calls).toEqual([true]); + }); +}); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index a77156c6..9c632b1e 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -6,6 +6,7 @@ import { LIST_ICON_SVG, PLUGINS_ICON_SVG, } from '../../src/editor/panels/assetIcons'; +import { HEADER_ICON_SVG } from '../../src/editor/panels/headerIcons'; import { TOOLBAR_ICON_SVG } from '../../src/editor/panels/toolbarIcons'; import { tintSvg } from '../../src/ui/svg'; import { SYMBOL_SVG } from '../../src/viewport/symbolIcons'; @@ -41,8 +42,24 @@ describe('icon files', () => { ['game tab', GAME_ICON_SVG], ['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]), ]; + // 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)', () => { + const others = [ + ...Object.values(ASSET_ICON_SVG), + ...Object.values(TOOLBAR_ICON_SVG), + LIST_ICON_SVG, + GAME_ICON_SVG, + PLUGINS_ICON_SVG, + ]; + expect(others).not.toContain(HEADER_ICON_SVG.snap); + expect(HEADER_ICON_SVG.snap).toContain('C'); + for (const { attrs } of tags(HEADER_ICON_SVG.snap).slice(1)) expect(attrs.d ?? '').not.toMatch(/[Aa]/); + }); + it('has one icon per button of the top bar, each its own picture (#183)', () => { expect(Object.keys(TOOLBAR_ICON_SVG).sort()).toEqual(['menu', 'pause', 'play', 'redo', 'stop', 'undo']); expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(6); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 1eda95e2..87850d5b 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -444,14 +444,20 @@ export async function graph(t) { const GRID = 24; const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0; check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false); - const snapLabel = await locate('Snap'); - // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. - await click(snapLabel.x + 30, snapLabel.y); + // The switch is a magnet (#194): a square icon button that stays lit while snapping is on. + const magnet = await json('__gm.graph.snapButton()'); + await click(magnet.x, magnet.y); check( 'the toggle switches it on without moving a single node', (await json('__gm.graph.snapping()')) === true && (await positions()) === before13i, await positions(), ); + const magnetOn = await json('__gm.graph.snapButton()'); + check( + 'the magnet is a square icon without a caption, and lit while on', + magnetOn.toggled && magnetOn.w === magnetOn.h && (await json(`__gm.locate('Snap')`)) === null, + JSON.stringify(magnetOn), + ); // A single node: dropped between two grid lines, it lands on the nearer one. await click(corner.x, corner.y); @@ -501,7 +507,7 @@ export async function graph(t) { check('undo takes both snapped moves back', (await positions()) === before13i, await positions()); // Switching it off has to give the pointer its exact drop position back. - await click(snapLabel.x + 30, snapLabel.y); + await click(magnet.x, magnet.y); await click(corner.x, corner.y); const offPre = await nodeOf('n-axis'); const offFrom = await json(`__gm.graph.nodeAt('n-axis')`); diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index ebfaf40a..971e7f39 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -106,16 +106,15 @@ export async function stateMachine(t) { ); await shot('01-two-states'); - // 26b2. The header's Snap checkbox is the graph's: on, a dragged state lands on the 24 px grid. + // 26b2. The header's magnet is the graph's: on, a dragged state lands on the 24 px grid. const GRID = 24; const stateXY = async (id) => (await states()).find((s) => s.id === id) ?? { x: NaN, y: NaN }; check('snapping is off until it is asked for', (await json('__gm.stateMachine.snapping()')) === false); - const snapLabel = await locate('Snap'); + const magnet = await json('__gm.stateMachine.snapButton()'); const preSnap = JSON.stringify(await states()); - // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. - await click(snapLabel.x + 30, snapLabel.y); + await click(magnet.x, magnet.y); check( - 'the Snap toggle switches it on without moving a state', + 'the magnet switches it on without moving a state', (await json('__gm.stateMachine.snapping()')) === true && JSON.stringify(await states()) === preSnap, JSON.stringify(await states()), ); @@ -137,8 +136,8 @@ export async function stateMachine(t) { backAt.x === wasAt.x && backAt.y === wasAt.y, `${backAt.x},${backAt.y}`, ); - await click(snapLabel.x + 30, snapLabel.y); - check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false); + await click(magnet.x, magnet.y); + check('the magnet switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false); // 26c. The inspector renames the selected state; the node follows. const nameField = await json("__gm.inspectorField('Name', true)"); diff --git a/tools/e2e/36-graph-groups.mjs b/tools/e2e/36-graph-groups.mjs index a9a6714e..ed2f317e 100644 --- a/tools/e2e/36-graph-groups.mjs +++ b/tools/e2e/36-graph-groups.mjs @@ -163,9 +163,8 @@ export async function graphGroups(t) { // 36f2. With snapping on, the corner obeys the grid like a dragged node does – and it is the far // edge that lands on it, because the near one is exactly what a resize must not move. - const snapLabel = await locate('Snap'); - // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. - await click(snapLabel.x + 30, snapLabel.y); + const magnet = await json('__gm.graph.snapButton()'); + await click(magnet.x, magnet.y); const held = await one('group'); const snapCorner = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)}, true)`); // 53 and 37 are deliberately ragged: a multiple of 24 afterwards cannot come from the gesture. @@ -181,7 +180,7 @@ export async function graphGroups(t) { snapped.w > held.w, JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }), ); - await click(snapLabel.x + 30, snapLabel.y); + await click(magnet.x, magnet.y); // 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour: // the text flips to black on a light fill, which is what makes a free picker safe. diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 23892ce1..1dc7ac49 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -87,6 +87,7 @@ export async function vectorArt(t) { shot, showTab, typeText, + undo, } = t; const Y = LANES.vectorArt; @@ -225,6 +226,29 @@ export async function vectorArt(t) { JSON.stringify(moved), ); + // The magnet is the graph's (#194): the tab has no Grid button of its own any more, and with it + // on a ragged drop lands on the 8 px grid. Undone, so the point is back where the next steps expect it. + const magnet = await json('__gm.vectorArt.snapButton()'); + check( + 'the tab carries the magnet in place of a Grid button', + magnet.w === magnet.h && !magnet.toggled && (await json(`__gm.locate('Grid')`)) === null, + JSON.stringify(magnet), + ); + await click(magnet.x, magnet.y); + const snapFrom = await json(`__gm.vectorArt.pointAt(180, 40)`); + const snapTo = await json(`__gm.vectorArt.pointAt(187, 45)`); + await drag(snapFrom.x, snapFrom.y, snapTo.x, snapTo.y); + const onGrid = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1]; + check( + 'with the magnet on, a dragged point lands on the grid', + (await json('__gm.vectorArt.snapping()')) === true && onGrid.x === 184 && onGrid.y === 48, + JSON.stringify(onGrid), + ); + await shot('47-magnet'); + await undo(); + await click(magnet.x, magnet.y); + check('the magnet switches snapping off again', (await json('__gm.vectorArt.snapping()')) === false); + const pulled = await json(`__gm.vectorArt.pointAt(180, 40)`); await click(pulled.x, pulled.y, 'left', { alt: true }); const smooth = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];