A selected vector shape has its own inspector rows (#129)
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>
This commit is contained in:
1 parent
3a68067079
commit
78595ec80f
9 files changed
+958
-8
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1172 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1179 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -32,6 +32,7 @@ import { currentNodePalette } from './graph/NodePalette';
|
||||
import { currentSoundPicker } from './sound/SoundPicker';
|
||||
import { currentImagePicker } from './texture/ImagePicker';
|
||||
import { currentTilemapWidgets } from './panels/TilemapPanel';
|
||||
import { currentShapeList } from './inspector/shapeSection';
|
||||
import type { EditorState, TilemapTool } from './state';
|
||||
import { rectOf, textProbe } from './textProbe';
|
||||
|
||||
@@ -466,6 +467,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
name: shape.name,
|
||||
fill: shape.fill,
|
||||
opacity: shape.opacity,
|
||||
stroke: shape.stroke ? { ...shape.stroke } : null,
|
||||
rings: shape.rings.map((ring) => ({
|
||||
closed: ring.closed,
|
||||
points: ring.points.map((point) => ({
|
||||
@@ -478,6 +480,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
}));
|
||||
},
|
||||
selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''),
|
||||
/**
|
||||
* The shape list in the inspector (#129, stage 4): the order it shows, top first, and where a
|
||||
* row sits. A row carries a name that also stands in the tab, so a check cannot click it by text.
|
||||
*/
|
||||
shapeRows: () => currentShapeList()?.shownIds ?? null,
|
||||
shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null,
|
||||
},
|
||||
animation: {
|
||||
current: () => editor.animationEditor.currentAssetId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { annotationSignature, buildAnnotationSection, refreshAnnotationSection }
|
||||
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
|
||||
import type { InspectorHost } from './host';
|
||||
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
|
||||
import { buildShapeSection, refreshShapeSection, shapeSignature } from './shapeSection';
|
||||
import {
|
||||
buildObjectSection,
|
||||
buildObjectsSection,
|
||||
@@ -97,8 +98,7 @@ export class Inspector extends Panel {
|
||||
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
|
||||
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
|
||||
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
|
||||
// A shape shows its asset's rows until it has rows of its own (#129, stage 4).
|
||||
else if (s.kind === 'shape') buildAssetSection(this.host, s.assetId);
|
||||
else if (s.kind === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId);
|
||||
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
|
||||
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
|
||||
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
|
||||
@@ -112,7 +112,7 @@ export class Inspector extends Panel {
|
||||
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
|
||||
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
|
||||
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
|
||||
if (s.kind === 'shape') return assetSignature(this.state.project, s.assetId);
|
||||
if (s.kind === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId);
|
||||
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
|
||||
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
|
||||
return objectSignature(this.state, s.holderId, s.objectIds, s.objectId);
|
||||
@@ -141,7 +141,7 @@ export class Inspector extends Panel {
|
||||
: s.kind === 'frame'
|
||||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||||
: s.kind === 'shape'
|
||||
? refreshAssetSection(this.host, s.assetId)
|
||||
? refreshShapeSection(this.host, s.assetId, s.shapeId)
|
||||
: s.kind === 'annotation'
|
||||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||||
: s.kind === 'track'
|
||||
|
||||
@@ -33,6 +33,7 @@ import { SoundPreview } from '../sound/SoundPreview';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildShapeListSection, shapeListSignature } from './shapeSection';
|
||||
import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
|
||||
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
|
||||
import { assetValue } from './valueSpecs';
|
||||
@@ -48,9 +49,11 @@ export function assetSignature(project: Project, assetId: string): string {
|
||||
if (!asset) return 'missing';
|
||||
// A machine's own rows are none, so its scope has to be named here or switching it rebuilds nothing.
|
||||
const machine = asset.type === 'statemachine' ? `|${scopeOf(asset)}` : '';
|
||||
// Vector art shows its shapes under its rows (#129), so a shape added or renamed is a new structure.
|
||||
const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : '';
|
||||
const values = resolveAssetProps(project, asset);
|
||||
const rows = schemaForAsset(project, asset).filter((def) => isRowShown(def, values));
|
||||
return rows.map(defSignature).join(',') + machine;
|
||||
return rows.map(defSignature).join(',') + machine + shapes;
|
||||
}
|
||||
|
||||
export function defSignature(def: PropertyDef): string {
|
||||
@@ -79,6 +82,8 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
|
||||
addNote(host, 'Every graph pointing here shares them.', 'small');
|
||||
}
|
||||
if (asset.type === 'statemachine') buildMachineScope(host, asset);
|
||||
// The drawing itself: its shapes are picked here as a texture's frames are picked in its tab (#129).
|
||||
if (asset.type === 'vectorart') buildShapeListSection(host, asset);
|
||||
// A font has no properties at all, so without this its inspector would be the name and nothing.
|
||||
if (asset.type === 'font')
|
||||
host.body.add(
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* The rows of a vector art shape (#129, stage 4, answer 8): the list of shapes with a preview each,
|
||||
* the four buttons that move one in the drawing order, and the style of the selected one – fill,
|
||||
* opacity and outline. The canvas size is an ordinary property and stands in the asset's own rows.
|
||||
*
|
||||
* The list is built for both selections: picking the asset shows the shapes without any of them
|
||||
* being chosen, picking a shape shows the same list with its style underneath. That is why the
|
||||
* asset's signature has to name the shapes too – a shape added, removed or renamed changes the
|
||||
* rows, and only a rebuild puts the new list on screen.
|
||||
*/
|
||||
import {
|
||||
AddVectorShapeCommand,
|
||||
MoveVectorShapeCommand,
|
||||
RemoveVectorShapeCommand,
|
||||
RenameVectorShapeCommand,
|
||||
SetVectorShapeStyleCommand,
|
||||
} from '../../core/commands/vectorArtCommands';
|
||||
import type { VectorArtAsset, VectorShape, VectorStroke } from '../../core/model/Asset';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import {
|
||||
findShape,
|
||||
SHAPE_FILL_DEFAULT,
|
||||
type ShapeOrderMove,
|
||||
shapeOrderTarget,
|
||||
} from '../../core/model/vectorart';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, HStack, Theme } from '../../ui';
|
||||
import { ShapeList } from '../vectorart/ShapeList';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
|
||||
/** The outline a shape gets when one is switched on: black, two canvas pixels wide. */
|
||||
const DEFAULT_STROKE: VectorStroke = { color: 0x000000, width: 2 };
|
||||
|
||||
/** What a style row writes while it is being dragged; `stroke` is never taken away by a preview. */
|
||||
interface PreviewStyle {
|
||||
fill?: number;
|
||||
opacity?: number;
|
||||
stroke?: VectorStroke;
|
||||
}
|
||||
|
||||
const ORDER_BUTTONS: { move: ShapeOrderMove; label: string }[] = [
|
||||
{ move: 'front', label: 'To front' },
|
||||
{ move: 'forward', label: 'Forward' },
|
||||
{ move: 'backward', label: 'Backward' },
|
||||
{ move: 'back', label: 'To back' },
|
||||
];
|
||||
|
||||
const FILL_DEF: PropertyDef = { key: 'fill', label: 'Fill', kind: 'color', default: SHAPE_FILL_DEFAULT };
|
||||
const OPACITY_DEF: PropertyDef = {
|
||||
key: 'opacity',
|
||||
label: 'Opacity',
|
||||
kind: 'number',
|
||||
default: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
decimals: 2,
|
||||
};
|
||||
const OUTLINE_DEF: PropertyDef = { key: 'outline', label: 'Outline', kind: 'boolean', default: false };
|
||||
const STROKE_COLOR_DEF: PropertyDef = {
|
||||
key: 'strokeColor',
|
||||
label: 'Line colour',
|
||||
kind: 'color',
|
||||
default: DEFAULT_STROKE.color,
|
||||
};
|
||||
const STROKE_WIDTH_DEF: PropertyDef = {
|
||||
key: 'strokeWidth',
|
||||
label: 'Line width',
|
||||
kind: 'number',
|
||||
default: DEFAULT_STROKE.width,
|
||||
min: 0.1,
|
||||
step: 0.5,
|
||||
decimals: 1,
|
||||
};
|
||||
|
||||
/** The list as it was last built, so a check can address a row that carries a name, not a widget. */
|
||||
let built: ShapeList | null = null;
|
||||
|
||||
/** Null while no vector art shapes are on screen. */
|
||||
export function currentShapeList(): ShapeList | null {
|
||||
return built;
|
||||
}
|
||||
|
||||
/** What the shapes of an asset make of the rows: their order, their names, and who is drawn. */
|
||||
export function shapeListSignature(asset: VectorArtAsset): string {
|
||||
return asset.shapes.map((shape) => `${shape.id}:${shape.name}`).join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure-defining facts of a shape selection: the list above it, and whether the shape has an
|
||||
* outline – its colour and width are rows of their own and appear with it.
|
||||
*/
|
||||
export function shapeSignature(project: Project, assetId: string, shapeId: string): string {
|
||||
const asset = findAssetOf(project, assetId, 'vectorart');
|
||||
const shape = asset && findShape(asset, shapeId);
|
||||
if (!asset || !shape) return 'missing';
|
||||
return `${shapeListSignature(asset)}|${shape.stroke ? 'stroke' : 'none'}`;
|
||||
}
|
||||
|
||||
/** The shapes of the asset, with nothing chosen: what the asset's own section shows underneath. */
|
||||
export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset): void {
|
||||
addTitle(host, 'SHAPES');
|
||||
addShapeList(host, asset, '');
|
||||
addNote(host, 'The top row is drawn over the ones below it.', 'small');
|
||||
addGap(host);
|
||||
host.body.add(addShapeButton(host, asset.id));
|
||||
}
|
||||
|
||||
/** Name, place in the drawing order and style of one shape, under the list it is picked from. */
|
||||
export function buildShapeSection(host: InspectorHost, assetId: string, shapeId: string): void {
|
||||
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
|
||||
const shape = asset && findShape(asset, shapeId);
|
||||
if (!asset || !shape) return;
|
||||
|
||||
addTitle(host, 'SHAPES');
|
||||
addShapeList(host, asset, shapeId);
|
||||
addNote(host, 'Drag a row to draw it earlier or later.', 'small');
|
||||
|
||||
// Two rows of two: four of them side by side cut "Backward" down to "Backw…" at the panel's width.
|
||||
for (let start = 0; start < ORDER_BUTTONS.length; start += 2) {
|
||||
const order = new HStack({ gap: Theme.gap });
|
||||
for (const { move, label } of ORDER_BUTTONS.slice(start, start + 2)) {
|
||||
const button = new Button(label, {
|
||||
variant: 'ghost',
|
||||
onClick: () => moveShape(host, assetId, shapeId, move),
|
||||
});
|
||||
// A shape already at the front has nowhere to go forwards.
|
||||
button.setEnabled(shapeOrderTarget(asset.shapes, shapeId, move) >= 0);
|
||||
order.add(button, 1);
|
||||
}
|
||||
host.body.add(order);
|
||||
}
|
||||
|
||||
addTitle(host, 'VECTOR SHAPE');
|
||||
addShapeName(host, assetId, shape);
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
shapeId,
|
||||
FILL_DEF,
|
||||
(s) => s.fill,
|
||||
(input) => ({ fill: Number(input) }),
|
||||
'Set fill',
|
||||
);
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
shapeId,
|
||||
OPACITY_DEF,
|
||||
(s) => s.opacity,
|
||||
(input) => ({ opacity: Number(input) }),
|
||||
'Set opacity',
|
||||
);
|
||||
outlineRow(host, assetId, shapeId, shape);
|
||||
if (shape.stroke) {
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
shapeId,
|
||||
STROKE_COLOR_DEF,
|
||||
(s) => s.stroke?.color ?? DEFAULT_STROKE.color,
|
||||
(input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }),
|
||||
'Set line colour',
|
||||
);
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
shapeId,
|
||||
STROKE_WIDTH_DEF,
|
||||
(s) => s.stroke?.width ?? DEFAULT_STROKE.width,
|
||||
(input, s) => ({ stroke: { ...strokeOf(s), width: Math.max(0.1, Number(input)) } }),
|
||||
'Set line width',
|
||||
);
|
||||
} else addNote(host, 'Without an outline only the fill is drawn.', 'small');
|
||||
|
||||
addGap(host);
|
||||
host.body.add(
|
||||
new Button('Delete shape', {
|
||||
variant: 'danger',
|
||||
onClick: () => {
|
||||
host.state.execute(new RemoveVectorShapeCommand(assetId, shapeId));
|
||||
host.state.select({ kind: 'asset', assetId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
host.body.add(addShapeButton(host, assetId));
|
||||
}
|
||||
|
||||
/** Pushes the shape's current name and style into the rows. False when it is gone (rebuild). */
|
||||
export function refreshShapeSection(host: InspectorHost, assetId: string, shapeId: string): boolean {
|
||||
const shape = currentShape(host, assetId, shapeId);
|
||||
if (!shape) return false;
|
||||
host.fields.get('name')?.setValue(shape.name);
|
||||
host.fields.get('fill')?.setValue(shape.fill);
|
||||
host.fields.get('opacity')?.setValue(shape.opacity);
|
||||
host.fields.get('outline')?.setValue(Boolean(shape.stroke));
|
||||
host.fields.get('strokeColor')?.setValue(shape.stroke?.color ?? DEFAULT_STROKE.color);
|
||||
host.fields.get('strokeWidth')?.setValue(shape.stroke?.width ?? DEFAULT_STROKE.width);
|
||||
return true;
|
||||
}
|
||||
|
||||
function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedId: string): void {
|
||||
if (asset.shapes.length === 0) {
|
||||
built = null;
|
||||
addNote(host, 'Nothing drawn yet – take the Pen in the Vector art tab.');
|
||||
return;
|
||||
}
|
||||
const list = new ShapeList(
|
||||
host.state,
|
||||
asset.id,
|
||||
asset.shapes.map((shape) => shape.id),
|
||||
selectedId,
|
||||
(shapeId) => host.state.select({ kind: 'shape', assetId: asset.id, shapeId }),
|
||||
(from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)),
|
||||
);
|
||||
built = list;
|
||||
host.body.add(list);
|
||||
}
|
||||
|
||||
function addShapeButton(host: InspectorHost, assetId: string): Button {
|
||||
return new Button('Add shape', {
|
||||
onClick: () => {
|
||||
const command = new AddVectorShapeCommand(assetId);
|
||||
if (host.state.execute(command))
|
||||
host.state.select({ kind: 'shape', assetId, shapeId: command.shapeId });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The name row. Not `addNameField`: the shape's name also stands in the list right above it, and a
|
||||
* blank one is refused by the command, which cannot put the old text back into the field.
|
||||
*/
|
||||
function addShapeName(host: InspectorHost, assetId: string, shape: VectorShape): void {
|
||||
const row = createFieldRow(host, { key: 'name', label: 'Name', kind: 'text', default: '' }, shape.name, {
|
||||
onInput: () => {},
|
||||
onChange: (value) => {
|
||||
const name = textOf(value).trim();
|
||||
if (name && name !== shape.name)
|
||||
host.state.execute(new RenameVectorShapeCommand(assetId, shape.id, name));
|
||||
else host.fields.get('name')?.setValue(shape.name);
|
||||
},
|
||||
});
|
||||
host.fields.set('name', row);
|
||||
host.nameField = null;
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
function moveShape(host: InspectorHost, assetId: string, shapeId: string, move: ShapeOrderMove): void {
|
||||
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
|
||||
if (!asset) return;
|
||||
const from = asset.shapes.findIndex((shape) => shape.id === shapeId);
|
||||
const to = shapeOrderTarget(asset.shapes, shapeId, move);
|
||||
if (from < 0 || to < 0) return;
|
||||
host.state.execute(new MoveVectorShapeCommand(assetId, from, to));
|
||||
}
|
||||
|
||||
/** An outline switched on gets a black line; switched off it is taken away, not made invisible. */
|
||||
function outlineRow(host: InspectorHost, assetId: string, shapeId: string, shape: VectorShape): void {
|
||||
const row = createFieldRow(host, OUTLINE_DEF, Boolean(shape.stroke), {
|
||||
onInput: () => {},
|
||||
onChange: (value) => {
|
||||
const current = currentShape(host, assetId, shapeId);
|
||||
if (!current) return;
|
||||
const stroke = value ? (current.stroke ?? DEFAULT_STROKE) : null;
|
||||
host.state.execute(
|
||||
new SetVectorShapeStyleCommand(
|
||||
assetId,
|
||||
shapeId,
|
||||
{ stroke },
|
||||
value ? 'Add outline' : 'Remove outline',
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
host.fields.set('outline', row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
/**
|
||||
* One style row, previewing into the shape while a colour is dragged and committing on release –
|
||||
* the same rule every other row follows. `toStyle` is asked on the shape as it is at that moment,
|
||||
* so a line width typed after a colour does not take the colour back.
|
||||
*/
|
||||
function styleRow(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
shapeId: string,
|
||||
def: PropertyDef,
|
||||
read: (shape: VectorShape) => number,
|
||||
toStyle: (input: PropertyValue, shape: VectorShape) => PreviewStyle,
|
||||
label: string,
|
||||
): void {
|
||||
const shape = currentShape(host, assetId, shapeId);
|
||||
const handlers: FieldHandlers = previewingHandlers<VectorShape, PropertyValue>(host, {
|
||||
target: () => currentShape(host, assetId, shapeId),
|
||||
read,
|
||||
write: (target, value) => applyPreview(target, toStyle(value, target)),
|
||||
commit: (value) =>
|
||||
new SetVectorShapeStyleCommand(
|
||||
assetId,
|
||||
shapeId,
|
||||
toStyle(value, shapeNow(host, assetId, shapeId)),
|
||||
label,
|
||||
),
|
||||
});
|
||||
const row = createFieldRow(host, def, shape ? read(shape) : def.default, handlers);
|
||||
host.fields.set(def.key, row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
/** Writes a previewed style straight into the shape; the command records the value from before. */
|
||||
function applyPreview(shape: VectorShape, style: PreviewStyle): void {
|
||||
if (style.fill !== undefined) shape.fill = style.fill;
|
||||
if (style.opacity !== undefined) shape.opacity = Math.max(0, Math.min(1, style.opacity));
|
||||
if (style.stroke !== undefined) shape.stroke = { ...style.stroke };
|
||||
}
|
||||
|
||||
function strokeOf(shape: VectorShape): VectorStroke {
|
||||
return shape.stroke ?? DEFAULT_STROKE;
|
||||
}
|
||||
|
||||
function currentShape(host: InspectorHost, assetId: string, shapeId: string): VectorShape | undefined {
|
||||
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
|
||||
return asset ? findShape(asset, shapeId) : undefined;
|
||||
}
|
||||
|
||||
/** The shape as it is right now, or an empty stand-in – a commit built after the selection went. */
|
||||
function shapeNow(host: InspectorHost, assetId: string, shapeId: string): VectorShape {
|
||||
return (
|
||||
currentShape(host, assetId, shapeId) ?? {
|
||||
id: shapeId,
|
||||
name: '',
|
||||
rings: [],
|
||||
fill: SHAPE_FILL_DEFAULT,
|
||||
opacity: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* The shapes of a vector art asset as a list with a preview each (#129, stage 4). It stands in the
|
||||
* inspector, over the rows that colour the selected shape – a panel of its own is #191.
|
||||
*
|
||||
* Two things make it more than a column of buttons. Each row draws the shape itself, with the same
|
||||
* commands the rasteriser uses, placed on the canvas it sits on: two shapes of the same colour are
|
||||
* told apart by where they are, which a name rarely says. And the list is the drawing order, so a
|
||||
* row may be dragged to another place in it – the topmost row is the shape drawn last, as in every
|
||||
* layer list.
|
||||
*
|
||||
* The rows are fixed when the list is built: the inspector section rebuilds whenever a shape is
|
||||
* added, removed or renamed. A shape merely edited repaints its preview, which is why the shapes
|
||||
* themselves are read out of the model on every redraw instead of being kept here.
|
||||
*/
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import type { VectorArtAsset, VectorShape } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { shapeDrawing } from '../../core/model/vectorPath';
|
||||
import { paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
import { type Size, Theme, textStyle, truncate, Widget } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { shapeDropGap, shapeDropMove, shapeRowOrder } from './shapeRows';
|
||||
|
||||
/** Height of one row: the swatch plus the air a hovered row needs to look like a row. */
|
||||
export const SHAPE_ROW_HEIGHT = 30;
|
||||
const SWATCH = 22;
|
||||
const PAD = 4;
|
||||
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
interface Row {
|
||||
box: Container;
|
||||
art: Graphics;
|
||||
clip: Graphics;
|
||||
label: Text;
|
||||
}
|
||||
|
||||
interface RowDrag {
|
||||
/** Index of the pressed row in display order. */
|
||||
from: number;
|
||||
startY: number;
|
||||
y: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export class ShapeList extends Widget {
|
||||
private readonly background = new Graphics();
|
||||
private readonly marker = new Graphics();
|
||||
private readonly rows: Row[] = [];
|
||||
/** Display order, top first – the reverse of the model, whose last shape lies on top. */
|
||||
private readonly shown: readonly string[];
|
||||
private hoveredRow = -1;
|
||||
private drag: RowDrag | null = null;
|
||||
private readonly stop: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
private readonly assetId: string,
|
||||
shapeIds: readonly string[],
|
||||
private selectedId: string,
|
||||
private readonly onPick: (shapeId: string) => void,
|
||||
private readonly onReorder: (from: number, to: number) => void,
|
||||
) {
|
||||
super();
|
||||
this.shown = shapeRowOrder(shapeIds);
|
||||
this.cursor = Theme.hoverCursor;
|
||||
this.addChild(this.background);
|
||||
for (const _ of this.shown) this.rows.push(this.buildRow());
|
||||
this.marker.eventMode = 'none';
|
||||
this.addChild(this.marker);
|
||||
// A point dragged in the canvas changes no row, only what a row shows.
|
||||
this.stop = state.on('project', () => this.invalidate());
|
||||
|
||||
this.on('pointermove', (e: FederatedPointerEvent) => {
|
||||
const index = this.rowAt(this.toLocal(e.global).y);
|
||||
if (index === this.hoveredRow) return;
|
||||
this.hoveredRow = index;
|
||||
this.invalidate();
|
||||
});
|
||||
this.on('pointerout', () => {
|
||||
if (this.hoveredRow < 0) return;
|
||||
this.hoveredRow = -1;
|
||||
this.invalidate();
|
||||
});
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onMove(e));
|
||||
this.on('pointerup', () => this.onRelease());
|
||||
this.on('pointerupoutside', () => this.onRelease());
|
||||
}
|
||||
|
||||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||||
this.stop();
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: this.minWidth, h: Math.max(SHAPE_ROW_HEIGHT, this.shown.length * SHAPE_ROW_HEIGHT) };
|
||||
}
|
||||
|
||||
/** Where a shape's row sits on screen (test hook); null when the list does not show it. */
|
||||
rowGlobal(shapeId: string): { x: number; y: number; w: number; h: number } | null {
|
||||
const index = this.shown.indexOf(shapeId);
|
||||
if (index < 0) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x, y: origin.y + index * SHAPE_ROW_HEIGHT, w: this.w, h: SHAPE_ROW_HEIGHT };
|
||||
}
|
||||
|
||||
/** The shapes as the list shows them, top first – for the check scripts. */
|
||||
get shownIds(): readonly string[] {
|
||||
return this.shown;
|
||||
}
|
||||
|
||||
private buildRow(): Row {
|
||||
const box = new Container();
|
||||
const art = new Graphics();
|
||||
const clip = new Graphics();
|
||||
const label = new Text({ text: '', style: textStyle('body') });
|
||||
box.eventMode = 'none';
|
||||
box.addChild(art, clip, label);
|
||||
art.mask = clip;
|
||||
this.addChild(box);
|
||||
return { box, art, clip, label };
|
||||
}
|
||||
|
||||
private asset(): VectorArtAsset | null {
|
||||
return findAssetOf(this.state.project, this.assetId, 'vectorart') ?? null;
|
||||
}
|
||||
|
||||
private rowAt(y: number): number {
|
||||
const index = Math.floor(y / SHAPE_ROW_HEIGHT);
|
||||
return index >= 0 && index < this.shown.length ? index : -1;
|
||||
}
|
||||
|
||||
private dropGap(y: number): number {
|
||||
return shapeDropGap(this.shown.length, y, SHAPE_ROW_HEIGHT);
|
||||
}
|
||||
|
||||
private onPress(e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
const y = this.toLocal(e.global).y;
|
||||
const index = this.rowAt(y);
|
||||
if (index < 0) return;
|
||||
e.stopPropagation();
|
||||
// Picked right away rather than on release: the rows below show what the selected shape is.
|
||||
const shapeId = this.shown[index]!;
|
||||
if (shapeId !== this.selectedId) {
|
||||
this.selectedId = shapeId;
|
||||
this.onPick(shapeId);
|
||||
}
|
||||
this.drag = { from: index, startY: y, y, active: false };
|
||||
}
|
||||
|
||||
private onMove(e: FederatedPointerEvent): void {
|
||||
const drag = this.drag;
|
||||
if (!drag) return;
|
||||
drag.y = this.toLocal(e.global).y;
|
||||
if (!drag.active && Math.abs(drag.y - drag.startY) < DRAG_THRESHOLD) return;
|
||||
drag.active = true;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
private onRelease(): void {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
if (!drag) return;
|
||||
this.invalidate();
|
||||
if (!drag.active) return;
|
||||
const move = shapeDropMove(this.shown.length, drag.from, this.dropGap(drag.y));
|
||||
if (move) this.onReorder(move.from, move.to);
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const asset = this.asset();
|
||||
this.background.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.control);
|
||||
this.marker.clear();
|
||||
for (const [index, shapeId] of this.shown.entries()) {
|
||||
const row = this.rows[index]!;
|
||||
const shape = asset?.shapes.find((s) => s.id === shapeId);
|
||||
const y = index * SHAPE_ROW_HEIGHT;
|
||||
row.box.position.set(0, y);
|
||||
row.box.visible = Boolean(shape);
|
||||
if (!shape) continue;
|
||||
const selected = shapeId === this.selectedId;
|
||||
if (selected || index === this.hoveredRow)
|
||||
this.background
|
||||
.rect(0, y, this.w, SHAPE_ROW_HEIGHT)
|
||||
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
|
||||
this.paintSwatch(row, asset!, shape);
|
||||
row.label.style = textStyle(selected ? 'body' : 'muted');
|
||||
row.label.text = truncate(shape.name, row.label.style, Math.max(0, this.w - SWATCH - PAD * 3));
|
||||
row.label.position.set(PAD * 2 + SWATCH, (SHAPE_ROW_HEIGHT - row.label.height) / 2);
|
||||
row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1;
|
||||
}
|
||||
if (this.drag?.active) {
|
||||
const y = this.dropGap(this.drag.y) * SHAPE_ROW_HEIGHT;
|
||||
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape alone, on the canvas it sits on: the whole canvas is scaled into the swatch, so a
|
||||
* shape in the corner is drawn in the corner. What lies outside the canvas is cut off here as it
|
||||
* is in the picture – the mask is the swatch, the canvas being scaled onto exactly that box.
|
||||
*/
|
||||
private paintSwatch(row: Row, asset: VectorArtAsset, shape: VectorShape): void {
|
||||
const { width, height } = vectorCanvasSize(asset.props);
|
||||
const scale = Math.min(SWATCH / width, SWATCH / height);
|
||||
const x = PAD + (SWATCH - width * scale) / 2;
|
||||
const y = (SHAPE_ROW_HEIGHT - height * scale) / 2;
|
||||
row.clip
|
||||
.clear()
|
||||
.rect(x, y, width * scale, height * scale)
|
||||
.fill(0xffffff);
|
||||
row.art.clear();
|
||||
row.art.position.set(x, y);
|
||||
row.art.scale.set(scale);
|
||||
const drawing = shapeDrawing(shape);
|
||||
if (drawing) paintDrawing(row.art, drawing);
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* How the shape list of #129 (stage 4) maps between what is on screen and what the model holds.
|
||||
* The list shows the drawing order upside down – the shape drawn last is the topmost row, as in
|
||||
* every layer list – so every index has two readings, and dropping a dragged row has a third: the
|
||||
* place it lands in is counted with the row still in the list, while the model takes it out first.
|
||||
*
|
||||
* Kept apart from the widget so the arithmetic can be measured without a canvas.
|
||||
*/
|
||||
|
||||
/** The ids as the list shows them, top first. */
|
||||
export function shapeRowOrder<T>(ids: readonly T[]): T[] {
|
||||
return [...ids].reverse();
|
||||
}
|
||||
|
||||
/** The gap a row dropped at `y` falls into, 0…count – the boundary above row 0 being 0. */
|
||||
export function shapeDropGap(count: number, y: number, rowHeight: number): number {
|
||||
if (rowHeight <= 0) return 0;
|
||||
return Math.max(0, Math.min(count, Math.round(y / rowHeight)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The move a drop makes, in model indices, or null when the row would land where it already is.
|
||||
* `fromRow` and `gap` are in display positions; a gap below the dragged row counts that row itself,
|
||||
* which the model does not once it has taken it out.
|
||||
*/
|
||||
export function shapeDropMove(
|
||||
count: number,
|
||||
fromRow: number,
|
||||
gap: number,
|
||||
): { from: number; to: number } | null {
|
||||
if (fromRow < 0 || fromRow >= count) return null;
|
||||
const toRow = gap > fromRow ? gap - 1 : gap;
|
||||
if (toRow === fromRow || toRow < 0 || toRow >= count) return null;
|
||||
const last = count - 1;
|
||||
return { from: last - fromRow, to: last - toRow };
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
+108
-2
@@ -69,8 +69,23 @@ const SHAPES = [
|
||||
];
|
||||
|
||||
export async function vectorArt(t) {
|
||||
const { check, click, doubleClick, drag, clickText, json, evaluate, mutate, settle, sleep, shot, showTab } =
|
||||
t;
|
||||
const {
|
||||
check,
|
||||
click,
|
||||
doubleClick,
|
||||
drag,
|
||||
clickText,
|
||||
json,
|
||||
evaluate,
|
||||
locate,
|
||||
mutate,
|
||||
press,
|
||||
settle,
|
||||
sleep,
|
||||
shot,
|
||||
showTab,
|
||||
typeText,
|
||||
} = t;
|
||||
const Y = LANES.vectorArt;
|
||||
|
||||
// 47a. A drawing on a sprite: what the rasteriser put on the GPU, read off the screen.
|
||||
@@ -235,4 +250,95 @@ export async function vectorArt(t) {
|
||||
Boolean(undrawn) && undrawn.file !== after?.file && undrawn.kept,
|
||||
JSON.stringify({ was: after?.file, now: undrawn?.file }),
|
||||
);
|
||||
|
||||
// 47e. The inspector (stage 4): the shapes are listed, picked, coloured and reordered there.
|
||||
const shapeIds = async () => (await json("__gm.vectorArt.shapes('va-art')")).map((s) => s.id);
|
||||
const shapeOf = async (id) =>
|
||||
(await json("__gm.vectorArt.shapes('va-art')")).find((shape) => shape.id === id);
|
||||
const rows = await json('__gm.vectorArt.shapeRows()');
|
||||
const model = await shapeIds();
|
||||
check(
|
||||
'the inspector lists the shapes, the one drawn last on top',
|
||||
Array.isArray(rows) && rows.length === model.length && rows.join() === [...model].reverse().join(),
|
||||
JSON.stringify({ rows, model }),
|
||||
);
|
||||
|
||||
// A row is picked by where it sits: its name also stands in the tab, so text would be ambiguous.
|
||||
const fieldRow = await json("__gm.vectorArt.shapeRowAt('va-field')");
|
||||
await click(fieldRow.x + fieldRow.w / 2, fieldRow.y + fieldRow.h / 2);
|
||||
await settle();
|
||||
check(
|
||||
'clicking a row selects that shape',
|
||||
(await json('__gm.vectorArt.selected()')) === 'va-field',
|
||||
JSON.stringify({ selected: await json('__gm.vectorArt.selected()'), row: fieldRow }),
|
||||
);
|
||||
|
||||
// The control of a row, not an offset from its label: a wider label pushes its control right.
|
||||
const fillField = await json("__gm.inspectorField('Fill')");
|
||||
await click(fillField.x, fillField.y);
|
||||
// The click puts a caret in the hex text rather than selecting it, so the old value goes first.
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('20c0e8');
|
||||
await press('Enter');
|
||||
await settle();
|
||||
const recoloured = await shapeOf('va-field');
|
||||
// Read off the tab as well: a row that only writes the model would show nothing to the eye.
|
||||
const spot = await json('__gm.vectorArt.pointAt(48, 200)');
|
||||
const painted = await json(`__gm.pixelAt(${Math.round(spot.x)}, ${Math.round(spot.y)})`);
|
||||
check(
|
||||
'the Fill row recolours the shape, and the tab shows it',
|
||||
recoloured?.fill === 0x20c0e8 && colourDistance(painted, '#20c0e8') < 14,
|
||||
JSON.stringify({ fill: recoloured?.fill?.toString(16), painted }),
|
||||
);
|
||||
|
||||
const outlineField = await json("__gm.inspectorField('Outline')");
|
||||
await click(outlineField.x, outlineField.y);
|
||||
await settle();
|
||||
const outlined = await shapeOf('va-field');
|
||||
check(
|
||||
'the Outline switch gives the shape a line, and its rows appear with it',
|
||||
Boolean(outlined?.stroke) && outlined.stroke.width === 2 && Boolean(await locate('Line width')),
|
||||
JSON.stringify(outlined?.stroke),
|
||||
);
|
||||
|
||||
// The badge was drawn over the field; "To front" puts the field over the badge instead.
|
||||
const covered = await json('__gm.vectorArt.pointAt(204, 60)');
|
||||
const wasBadge = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
|
||||
await clickText('To front');
|
||||
await settle();
|
||||
const nowField = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
|
||||
check(
|
||||
'To front draws the shape over the one that covered it',
|
||||
(await shapeIds()).at(-1) === 'va-field' &&
|
||||
colourDistance(wasBadge, BADGE) < 14 &&
|
||||
colourDistance(nowField, '#20c0e8') < 14,
|
||||
JSON.stringify({ order: await shapeIds(), wasBadge, nowField }),
|
||||
);
|
||||
|
||||
await shot('47-shape-rows');
|
||||
|
||||
// Dragging the top row to the bottom of the list is the same move the other way round.
|
||||
const top = await json("__gm.vectorArt.shapeRowAt('va-field')");
|
||||
await drag(
|
||||
top.x + top.w / 2,
|
||||
top.y + top.h / 2,
|
||||
top.x + top.w / 2,
|
||||
top.y + top.h * (await shapeIds()).length,
|
||||
);
|
||||
await settle();
|
||||
check(
|
||||
'dragging a row to the bottom draws that shape first',
|
||||
(await shapeIds())[0] === 'va-field',
|
||||
JSON.stringify(await shapeIds()),
|
||||
);
|
||||
|
||||
const before47e = (await shapeIds()).length;
|
||||
await clickText('Add shape');
|
||||
await settle();
|
||||
const added = await shapeIds();
|
||||
check(
|
||||
'Add shape puts an empty shape on top and selects it',
|
||||
added.length === before47e + 1 && (await json('__gm.vectorArt.selected()')) === added.at(-1),
|
||||
JSON.stringify({ added: added.length, selected: await json('__gm.vectorArt.selected()') }),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user