/** * The inspector side of vector art (#129, stage 4): the arithmetic of the shape list, which rows a * shape shows, and what editing one of them writes into the model. The rows are driven through the * widgets' own callbacks – that is the path a click takes, `setValue` only shows a value. */ import { beforeAll, describe, expect, it } from 'vitest'; import { CanvasTextMetrics } from 'pixi.js'; import { AddVectorShapeCommand, RemoveVectorShapeCommand } from '../../src/core/commands/vectorArtCommands'; import type { VectorArtAsset } from '../../src/core/model/Asset'; import { buildAsset } from '../../src/core/commands/assetCommands'; import { createEmptyProject, findAssetOf } from '../../src/core/model/Project'; import { Button, Checkbox, ColorField, Label, NumberField, TextField, toHex, VStack, type Widget, } from '../../src/ui'; import type { InspectorHost } from '../../src/editor/inspector/host'; import { buildShapeListSection, buildShapeSection, refreshShapeSection, shapeSignature, } from '../../src/editor/inspector/shapeSection'; import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows'; import { EditorState, shapeSelection } from '../../src/editor/state'; const ROW = 30; /** * These rows really are built, and a widget repaints itself one microtask after a value goes into * it – text width included, which Pixi measures on a canvas of the browser. The suite runs on node, * so the measure is answered here instead of the whole test avoiding the widgets. */ beforeAll(() => { CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; }); function makeHost(): InspectorHost { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); return { state, body: new VStack(), fields: new Map(), nameField: null, project: () => state.project, liveVariables: () => null, }; } /** A vector art asset with `count` triangles in it, and a host to build its rows into. */ function withShapes(count: number): { host: InspectorHost; assetId: string; ids: string[] } { const host = makeHost(); const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset; host.state.project.assets.push(asset); const ids: string[] = []; for (let i = 0; i < count; i++) { const command = new AddVectorShapeCommand(asset.id, [ { closed: true, points: [ { x: i, y: 0 }, { x: i + 20, y: 0 }, { x: i, y: 20 }, ], }, ]); host.state.execute(command); ids.push(command.shapeId); } return { host, assetId: asset.id, ids }; } const shapeOf = (host: InspectorHost, assetId: string, index = 0) => findAssetOf(host.state.project, assetId, 'vectorart')!.shapes[index]!; function widgets(host: InspectorHost, type: new (...args: never[]) => T): T[] { const found: T[] = []; const walk = (widget: Widget): void => { if (widget instanceof type) found.push(widget); for (const child of widget.children) walk(child as Widget); }; walk(host.body); return found; } const texts = (host: InspectorHost): string[] => widgets(host, Label).map((label) => label.text); describe('shape list order', () => { it('shows the drawing order upside down – the shape drawn last is the top row', () => { expect(shapeRowOrder(['bottom', 'middle', 'top'])).toEqual(['top', 'middle', 'bottom']); }); it('drops into the gap the pointer is nearest to', () => { expect(shapeDropGap(3, 2, ROW)).toBe(0); expect(shapeDropGap(3, 20, ROW)).toBe(1); expect(shapeDropGap(3, 44, ROW)).toBe(1); expect(shapeDropGap(3, 46, ROW)).toBe(2); // Outside the list there is no gap: a drop above it is the first, below it the last. expect(shapeDropGap(3, 400, ROW)).toBe(3); expect(shapeDropGap(3, -40, ROW)).toBe(0); }); it('turns a drop into a move of the model, the top row being the last shape', () => { // Three shapes, so the rows are the model indices [2, 1, 0]: the top row dropped at the // bottom gap becomes the first shape of the model, the one drawn first. expect(shapeDropMove(3, 0, 3)).toEqual({ from: 2, to: 0 }); expect(shapeDropMove(3, 2, 0)).toEqual({ from: 0, to: 2 }); expect(shapeDropMove(3, 1, 0)).toEqual({ from: 1, to: 2 }); }); it('is no move when the row lands where it already is', () => { expect(shapeDropMove(3, 1, 1)).toBeNull(); expect(shapeDropMove(3, 1, 2)).toBeNull(); expect(shapeDropMove(3, 5, 1)).toBeNull(); }); }); describe('shape rows', () => { it('shows fill, opacity and an outline switch, and no line rows without an outline', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); expect([...host.fields.keys()]).toEqual(['name', 'fill', 'opacity', 'outline']); expect(texts(host)).toContain('Without an outline only the fill is drawn.'); }); it('writes a fill as one undo step, a dragged colour previewing without one', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); const before = shapeOf(host, assetId).fill; const fill = widgets(host, ColorField)[0]!; fill.onInput!(0xff0000); expect(shapeOf(host, assetId).fill).toBe(0xff0000); fill.onChange!(0x00ff00); expect(shapeOf(host, assetId).fill).toBe(0x00ff00); host.state.undo(); expect(shapeOf(host, assetId).fill).toBe(before); }); it('writes the opacity, clamped to what an opacity can be', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); // A NumberField reads text: its own `onChange` is the one a typed value goes through. const opacity = widgets(host, NumberField)[0]!; opacity.onChange!('0.4'); expect(shapeOf(host, assetId).opacity).toBeCloseTo(0.4); opacity.onChange!('5'); expect(shapeOf(host, assetId).opacity).toBe(1); }); it('the outline switch adds a line and takes it away again', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); widgets(host, Checkbox)[0]!.toggle(); expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x000000, width: 2 }); host.state.undo(); expect(shapeOf(host, assetId).stroke).toBeUndefined(); }); it('shows the line rows once there is a line, and a width of nothing is not one', () => { const { host, assetId, ids } = withShapes(1); shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 }; buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); expect([...host.fields.keys()]).toEqual([ 'name', 'fill', 'opacity', 'outline', 'strokeColor', 'strokeWidth', ]); const width = widgets(host, NumberField)[1]!; // The command refuses a line of no width, so the row never sends one. width.onChange!('0'); expect(shapeOf(host, assetId).stroke?.width).toBe(0.1); widgets(host, ColorField)[1]!.onChange!(0xabcdef); expect(shapeOf(host, assetId).stroke).toEqual({ color: 0xabcdef, width: 0.1 }); }); it('names the shapes and the outline in its signature, so both rebuild the rows', () => { const { host, assetId, ids } = withShapes(2); const before = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]); shapeOf(host, assetId).name = 'Hat'; const renamed = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]); expect(renamed).not.toBe(before); shapeOf(host, assetId).stroke = { color: 0, width: 1 }; expect(shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!])).not.toBe(renamed); }); it('a shape that is gone asks for a rebuild instead of refreshing', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); expect(refreshShapeSection(host, assetId, ids[0]!)).toBe(true); expect(refreshShapeSection(host, assetId, 'gone')).toBe(false); expect(shapeSignature(host.state.project, assetId, 'gone', ['gone'])).toBe('missing'); }); it('refreshing pushes the model values back into the rows', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); const shape = shapeOf(host, assetId); shape.fill = 0x123456; shape.name = 'Cloud'; refreshShapeSection(host, assetId, ids[0]!); // The name field first, then the hex text inside the fill row – a name is a field, not a label. const fields = widgets(host, TextField); expect(fields[0]!.value).toBe('Cloud'); expect(fields[1]!.value).toBe(toHex(0x123456)); }); it('the asset itself shows the list and the way to add to it', () => { const { host, assetId } = withShapes(2); buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!); expect(texts(host)).toContain('SHAPES'); expect(texts(host)).toContain('The top row is drawn over the ones below it.'); }); it('an empty drawing says so instead of showing an empty list', () => { const { host, assetId } = withShapes(0); buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!); expect(texts(host).some((t) => t.startsWith('Nothing drawn yet'))).toBe(true); }); }); describe('combining from the inspector (#187)', () => { const button = (host: InspectorHost, text: string) => widgets(host, Button).find((b) => b.text === text)!; it('Ctrl+click adds a shape and takes it out again; emptied, the drawing stays selected', () => { const { host, assetId, ids } = withShapes(2); host.state.select(shapeSelection(assetId, [ids[0]!])); host.state.toggleShape(assetId, ids[1]!); expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[0], ids[1]] }); host.state.toggleShape(assetId, ids[1]!); host.state.toggleShape(assetId, ids[0]!); expect(host.state.selection).toEqual({ kind: 'asset', assetId }); }); it('switches the four buttons off for one shape and says how to get a second', () => { const { host, assetId, ids } = withShapes(2); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); expect(['Union', 'Subtract', 'Intersect', 'Exclude'].map((t) => button(host, t).eventMode)).toEqual([ 'none', 'none', 'none', 'none', ]); expect(texts(host)).toContain('Ctrl+click a second shape to combine.'); }); it('combines the selected shapes into the lowest one and selects it', () => { const { host, assetId, ids } = withShapes(3); host.state.select(shapeSelection(assetId, [ids[2]!, ids[1]!])); buildShapeSection(host, assetId, ids[1]!, [ids[2]!, ids[1]!]); expect(texts(host)).toContain('The result keeps "Shape 2", the lowest.'); button(host, 'Union').onClick!(); expect(findAssetOf(host.state.project, assetId, 'vectorart')!.shapes.map((s) => s.id)).toEqual([ ids[0], ids[1], ]); expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[1]] }); }); it('drops a selected shape that is gone and keeps the rest', () => { const { host, assetId, ids } = withShapes(3); host.state.select(shapeSelection(assetId, [ids[0]!, ids[2]!])); host.state.execute(new RemoveVectorShapeCommand(assetId, ids[2]!)); expect(host.state.selection).toMatchObject({ shapeId: ids[0], shapeIds: [ids[0]] }); }); it('names the selected shapes in the signature, so a Ctrl+click rebuilds the rows', () => { const { host, assetId, ids } = withShapes(2); const project = host.state.project; expect(shapeSignature(project, assetId, ids[0]!, [ids[0]!])).not.toBe( shapeSignature(project, assetId, ids[0]!, [ids[1]!, ids[0]!]), ); }); });