Snap is a magnet button in every graph and vector art tab (#194)
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m33s

The snap toggle was a checkbox with the word "Snap" next to Fit, and
the vector art tab had its own "Grid" button for the same idea. Both
now share one square icon button, so snapping looks and works the
same wherever it exists.

- SnapToggle is a square toggle button with a magnet icon and no
  text. It lights up blue when snapping is on, like the other toggle
  buttons.
- The magnet is drawn with cubic curves instead of arcs, because the
  Pixi SVG parser draws arcs as jagged edges.
- The vector art tab drops its Grid button and uses the same
  SnapToggle. It still snaps to 8 px, the graph tabs to 24 px.
- SnapToggle accepts anything with setSnapping(on). The vector art
  canvas method is renamed from setSnap to setSnapping for that.
- Every tab exposes the button as __gm.<tab>.snapButton(). Modules
  06, 26 and 36 click through that hook instead of the label "Snap"
  plus 30 px.

Tests:
- ./check.sh: 129 files, 1903 tests (was 128 / 1897)
- 3 new tests in tests/editor/snap-toggle.test.ts, 3 in the icon tests
- GM_STEPS=1-6,26,36,47 fresh: ALL PASSED, 346 checks
- GM_STEPS=47: ALL PASSED, 32 checks (was 29)
- Counter-checks: toggle not switching its target fails 2 unit
  tests, the canvas ignoring the switch fails module 47
- Check total 1188 in README and wiki is computed (1184 + 4), the
  full run is left to CI

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 06:25:56 +02:00
1 parent ad62a0f525
commit 90627ccf47
17 files changed
+181 -43

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+13
View File
@@ -33,6 +33,7 @@ import { currentSoundPicker } from './sound/SoundPicker';
import { currentImagePicker } from './texture/ImagePicker'; import { currentImagePicker } from './texture/ImagePicker';
import { currentTilemapWidgets } from './panels/TilemapPanel'; import { currentTilemapWidgets } from './panels/TilemapPanel';
import { currentShapeList } from './inspector/shapeSection'; import { currentShapeList } from './inspector/shapeSection';
import type { SnapToggle } from './graph/SnapToggle';
import type { EditorState, TilemapTool } from './state'; import type { EditorState, TilemapTool } from './state';
import { rectOf, textProbe } from './textProbe'; import { rectOf, textProbe } from './textProbe';
@@ -56,6 +57,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = widget.getGlobalPosition(); const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; 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); const { eachText, locate, locateAll } = textProbe(app);
/** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */ /** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */
const textColor = (text: string): string | null => { const textColor = (text: string): string | null => {
@@ -338,6 +346,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
// drawing area. // drawing area.
center: () => centreOf(graphEditor.canvas), center: () => centreOf(graphEditor.canvas),
snapping: () => graphEditor.canvas.snapEnabled, snapping: () => graphEditor.canvas.snapEnabled,
snapButton: () => snapButton(graphEditor.snap),
zoom: () => graphEditor.canvas.zoomLevel, zoom: () => graphEditor.canvas.zoomLevel,
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(), crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
}, },
@@ -349,6 +358,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(), canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(),
center: () => centreOf(editor.stateMachineEditor.canvas), center: () => centreOf(editor.stateMachineEditor.canvas),
snapping: () => editor.stateMachineEditor.canvas.snapEnabled, 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. */ /** Where a state's hidden pin sits; it cannot be grabbed – `portGrabbable` is the check. */
portAt: (nodeId: string, port: string, isOutput: boolean) => portAt: (nodeId: string, port: string, isOutput: boolean) =>
editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput), editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput),
@@ -372,6 +382,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
particles: { particles: {
current: () => editor.particlesEditor.currentAssetId, current: () => editor.particlesEditor.currentAssetId,
info: () => editor.particlesEditor.shownInfo, info: () => editor.particlesEditor.shownInfo,
snapButton: () => snapButton(editor.particlesEditor.snap),
nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId), nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId),
nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId), nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId),
nodeRect: (nodeId: string) => editor.particlesEditor.canvas.nodeRect(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(), emptySpot: () => editor.shaderEditor.canvas.emptySpotGlobal(),
center: () => centreOf(editor.shaderEditor.canvas), center: () => centreOf(editor.shaderEditor.canvas),
snapping: () => editor.shaderEditor.canvas.snapEnabled, 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. */ /** The console panel: its lines as kept, its drawn height, and the divider above it. */
console: { console: {
@@ -456,6 +468,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
rect: () => vectorArtEditor.canvasRect(), rect: () => vectorArtEditor.canvasRect(),
tool: () => vectorArtEditor.canvas.currentTool, tool: () => vectorArtEditor.canvas.currentTool,
snapping: () => vectorArtEditor.canvas.snapping, snapping: () => vectorArtEditor.canvas.snapping,
snapButton: () => snapButton(vectorArtEditor.snap),
/** Whether the pen has a line open that the next click would add a point to. */ /** Whether the pen has a line open that the next click would add a point to. */
drawing: () => vectorArtEditor.canvas.drawing, drawing: () => vectorArtEditor.canvas.drawing,
/** The shapes as the model holds them, so a check can read points and handles exactly. */ /** The shapes as the model holds them, so a check can read points and handles exactly. */
+1 -1
View File
@@ -11,7 +11,7 @@ import { backTo, descend, follow, type Trail } from './trail';
const INFO_WIDTH = 150; const INFO_WIDTH = 150;
export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> { export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
private readonly snap = new SnapToggle(() => this.canvas); readonly snap = new SnapToggle(() => this.canvas);
readonly breadcrumb = new Breadcrumb(); readonly breadcrumb = new Breadcrumb();
private trail: Trail = []; private trail: Trail = [];
/** A call followed into another behavior: the tab goes on to that document (#171). */ /** A call followed into another behavior: the tab goes on to that document (#171). */
+32 -9
View File
@@ -1,17 +1,40 @@
import { Checkbox, Label, type Widget } from '../../ui'; import { Button, Theme } from '../../ui';
import type { GraphCanvas } from './GraphCanvas'; 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 { export class SnapToggle {
private readonly box = new Checkbox(); readonly button: Button;
readonly widgets: readonly Widget[]; private on = false;
constructor(canvas: () => GraphCanvas) { constructor(private readonly target: () => Snappable) {
this.box.onChange = (on) => canvas().setSnapping(on); this.button = new Button('', {
this.widgets = [new Label('Snap', { style: 'muted' }), this.box]; 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 { setEnabled(on: boolean): void {
this.box.setEnabled(on); this.button.setEnabled(on);
} }
} }
+4
View File
@@ -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 };
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- A horseshoe opening downwards, the two poles split off by a gap. Cubic curves, not arcs:
Pixi's parser draws an A in the middle of a path as a spike (see plugins.svg). -->
<path d="M-4.8 1.6 L-4.8 -1.2 C-4.8 -7.6, 4.8 -7.6, 4.8 -1.2 L4.8 1.6" fill="none" stroke="currentColor" stroke-width="3.2" />
<path d="M-6.4 3 L-3.2 3 L-3.2 6.4 L-6.4 6.4 Z" fill="currentColor" />
<path d="M3.2 3 L6.4 3 L6.4 6.4 L3.2 6.4 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 550 B

+1 -1
View File
@@ -27,7 +27,7 @@ const HOLD_WIDTH = 52;
*/ */
export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas> { export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas> {
readonly preview: ParticlePreview; 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. */ /** Public for the check scripts: the one control they have to hit rather than read. */
readonly slider = new Slider(0, 0, MIN_SPAN, 120); readonly slider = new Slider(0, 0, MIN_SPAN, 120);
private readonly holdButton = new Button('Hold', { private readonly holdButton = new Button('Hold', {
+1 -1
View File
@@ -14,7 +14,7 @@ const INFO_WIDTH = 220;
* on every change (see `ShaderLibrary`); the readout says whether it did. * on every change (see `ShaderLibrary`); the readout says whether it did.
*/ */
export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> { export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
private readonly snap = new SnapToggle(() => this.canvas); readonly snap = new SnapToggle(() => this.canvas);
constructor(state: EditorState) { constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH }); super(state, { infoWidth: INFO_WIDTH });
@@ -14,7 +14,7 @@ const INFO_WIDTH = 90;
* and undo are all the graph's. * and undo are all the graph's.
*/ */
export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphCanvas> { export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphCanvas> {
private readonly snap = new SnapToggle(() => this.canvas); readonly snap = new SnapToggle(() => this.canvas);
constructor(state: EditorState) { constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH }); super(state, { infoWidth: INFO_WIDTH });
+1 -1
View File
@@ -171,7 +171,7 @@ export class VectorArtCanvas extends Widget {
return this.tool; return this.tool;
} }
setSnap(on: boolean): void { setSnapping(on: boolean): void {
this.snap = on; this.snap = on;
} }
+5 -13
View File
@@ -2,6 +2,7 @@ import type { Asset, VectorArtAsset } from '../../core/model/Asset';
import { vectorCanvasSize } from '../../core/model/vectorart'; import { vectorCanvasSize } from '../../core/model/vectorart';
import { Button } from '../../ui'; import { Button } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor'; import { AssetTabEditor } from '../AssetTabEditor';
import { SnapToggle } from '../graph/SnapToggle';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { VectorArtCanvas, type VectorTool } from './VectorArtCanvas'; import { VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
@@ -14,20 +15,16 @@ const INFO_WIDTH = 190;
* a panel of its own is #191. * a panel of its own is #191.
*/ */
export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCanvas> { export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCanvas> {
/** 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 selectButton: Button;
readonly penButton: Button; readonly penButton: Button;
readonly snapButton: Button; readonly snap = new SnapToggle(() => this.canvas);
constructor(state: EditorState) { constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH }); super(state, { infoWidth: INFO_WIDTH });
this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') }); this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') });
this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') }); this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') });
this.snapButton = new Button('Grid', { this.mount(new VectorArtCanvas(state), [this.selectButton, this.penButton, ...this.snap.widgets]);
toggle: true,
onClick: () => this.setSnap(!this.canvas.snapping),
});
this.mount(new VectorArtCanvas(state), [this.selectButton, this.penButton, this.snapButton]);
this.setTool('select'); this.setTool('select');
} }
@@ -37,11 +34,6 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
this.penButton.setToggled(tool === 'pen'); this.penButton.setToggled(tool === 'pen');
} }
setSnap(on: boolean): void {
this.canvas.setSnap(on);
this.snapButton.setToggled(on);
}
/** Where a canvas pixel of the shown drawing sits on screen – for the check scripts. */ /** Where a canvas pixel of the shown drawing sits on screen – for the check scripts. */
canvasToScreen(x: number, y: number): { x: number; y: number } { canvasToScreen(x: number, y: number): { x: number; y: number } {
return this.canvas.canvasToScreen(x, y); return this.canvas.canvasToScreen(x, y);
@@ -71,6 +63,6 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
this.fitButton.setEnabled(asset !== null); this.fitButton.setEnabled(asset !== null);
this.selectButton.setEnabled(asset !== null); this.selectButton.setEnabled(asset !== null);
this.penButton.setEnabled(asset !== null); this.penButton.setEnabled(asset !== null);
this.snapButton.setEnabled(asset !== null); this.snap.setEnabled(asset !== null);
} }
} }
+54
View File
@@ -0,0 +1,54 @@
/**
* The magnet in the tab headers (#194): one icon toggle for every tab that snaps, graph canvas and
* vector art alike. Driven through the button's own `onClick`, the path a click takes.
*/
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { CanvasTextMetrics } from 'pixi.js';
import { SnapToggle } from '../../src/editor/graph/SnapToggle';
import { Theme } from '../../src/ui';
// Node has no DOMParser to read the file with; what the magnet looks like is the icon tests' job.
vi.mock('../../src/ui/svg', () => ({ 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]);
});
});
+17
View File
@@ -6,6 +6,7 @@ import {
LIST_ICON_SVG, LIST_ICON_SVG,
PLUGINS_ICON_SVG, PLUGINS_ICON_SVG,
} from '../../src/editor/panels/assetIcons'; } from '../../src/editor/panels/assetIcons';
import { HEADER_ICON_SVG } from '../../src/editor/panels/headerIcons';
import { TOOLBAR_ICON_SVG } from '../../src/editor/panels/toolbarIcons'; import { TOOLBAR_ICON_SVG } from '../../src/editor/panels/toolbarIcons';
import { tintSvg } from '../../src/ui/svg'; import { tintSvg } from '../../src/ui/svg';
import { SYMBOL_SVG } from '../../src/viewport/symbolIcons'; import { SYMBOL_SVG } from '../../src/viewport/symbolIcons';
@@ -41,8 +42,24 @@ describe('icon files', () => {
['game tab', GAME_ICON_SVG], ['game tab', GAME_ICON_SVG],
['plugins tab', PLUGINS_ICON_SVG], ['plugins tab', PLUGINS_ICON_SVG],
...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, 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)', () => { 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(Object.keys(TOOLBAR_ICON_SVG).sort()).toEqual(['menu', 'pause', 'play', 'redo', 'stop', 'undo']);
expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(6); expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(6);
+10 -4
View File
@@ -444,14 +444,20 @@ export async function graph(t) {
const GRID = 24; const GRID = 24;
const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0; 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); check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false);
const snapLabel = await locate('Snap'); // The switch is a magnet (#194): a square icon button that stays lit while snapping is on.
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre. const magnet = await json('__gm.graph.snapButton()');
await click(snapLabel.x + 30, snapLabel.y); await click(magnet.x, magnet.y);
check( check(
'the toggle switches it on without moving a single node', 'the toggle switches it on without moving a single node',
(await json('__gm.graph.snapping()')) === true && (await positions()) === before13i, (await json('__gm.graph.snapping()')) === true && (await positions()) === before13i,
await positions(), 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. // A single node: dropped between two grid lines, it lands on the nearer one.
await click(corner.x, corner.y); 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()); 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. // 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); await click(corner.x, corner.y);
const offPre = await nodeOf('n-axis'); const offPre = await nodeOf('n-axis');
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`); const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
+6 -7
View File
@@ -106,16 +106,15 @@ export async function stateMachine(t) {
); );
await shot('01-two-states'); 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 GRID = 24;
const stateXY = async (id) => (await states()).find((s) => s.id === id) ?? { x: NaN, y: NaN }; 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); 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()); const preSnap = JSON.stringify(await states());
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre. await click(magnet.x, magnet.y);
await click(snapLabel.x + 30, snapLabel.y);
check( 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, (await json('__gm.stateMachine.snapping()')) === true && JSON.stringify(await states()) === preSnap,
JSON.stringify(await states()), JSON.stringify(await states()),
); );
@@ -137,8 +136,8 @@ export async function stateMachine(t) {
backAt.x === wasAt.x && backAt.y === wasAt.y, backAt.x === wasAt.x && backAt.y === wasAt.y,
`${backAt.x},${backAt.y}`, `${backAt.x},${backAt.y}`,
); );
await click(snapLabel.x + 30, snapLabel.y); await click(magnet.x, magnet.y);
check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false); check('the magnet switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false);
// 26c. The inspector renames the selected state; the node follows. // 26c. The inspector renames the selected state; the node follows.
const nameField = await json("__gm.inspectorField('Name', true)"); const nameField = await json("__gm.inspectorField('Name', true)");
+3 -4
View File
@@ -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 // 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. // edge that lands on it, because the near one is exactly what a resize must not move.
const snapLabel = await locate('Snap'); const magnet = await json('__gm.graph.snapButton()');
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre. await click(magnet.x, magnet.y);
await click(snapLabel.x + 30, snapLabel.y);
const held = await one('group'); const held = await one('group');
const snapCorner = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)}, true)`); 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. // 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, snapped.w > held.w,
JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }), 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: // 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. // the text flips to black on a light fill, which is what makes a free picker safe.
+24
View File
@@ -87,6 +87,7 @@ export async function vectorArt(t) {
shot, shot,
showTab, showTab,
typeText, typeText,
undo,
} = t; } = t;
const Y = LANES.vectorArt; const Y = LANES.vectorArt;
@@ -225,6 +226,29 @@ export async function vectorArt(t) {
JSON.stringify(moved), 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)`); const pulled = await json(`__gm.vectorArt.pointAt(180, 40)`);
await click(pulled.x, pulled.y, 'left', { alt: true }); await click(pulled.x, pulled.y, 'left', { alt: true });
const smooth = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1]; const smooth = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];