Files
feuerwerk/tests/editor/point-gestures.test.ts
T
adminandClaude Opus 5.5 a848cf8469
game-maker / gate (push) Successful in 3m48s
game-maker / suite (push) Successful in 15m0s
Pick a new asset from category submenus in the + menu
The + menu was one long list in two groups, placeable types and
library ones. That split is no longer wanted. The menu now lists the
categories of the asset panel, and each opens its types beside it, so
a new asset is filed where the panel shows it.

- Menu learns submenus: an entry with submenu instead of onSelect
  opens a second Menu right of the first, or left of it where the
  canvas ends. Its first entry lines up with the row that opened it.
- A submenu opens on hover or click. Resting on another entry swaps
  or closes it after 150 ms, so a pointer cutting across the rows on
  its way into a submenu does not lose it.
- The row of an open submenu stays lit, and a triangle pointing right
  marks every such row.
- A submenu has no backdrop and no Escape of its own. Popup takes a
  backdrop option for that. The first menu's backdrop and Escape
  close the whole stack, and picking an entry does the same.
- assetMenuCategories replaces assetMenuGroups and takes categories
  and their order from ASSET_GROUPS. The types are sorted by label.
- New test hooks __gm.menuSubmenus() and __gm.addMenuCategory().
  pickAssetType opens the category first, so the modules that pick a
  type through it need no change of their own. Modules 04, 10, 47,
  48, 53 and 54 open the category themselves where they read the menu.

Tests:
- Unit asset-menu: 4 green
- e2e 04: ALL PASSED, 63 checks. New: categories in panel order, each
  type once and sorted, submenu placement, hover swaps the submenu,
  picking closes menu and submenu
- Gate: CHECK EXIT 0, 261 files, 4040 tests
- The other modules that use the + menu did not run here

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:17:21 +02:00

