/** * 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, SetVectorShapeStyleCommand, } from '../../src/core/commands/vectorArtCommands'; import type { VectorArtAsset, VectorGradient } 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, setFillTexture, setPaintKind, shapeSignature, } from '../../src/editor/inspector/shapeSection'; import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows'; import { EditorState, shapeSelection } from '../../src/editor/state'; import { PaintCache } from '../../src/viewport/vectorArtPicture'; 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; // A gradient (#186) paints itself onto a browser canvas too; the rows under test do not need it. PaintCache.prototype.gradient = (() => null) 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', 'fillType', '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', 'fillType', 'fill', 'opacity', 'outline', 'lineType', '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]!]), ); }); }); describe('fill and line types (#186)', () => { const button = (host: InspectorHost, text: string) => widgets(host, Button).find((b) => b.text === text)!; const rebuild = (host: InspectorHost, assetId: string, shapeId: string) => { host.body.removeChildren(); host.fields.clear(); buildShapeSection(host, assetId, shapeId, [shapeId]); }; it('switches the fill to a gradient in one undo step, and the colour row makes way for the stops', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); setPaintKind(host.state, assetId, ids[0]!, 'fill', 'linear'); expect(shapeOf(host, assetId).paint).toMatchObject({ type: 'linear', stops: [{ offset: 0 }, { offset: 1 }], }); const signature = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]); expect(signature).toContain('linear/none'); rebuild(host, assetId, ids[0]!); expect([...host.fields.keys()]).toEqual([ 'name', 'fillType', 'fillStopColor', 'fillStopAlpha', 'fillStopOffset', 'opacity', 'outline', ]); host.state.undo(); expect(shapeOf(host, assetId).paint).toBeUndefined(); }); it('edits the picked stop, and "Remove stop" only works while three are left', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); setPaintKind(host.state, assetId, ids[0]!, 'fill', 'radial'); rebuild(host, assetId, ids[0]!); // The stop's colour is the only colour row left; opacity of the stop is the first number row. widgets(host, ColorField)[0]!.onChange!(0xff0000); widgets(host, NumberField)[0]!.onChange!('0.5'); const paint = shapeOf(host, assetId).paint as VectorGradient; expect(paint.stops[0]).toEqual({ offset: 0, color: 0xff0000, alpha: 0.5 }); expect(button(host, 'Remove stop').eventMode).toBe('none'); host.state.execute( new SetVectorShapeStyleCommand(assetId, ids[0]!, { paint: { ...paint, stops: [...paint.stops, { offset: 0.5, color: 0, alpha: 1 }] }, }), ); rebuild(host, assetId, ids[0]!); expect(button(host, 'Remove stop').eventMode).not.toBe('none'); button(host, 'Remove stop').onClick!(); expect((shapeOf(host, assetId).paint as VectorGradient).stops.map((s) => s.offset)).toEqual([1, 0.5]); }); it('gives the outline a type of its own, with the line colour as its first stop', () => { const { host, assetId, ids } = withShapes(1); shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 }; buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); setPaintKind(host.state, assetId, ids[0]!, 'stroke', 'linear'); expect(shapeOf(host, assetId).stroke).toMatchObject({ color: 0x112233, width: 3, paint: { type: 'linear' }, }); expect((shapeOf(host, assetId).stroke!.paint as VectorGradient).stops[0]?.color).toBe(0x112233); setPaintKind(host.state, assetId, ids[0]!, 'stroke', 'solid'); expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x112233, width: 3 }); }); it('a texture fill shows the picker, and picking writes the id into the paint', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); setPaintKind(host.state, assetId, ids[0]!, 'fill', 'texture'); rebuild(host, assetId, ids[0]!); expect(host.fields.has('fillTexture')).toBe(true); setFillTexture(host.state, assetId, ids[0]!, 't1'); expect(shapeOf(host, assetId).paint).toMatchObject({ type: 'texture', texture: 't1' }); }); });