Stage 4 of #129. Until now the inspector showed the rows of the asset when a shape was selected, so there was no way to give a shape a colour, an outline or a place in the drawing order without editing the project file. The rows for a shape now sit where every other selection puts its rows. - SHAPES lists every shape of the asset, topmost row drawn last, the same way a layer list reads. Selecting the asset itself shows the same list below its own rows, with no shape selected. - Each row paints its shape with the commands the rasterizer uses (shapeDrawing plus paintDrawing) into a 22 px swatch, the whole canvas scaled in, so the row also says where on the canvas the shape sits. - The four order buttons from the issue (forward, to front, backward, to back) sit in two rows of two, and a row can be dragged to another place with a marker line. They are disabled when there is nothing to move. - VECTOR SHAPE carries name, fill, opacity and an outline checkbox, the checkbox adding and removing the stroke instead of hiding it; line colour and width appear with it. A dragged colour slider is one undo step, the style rows going through previewingHandlers like every other row. - A line width of 0 is no stroke, which the command rejects, so the row clamps at 0.1. Findings: - locate('Outline') aims beside the control, because it gives the centre of the label text and a wider label pushes a fixed offset out of the field. __gm.inspectorField(label) knows the centre of the control. - A click into a text field sets the cursor and selects nothing, so a typed hex landed appended to the old one and parseHex returned null without anything failing - typeText only checks that the text is contained. A Ctrl+A goes first now. - Four buttons side by side cut "Backward" to "Backw...", which only the screenshot showed. Two rows of two now, as in the tilemap panel. Tests: ./check.sh 128 files, 1897 tests (was 127/1883). Full e2e suite locally instead of only CI because the change sits in the inspector and touches every selection: ALL PASSED - 1179 checks (was 1172), module 47 at 24 checks (was 17). Three counter-checks hold: not reversing the list fails 1 unit test and 2 e2e checks, dropping the gap correction when dropping a row fails 2 unit tests and the drag check, and a constant signature kills module 47 because the outline rows never appear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
9.0 KiB
TypeScript
229 lines
9.0 KiB
TypeScript
/**
|
||
* 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 } 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 {
|
||
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 } 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<T extends Widget>(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]!);
|
||
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]!);
|
||
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]!);
|
||
// 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]!);
|
||
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]!);
|
||
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]!);
|
||
shapeOf(host, assetId).name = 'Hat';
|
||
const renamed = shapeSignature(host.state.project, assetId, ids[0]!);
|
||
expect(renamed).not.toBe(before);
|
||
shapeOf(host, assetId).stroke = { color: 0, width: 1 };
|
||
expect(shapeSignature(host.state.project, assetId, 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]!);
|
||
expect(refreshShapeSection(host, assetId, ids[0]!)).toBe(true);
|
||
expect(refreshShapeSection(host, assetId, 'gone')).toBe(false);
|
||
expect(shapeSignature(host.state.project, assetId, 'gone')).toBe('missing');
|
||
});
|
||
|
||
it('refreshing pushes the model values back into the rows', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, 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);
|
||
});
|
||
});
|