483 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The gestures of the four point editors (#288): a double-click puts a point down, a click picks
* one, Delete takes the picked one away – and only while one is picked. The rule itself first, then
* each editor driven through the presses its view hands it.
*/
import { CanvasTextMetrics, type FederatedPointerEvent, type Graphics } from 'pixi.js';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { buildAsset, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
import type { CurveAsset, SceneAsset, TextureAsset, VectorArtAsset } from '../../src/core/model/Asset';
import { IDENTITY } from '../../src/core/model/matrix';
import { findObject } from '../../src/core/model/hierarchy';
import { createEmptyProject } from '../../src/core/model/Project';
import type { Vec2 } from '../../src/core/model/Properties';
import { COLLIDER_POINTS_KEY, colliderPoints } from '../../src/core/properties/collider';
import { CurveCanvas } from '../../src/editor/curve/CurveCanvas';
import { deleteTakes, pickedIn, pointPress } from '../../src/editor/pointGestures';
import { pointMenuEntries } from '../../src/editor/pointMenu';
import { EditorState, objectSelection, shapeSelection } from '../../src/editor/state';
import { TextureCanvas } from '../../src/editor/texture/TextureCanvas';
import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas';
import { PolygonCornerEditor } from '../../src/editor/viewport/polygonCorners';
import type { SceneViewHost } from '../../src/editor/viewport/sceneViewHost';
import { resetRenderLoop } from '../../src/ui/render';
import type { DrawItem } from '../../src/viewport/items';
import { PaintCache } from '../../src/viewport/vectorArtPicture';
import { allShapes } from '../../src/core/model/vectorTree';
/** Presses run on one clock, far enough apart that no two join unless a test asks for a double-click. */
let clock = 0;
function pressAt(global: Vec2, options: { double?: boolean; alt?: boolean; ctrl?: boolean } = {}) {
clock += options.double ? 100 : 1000;
return {
timeStamp: clock,
button: 0,
global,
altKey: options.alt ?? false,
ctrlKey: options.ctrl ?? false,
metaKey: false,
shiftKey: false,
stopPropagation: () => undefined,
} as unknown as FederatedPointerEvent;
}
const key = (name: string) => ({ key: name }) as KeyboardEvent;
/** What a sheet canvas does with a press, a move and a release, past the focus its widget takes. */
interface Pressable<A> {
press(asset: A, e: FederatedPointerEvent, local: Vec2): void;
move(local: Vec2): void;
release(): void;
}
function gestures<A>(canvas: unknown, asset: A) {
const target = canvas as Pressable<A>;
const click = (at: Vec2, options?: Parameters<typeof pressAt>[1]) => {
target.press(asset, pressAt(at, options), at);
target.release();
};
return {
click,
doubleClick: (at: Vec2) => {
click(at);
click(at, { double: true });
},
drag: (from: Vec2, to: Vec2) => {
target.press(asset, pressAt(from), from);
target.move(to);
target.release();
},
/** A double-click whose second press is held and dragged away before it lets go. */
doubleClickAndDrag: (at: Vec2, to: Vec2) => {
click(at);
target.press(asset, pressAt(at, { double: true }), at);
target.move(to);
target.release();
},
};
}
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
PaintCache.prototype.gradient = (() => null) as never;
});
beforeEach(() => resetRenderLoop());
describe('the rule of a press in a point editor', () => {
it('picks a point on any press, and adds only on a double-click', () => {
expect(pointPress('point', 1)).toBe('pick');
expect(pointPress('point', 2)).toBe('pick');
expect(pointPress('line', 1)).toBe('clear');
expect(pointPress('line', 2)).toBe('add');
expect(pointPress('line', 3)).toBe('clear');
});
it('adds beside the line only where the editor says so', () => {
expect(pointPress('free', 2)).toBe('clear');
expect(pointPress('free', 2, true)).toBe('add');
expect(pointPress('free', 1, true)).toBe('clear');
});
it('gives Delete the picked point, and the selection while none is picked', () => {
expect(deleteTakes(true)).toBe('point');
expect(deleteTakes(false)).toBe('selection');
});
it('drops a pick that points past the list', () => {
expect(pickedIn(3, 2)).toBe(2);
expect(pickedIn(3, 3)).toBe(-1);
expect(pickedIn(3, -1)).toBe(-1);
});
it('offers Delete point, greyed out for a point that must stay, and the type where there is one', () => {
const plain = pointMenuEntries({ removable: false, remove: () => undefined });
expect(plain).toHaveLength(1);
expect(plain[0]).toMatchObject({ label: 'Delete point', disabled: true });
const kinds: string[] = [];
const typed = pointMenuEntries({
removable: true,
remove: () => undefined,
kind: 'corner',
setKind: (kind) => kinds.push(kind),
});
const labels = typed.map((entry) => ('separator' in entry ? '-' : entry.label));
expect(labels).toEqual(['Delete point', '-', 'Straight', 'Corner', 'Smooth', 'Symmetric']);
expect(typed[0]).toMatchObject({ disabled: false });
const smooth = typed[4]!;
if ('onSelect' in smooth) smooth.onSelect();
expect(kinds).toEqual(['smooth']);
});
});
describe('the curve tab', () => {
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const curve = buildAsset('curve', 'Ease') as CurveAsset;
state.project.assets.push(curve);
const canvas = new CurveCanvas(state);
canvas.show(curve.id);
canvas.resize(400, 400);
canvas.redraw();
const at = (t: number, v: number) => canvas.graphToScreen(t, v);
return { state, curve, canvas, at, ...gestures(canvas, curve) };
}
it('adds a point on a double-click, on free space too, picks it and leaves it where it is', () => {
const { state, curve, canvas, at, click, doubleClickAndDrag } = rig();
click(at(0.5, 0.9));
expect(curve.points).toHaveLength(2);
expect(canvas.pickedIndex).toBe(-1);
doubleClickAndDrag(at(0.5, 0.9), at(0.7, 0.2));
expect(curve.points).toHaveLength(3);
expect(curve.points[1]!.x).toBeCloseTo(0.5);
expect(curve.points[1]!.y).toBeCloseTo(0.9);
expect(canvas.pickedIndex).toBe(1);
state.undo();
expect(curve.points).toHaveLength(2);
});
it('adds one on the line by a double-click, and none by a click', () => {
const { curve, at, click, doubleClick } = rig();
click(at(0.5, 0.5));
expect(curve.points).toHaveLength(2);
doubleClick(at(0.5, 0.5));
expect(curve.points).toHaveLength(3);
});
it('keeps a point through a double-click on it, and drags it once it is pressed again', () => {
const { curve, canvas, at, doubleClick, drag } = rig();
doubleClick(at(0.5, 0.9));
doubleClick(at(0.5, 0.9));
expect(curve.points).toHaveLength(3);
drag(at(0.5, 0.9), at(0.5, 0.4));
expect(curve.points[1]!.y).toBeCloseTo(0.4);
expect(canvas.pickedIndex).toBe(1);
});
it('takes the picked point away on Delete, never an end, and nothing while none is picked', () => {
const { state, curve, canvas, at, click, doubleClick } = rig();
doubleClick(at(0.5, 0.9));
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
expect(curve.points).toHaveLength(2);
expect(canvas.pickedIndex).toBe(-1);
state.undo();
expect(curve.points).toHaveLength(3);
click(at(0, 0));
expect(canvas.pickedIndex).toBe(0);
canvas.onKeyDown(key('Delete'));
expect(curve.points).toHaveLength(3);
click(at(0.3, 0.9));
expect(canvas.pickedIndex).toBe(-1);
expect(canvas.onKeyDown(key('Delete'))).toBe(false);
expect(curve.points).toHaveLength(3);
});
});
describe('the vector art tab', () => {
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
const square = [
{ x: 10, y: 10 },
{ x: 50, y: 10 },
{ x: 50, y: 50 },
{ x: 10, y: 50 },
];
asset.layers[0]!.items.push({
id: 's',
name: 'Square',
fill: 0x0000ff,
opacity: 1,
rings: [{ closed: true, points: square }],
});
state.project.assets.push(asset);
const canvas = new VectorArtCanvas(state);
canvas.show(asset.id);
canvas.resize(400, 400);
canvas.redraw();
state.select(shapeSelection(asset.id, ['s']));
const at = (x: number, y: number) => canvas.imageToScreen(x, y);
const points = () => allShapes(asset)[0]?.rings[0]?.points ?? [];
return { state, asset, canvas, at, points, ...gestures(canvas, asset) };
}
it('adds a point on a double-click on a line, picks it and leaves it where it is', () => {
const { state, canvas, at, points, click, doubleClickAndDrag } = rig();
click(at(30, 10));
expect(points()).toHaveLength(4);
expect(canvas.pickedKind).toBe(null);
doubleClickAndDrag(at(30, 10), at(30, 0));
expect(points()).toHaveLength(5);
expect(points()[1]).toMatchObject({ x: 30, y: 10 });
expect(canvas.pickedKind).toBe('straight');
state.undo();
expect(points()).toHaveLength(4);
});
it('carries the shape when its line is dragged', () => {
const { at, points, drag } = rig();
drag(at(30, 10), at(40, 30));
expect(points()).toHaveLength(4);
expect(points()[0]).toMatchObject({ x: 20, y: 30 });
});
it('keeps a point through a double-click on it', () => {
const { at, points, doubleClick } = rig();
doubleClick(at(50, 10));
expect(points()).toHaveLength(4);
});
it('takes the picked point away on Delete, and the shape while none is picked', () => {
const { state, asset, canvas, at, points, click } = rig();
click(at(50, 10));
expect(canvas.pickedKind).toBe('straight');
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
expect(points()).toHaveLength(3);
expect(allShapes(asset)).toHaveLength(1);
state.undo();
expect(points()).toHaveLength(4);
click(at(30, 30));
expect(canvas.pickedKind).toBe(null);
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
expect(allShapes(asset)).toHaveLength(0);
});
});
describe('a polygon collider in the scene view', () => {
const SQUARE = [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
];
function rig(corners: readonly Vec2[] = SQUARE) {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const scene = buildAsset('scene', 'Scene') as SceneAsset;
const sprite = buildAsset('sprite', 'Crate');
state.project.assets.push(scene, sprite);
state.project.activeSceneId = scene.id;
const placed = new PlaceAssetCommand(scene.id, sprite.id, { x: 0, y: 0 });
state.execute(placed);
const collider = new AddColliderCommand(scene.id, placed.objectId);
state.execute(collider);
state.execute(new SetAssetPropertyCommand(collider.assetId, 'shape', 'polygon'));
state.execute(
new SetAssetPropertyCommand(
collider.assetId,
COLLIDER_POINTS_KEY,
corners.map((p) => ({ ...p })),
),
);
const colliderAsset = state.project.assets.find((a) => a.id === collider.assetId)!;
const props = () => ({
...colliderAsset.props,
...findObject(scene, collider.objectId)?.overrides,
});
const item = () => ({ id: collider.objectId, type: 'collider', m: IDENTITY, props: props() }) as DrawItem;
const host: SceneViewHost = {
state,
holder: () => scene,
items: () => [item()],
worldAt: (global) => global,
local: (global) => global,
view: () => ({ x: 0, y: 0, scale: 1, rect: { x: 0, y: 0, w: 0, h: 0 } }),
scale: () => 1,
redraw: () => undefined,
};
const editor = new PolygonCornerEditor(host);
const select = () => state.select(objectSelection(scene.id, [collider.objectId]));
select();
const points = () => colliderPoints(props());
const press = (at: Vec2, double = false) => {
const taken = editor.press(pressAt(at, { double }));
editor.release();
return taken;
};
return { state, scene, editor, points, press, select, objectId: collider.objectId };
}
it('adds a corner on a double-click on an edge, picks it and leaves it where it is', () => {
const { state, editor, points, press } = rig();
expect(press({ x: 50, y: 0 })).toBe(true);
expect(points()).toHaveLength(4);
expect(editor.pickedIndex()).toBe(-1);
expect(editor.press(pressAt({ x: 50, y: 0 }, { double: true }))).toBe(true);
expect(editor.active).toBe(false);
editor.move({ x: 50, y: -40 });
editor.release();
expect(points()).toHaveLength(5);
expect(points()[1]).toEqual({ x: 50, y: 0 });
expect(editor.pickedIndex()).toBe(1);
state.undo();
expect(points()).toHaveLength(4);
});
it('picks a corner on a click, keeps it through a double-click and drags it', () => {
const { editor, points, press } = rig();
press({ x: 100, y: 0 });
press({ x: 100, y: 0 }, true);
expect(points()).toHaveLength(4);
expect(editor.pickedIndex()).toBe(1);
editor.press(pressAt({ x: 100, y: 0 }));
editor.move({ x: 140, y: 0 });
editor.release();
expect(points()[1]).toEqual({ x: 140, y: 0 });
});
it('takes the picked corner away on Delete and leaves the collider; without a pick it takes nothing', () => {
const { state, scene, editor, points, press, objectId } = rig();
expect(editor.removePicked()).toBe(false);
press({ x: 100, y: 0 });
expect(editor.removePicked()).toBe(true);
expect(points()).toHaveLength(3);
expect(findObject(scene, objectId)).toBeDefined();
expect(editor.pickedIndex()).toBe(-1);
expect(editor.removePicked()).toBe(false);
state.undo();
expect(points()).toHaveLength(4);
});
it('keeps the last three corners', () => {
const { editor, points, press } = rig(SQUARE.slice(0, 3));
press({ x: 100, y: 0 });
expect(editor.removePicked()).toBe(true);
expect(points()).toHaveLength(3);
});
it('drops the pick on a press beside the polygon and when the collider is selected anew', () => {
const { state, editor, press, select } = rig();
press({ x: 100, y: 0 });
expect(press({ x: 300, y: 300 })).toBe(false);
expect(editor.pickedIndex()).toBe(-1);
press({ x: 100, y: 0 });
state.select(null);
select();
expect(editor.pickedIndex()).toBe(-1);
});
it('draws the picked corner', () => {
const { editor, press } = rig();
press({ x: 100, y: 100 });
const calls: string[] = [];
const g = new Proxy(
{},
{
get: (_, name: string) =>
name === 'toGlobal'
? (p: Vec2) => p
: (...args: unknown[]) => (calls.push(`${name}:${JSON.stringify(args[0])}`), g),
},
) as unknown as Graphics;
editor.draw(g, { x: 0, y: 0, scale: 1, rect: { x: 0, y: 0, w: 0, h: 0 } });
expect(editor.info()?.picked).toBe(2);
const fills = calls.filter((call) => call.startsWith('fill:'));
expect(new Set(fills).size).toBe(2);
expect(fills.filter((fill) => fill === fills[2])).toHaveLength(1);
});
});
describe('a collision outline in the texture tab', () => {
const TRIANGLE = [
{ x: 0, y: 32 },
{ x: 32, y: 0 },
{ x: 32, y: 32 },
];
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const texture = buildAsset('texture', 'Sheet') as TextureAsset;
texture.width = 64;
texture.height = 64;
texture.frames.push({
id: 'f',
name: 'Wedge',
x: 0,
y: 0,
w: 64,
h: 64,
collision: [TRIANGLE.map((p) => ({ ...p }))],
});
state.project.assets.push(texture);
const canvas = new TextureCanvas(state);
canvas.show(texture.id);
canvas.resize(400, 400);
canvas.restoreView({ view: { panX: 0, panY: 0, zoom: 4 }, needsFit: false });
canvas.setCollisionMode(true);
state.select({ kind: 'frame', assetId: texture.id, frameId: 'f' });
const at = (x: number, y: number) => ({ x: x * 4, y: y * 4 });
const outlines = () => texture.frames[0]?.collision ?? [];
return { state, texture, canvas, at, outlines, ...gestures(canvas, texture) };
}
it('adds a corner on a double-click on an edge, picks it and leaves it where it is', () => {
const { state, canvas, at, outlines, click, doubleClickAndDrag } = rig();
click(at(32, 16));
expect(outlines()[0]).toHaveLength(3);
expect(canvas.pickedCorner).toBe(null);
doubleClickAndDrag(at(32, 16), at(50, 16));
expect(outlines()[0]).toHaveLength(4);
expect(outlines()[0]![2]).toEqual({ x: 32, y: 16 });
expect(canvas.pickedCorner).toEqual({ outline: 0, index: 2 });
state.undo();
expect(outlines()[0]).toHaveLength(3);
});
it('keeps a corner through a double-click on it', () => {
const { at, outlines, doubleClick } = rig();
doubleClick(at(32, 0));
expect(outlines()[0]).toHaveLength(3);
});
it('takes the picked corner away on Delete, and the whole outline while none is picked', () => {
const { state, texture, canvas, at, outlines, click } = rig();
click(at(32, 0));
expect(canvas.pickedCorner).toEqual({ outline: 0, index: 1 });
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
expect(outlines()[0]).toHaveLength(2);
state.undo();
expect(outlines()[0]).toHaveLength(3);
click(at(32, 16));
expect(canvas.pickedCorner).toBe(null);
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
expect(outlines()).toHaveLength(0);
expect(texture.frames).toHaveLength(1);
});
it('still draws an outline corner by corner, the last one picked', () => {
const { canvas, at, outlines, click } = rig();
click(at(50, 50));
expect(outlines()[0]).toHaveLength(4);
expect(canvas.pickedCorner).toEqual({ outline: 0, index: 3 });
canvas.onKeyDown(key('Delete'));
expect(outlines()[0]).toHaveLength(3);
});
});