Reordering shapes by dragging worked well in the vector tab. Pixel art layers and frames and tilemap layers still used up/down buttons. All three lists now share one widget built from the shape list. - SortableList in the UI toolkit, with the drag rules next to it in src/ui/sortable.ts. A row has an optional thumbnail, a name, a hint on the right and an eye toggle. Every drop is one undo step. - The shape list is a thin wrapper around it. shapeRows.ts is gone, its tests moved to the new rules. - Pixel art layers show the layer alone on the current frame, frames show the whole frame. Up/Down, Hide, Earlier and Later are gone. - Tilemap layers get the eye instead of the checkbox. The per-row up, down and delete buttons are replaced by Delete layer next to Add layer. - A row is selected on release instead of press. Selecting rebuilt the inspector or tilemap panel and cancelled a drag that started on a row not yet selected. Tests: - ./check.sh: 138 files, 2052 tests, exit 0 (before 137 / 2051). - GM_STEPS=39,40,47,48 (fresh seed): ALL PASSED, 154 checks. - 5 new e2e checks. Check total 1264 in the README is computed (1259 + 5), CI never measured 1259. - Counter-checks bite: dropMove ignoring reversed fails a unit test, a tilemap drop not applied fails the drag check, a layer thumbnail showing all layers fails the thumbnail check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
14 KiB
TypeScript
343 lines
14 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,
|
||
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 { EditorState, shapeSelection } from '../../src/editor/state';
|
||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||
|
||
/**
|
||
* 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<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 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' });
|
||
});
|
||
});
|