A selected vector shape has its own inspector rows (#129)
game-maker / gate (push) Successful in 2m12s
game-maker / suite (push) Successful in 7m45s

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:
adminandClaude Opus 5 committed 2026-09-20 22:29:40 +02:00
1 parent 3a68067079
commit 78595ec80f
9 files changed
+958 -8

No files matched your search

+1 -1
View File
@@ -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
+8
View File
@@ -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,
+4 -4
View File
@@ -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'
+6 -1
View File
@@ -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(
+342
View File
@@ -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,
}
);
}
+225
View File
@@ -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();
}
}
+36
View File
@@ -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 };
}
+228
View File
@@ -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
View File
@@ -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()') }),
);
}