diff --git a/README.md b/README.md index e2a349d9..8d157dee 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 8f6bd0fe..9442ec54 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index c8c6798c..a9ba31d1 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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' diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index de1d69bb..43b6e486 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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( diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts new file mode 100644 index 00000000..ee0ed4e9 --- /dev/null +++ b/src/editor/inspector/shapeSection.ts @@ -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(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, + } + ); +} diff --git a/src/editor/vectorart/ShapeList.ts b/src/editor/vectorart/ShapeList.ts new file mode 100644 index 00000000..a00f5681 --- /dev/null +++ b/src/editor/vectorart/ShapeList.ts @@ -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[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(); + } +} diff --git a/src/editor/vectorart/shapeRows.ts b/src/editor/vectorart/shapeRows.ts new file mode 100644 index 00000000..e887dd83 --- /dev/null +++ b/src/editor/vectorart/shapeRows.ts @@ -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(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 }; +} diff --git a/tests/editor/shape-rows.test.ts b/tests/editor/shape-rows.test.ts new file mode 100644 index 00000000..3da0fcd6 --- /dev/null +++ b/tests/editor/shape-rows.test.ts @@ -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(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); + }); +}); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index f31c4117..b29f038d 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -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()') }), + ); }