diff --git a/README.md b/README.md index 33b3f1cb..a24fd0ff 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, 1259 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1264 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 09198b26..dbcffa15 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -528,16 +528,34 @@ export function installDebugHook(app: Application, state: EditorState, editor: E side.reveal(side.palette); return side.palette.swatchGlobal(index); }, + /** + * The centre of a layer's or frame's row, scrolled into view, with the centre of its picture; + * `eye` is the layer's switch. + */ layerRow: (layerId: string) => { const side = editor.pixelArtEditor.side; side.reveal(side.layers); - return side.layers.rowGlobal(layerId); + const row = side.layers.rowGlobal(layerId); + return row + ? { + x: row.x + row.w / 2, + y: row.y + row.h / 2, + thumb: side.layers.thumbGlobal(layerId), + eye: side.layers.eyeGlobal(layerId), + } + : null; }, frameRow: (frameId: string) => { const side = editor.pixelArtEditor.side; side.reveal(side.frames); - return side.frames.rowGlobal(frameId); + const row = side.frames.rowGlobal(frameId); + return row + ? { x: row.x + row.w / 2, y: row.y + row.h / 2, thumb: side.frames.thumbGlobal(frameId) } + : null; }, + /** The ids as the two lists show them, top first. */ + layerRows: () => editor.pixelArtEditor.side.layers.shownIds, + frameRows: () => editor.pixelArtEditor.side.frames.shownIds, previewIndex: () => editor.pixelArtEditor.side.preview.shownIndex, /** Canvas, layers and frames as the model holds them. */ asset: (assetId: string) => { @@ -767,21 +785,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const button = currentTilemapWidgets()?.focus; return button ? { ...centreOf(button), label: button.text } : null; }, - /** One row of the layer list, top-down as drawn: the layer, its switch and its buttons. */ + /** One row of the layer list, top-down as drawn: the layer, where to press it, and its eye. */ layerRowAt: (index: number) => { - const row = currentTilemapWidgets()?.layers[index]; - return row - ? { - id: row.id, - // `y` is the row's own line, so a check can tell whether it is inside the panel. - y: centreOf(row.name).y, - name: centreOf(row.name), - visible: centreOf(row.visible), - up: centreOf(row.up), - down: centreOf(row.down), - remove: centreOf(row.remove), - } - : null; + const list = currentTilemapWidgets()?.layers; + const id = list?.shownIds[index]; + const row = id ? list.rowGlobal(id) : null; + if (!list || !id || !row) return null; + const name = { x: row.x + row.w / 2, y: row.y + row.h / 2 }; + // `y` is the row's own line, so a check can tell whether it is inside the panel. + return { id, y: name.y, name, visible: list.eyeGlobal(id) }; + }, + removeLayerAt: () => { + const button = currentTilemapWidgets()?.removeLayer; + return button ? { ...centreOf(button), enabled: button.enabled } : null; }, /** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */ cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y), diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index 0c965a84..5543c07d 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -15,7 +15,6 @@ import { SetTilemapLayerHiddenCommand, SetTilemapLayerZCommand, } from '../../core/commands/tilemapCommands'; -import type { Command } from '../../core/commands/Command'; import type { TilemapAsset } from '../../core/model/Asset'; import { findObject } from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; @@ -23,13 +22,13 @@ import { findAssetOf } from '../../core/model/Project'; import { findLayer, isLayerVisible, layerZ } from '../../core/model/tilemap'; import { Button, - Checkbox, Dropdown, HStack, Menu, NumberField, Panel, ScrollContainer, + SortableList, TextField, Theme, ui, @@ -134,7 +133,8 @@ export interface TilemapPanelWidgets { tools: Map; /** Null while the project has no tile. */ palette: TilePalette | null; - layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[]; + layers: SortableList; + removeLayer: Button; /** The name of the layer painted on (#138). */ layerName: TextField; /** Its `z` above the map's own (#174). */ @@ -287,7 +287,18 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { const widgets: TilemapPanelWidgets = { tools: new Map(), palette: null, - layers: [], + layers: new SortableList({ + reversed: true, + onPick: (layerId) => state.setTilemapPaint({ layerId }), + onMove: (from, to) => state.execute(new MoveTilemapLayerCommand(asset.id, from, to)), + onToggleHidden: (layerId) => + state.execute( + new SetTilemapLayerHiddenCommand(asset.id, layerId, isLayerVisible(findLayer(asset, layerId)!)), + ), + }), + removeLayer: new Button('Delete layer', { + onClick: () => state.execute(new RemoveTilemapLayerCommand(asset.id, painted)), + }), layerName, layerZ: layerZField, focus, @@ -329,31 +340,14 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { addNote(host, 'Choose a tile to paint with.', 'small'); addTitle(host, 'LAYERS'); - addNote(host, 'The last layer is drawn on top.', 'small'); - // Bottom-up on screen, as in every layer list: the topmost row is the one drawn last. - for (let index = asset.layers.length - 1; index >= 0; index--) { - const layer = asset.layers[index]!; - const row = new HStack({ gap: Theme.gap }); - const visible = new Checkbox(isLayerVisible(layer)); - visible.onChange = (checked) => - state.execute(new SetTilemapLayerHiddenCommand(asset.id, layer.id, !checked)); - row.add(visible); - const choose = new Button(layer.name, { - align: 'left', - onClick: () => state.setTilemapPaint({ layerId: layer.id }), - }); - choose.toggled = layer.id === painted; - row.add(choose, 1); - const small = (label: string, command: () => Command) => - row.add(new Button(label, { variant: 'ghost', width: 24, onClick: () => state.execute(command()) })); - const up = small('↑', () => new MoveTilemapLayerCommand(asset.id, index, index + 1)); - const down = small('↓', () => new MoveTilemapLayerCommand(asset.id, index, index - 1)); - const remove = small('✕', () => new RemoveTilemapLayerCommand(asset.id, layer.id)); - widgets.layers.push({ id: layer.id, name: choose, visible, up, down, remove }); - host.body.add(row); - } - // Below the list rather than in it: a row is a button that picks the layer, and a text field - // in its place would take the click that chooses it. + widgets.layers.setItems( + asset.layers.map((layer) => ({ id: layer.id, label: layer.name, hidden: !isLayerVisible(layer) })), + [painted], + ); + host.body.add(widgets.layers); + addNote(host, 'Drag a row to draw it earlier or later.', 'small'); + // Below the list rather than in it: a press on a row picks the layer, and a text field in its + // place would take the click that chooses it. const nameRow = labelledRow('Name').row; nameRow.add(layerName, 1); host.body.add(nameRow); @@ -366,7 +360,11 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { host.body.add(showRow); addNote(host, 'Show only changes the scene view, not the game.', 'small'); addGap(host); - host.body.add(widgets.addLayer); + widgets.removeLayer.setEnabled(asset.layers.length > 1); + const layerButtons = new HStack({ gap: Theme.gap }); + layerButtons.add(widgets.addLayer, 1); + layerButtons.add(widgets.removeLayer, 1); + host.body.add(layerButtons); } function tileIdsOf(state: EditorState): string[] { diff --git a/src/editor/pixelart/PixelArtSide.ts b/src/editor/pixelart/PixelArtSide.ts index c9c8e9c7..58a7fb21 100644 --- a/src/editor/pixelart/PixelArtSide.ts +++ b/src/editor/pixelart/PixelArtSide.ts @@ -6,6 +6,7 @@ import { CreateAssetCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; import { oneStep } from '../../core/commands/CompoundCommand'; import { nextAssetName } from '../../core/commands/naming'; +import type { Graphics } from 'pixi.js'; import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset'; import { colorAt, frameLabel, PALETTE_KEY, paletteAssetOf, paletteColors } from '../../core/model/pixelart'; import { BRUSH_MAX, BRUSH_MIN } from '../../core/model/pixelDraw'; @@ -16,16 +17,17 @@ import { Dropdown, HStack, Label, - ListView, Menu, NumberField, ScrollContainer, + SortableList, Spacer, TextField, Theme, VStack, type Widget, } from '../../ui'; +import { pixelCels } from '../../viewport/PixelCels'; import type { EditorState } from '../state'; import { PaletteGrid } from './PaletteGrid'; import { type PixelArtCanvas, PIXEL_TOOL_LABELS, PIXEL_TOOLS, type PixelTool } from './PixelArtCanvas'; @@ -45,10 +47,10 @@ import { setPaletteColor, } from './pixelActions'; import { PixelPreview } from './PixelPreview'; +import { PixelThumbs } from './PixelThumbs'; /** Four tools to a row, so the column stays narrow enough for the canvas beside it. */ const TOOLS_PER_ROW = 4; -const LIST_ROWS = 4; function brushLabel(size: number): string { return `Brush: ${size} px`; @@ -67,12 +69,14 @@ export class PixelArtSide extends ScrollContainer { readonly removeColorButton = new Button('Remove last'); readonly createPaletteButton = new Button('Create palette'); private readonly paletteNote = new Label('', { style: 'muted' }); - readonly layers = new ListView(); + readonly layers: SortableList; readonly layerName = new TextField(''); readonly layerOpacity = new NumberField(1, { min: 0, max: 1, step: 0.05, decimals: 2 }); - readonly layerButtons: Record<'add' | 'remove' | 'up' | 'down' | 'merge' | 'hide', Button>; - readonly frames = new ListView(); - readonly frameButtons: Record<'add' | 'duplicate' | 'remove' | 'earlier' | 'later', Button>; + readonly layerButtons: Record<'add' | 'remove' | 'merge', Button>; + readonly frames: SortableList; + readonly frameButtons: Record<'add' | 'duplicate' | 'remove', Button>; + private readonly thumbs = new PixelThumbs(); + private readonly stopCels: () => void; readonly onionBox = new Checkbox(false); readonly tilesBox = new Checkbox(false); readonly preview: PixelPreview; @@ -125,8 +129,14 @@ export class PixelArtSide extends ScrollContainer { c.add(row(this.paletteNote, new Spacer(), this.createPaletteButton)); c.add(heading('Layers')); - this.layers.minHeight = LIST_ROWS * this.layers.rowHeight; - this.layers.onSelect = (id) => id && this.canvas.selectLayer(id); + this.layers = new SortableList({ + reversed: true, + onPick: (id) => this.canvas.selectLayer(id), + onMove: (from, to) => + this.withAsset((a) => a.layers[from] && moveLayer(state, a, a.layers[from].id, to)), + onToggleHidden: (id) => this.toggleHidden(id), + thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, id)), + }); c.add(this.layers); this.layerName.onChange = (name) => this.withAsset((asset) => renameLayer(state, asset, this.canvas.layerId, name)); @@ -139,24 +149,20 @@ export class PixelArtSide extends ScrollContainer { remove: new Button('Delete', { onClick: () => this.withAsset((a) => removeLayer(state, a, this.canvas.layerId)), }), - up: new Button('Up', { - onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, 1)), - }), - down: new Button('Down', { - onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, -1)), - }), merge: new Button('Merge down', { onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)), }), - hide: new Button('Hide', { minWidth: 56, onClick: () => this.toggleHidden() }), }; const lb = this.layerButtons; - c.add(row(lb.add, lb.remove, lb.up, lb.down)); - c.add(row(lb.merge, lb.hide)); + c.add(row(lb.add, lb.remove, lb.merge)); c.add(heading('Frames')); - this.frames.minHeight = LIST_ROWS * this.frames.rowHeight; - this.frames.onSelect = (id) => id && this.canvas.selectFrame(id); + this.frames = new SortableList({ + onPick: (id) => this.canvas.selectFrame(id), + onMove: (from, to) => + this.withAsset((a) => a.frames[from] && moveFrame(state, a, a.frames[from].id, to)), + thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, undefined, id)), + }); c.add(this.frames); this.frameButtons = { add: new Button('Add', { onClick: () => this.addFrame(false) }), @@ -164,16 +170,9 @@ export class PixelArtSide extends ScrollContainer { remove: new Button('Delete', { onClick: () => this.withAsset((a) => removeFrame(state, a, this.canvas.frameId)), }), - earlier: new Button('Earlier', { - onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, -1)), - }), - later: new Button('Later', { - onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, 1)), - }), }; const fb = this.frameButtons; c.add(row(fb.add, fb.duplicate, fb.remove)); - c.add(row(fb.earlier, fb.later)); c.add(heading('View')); this.onionBox.onChange = (on) => { @@ -197,6 +196,17 @@ export class PixelArtSide extends ScrollContainer { this.setContent(this.column); this.setTool('pencil'); state.on('project', () => this.refresh()); + // A cel that lands after the list was drawn shows in its picture. + this.stopCels = pixelCels.onChange(() => { + this.layers.invalidate(); + this.frames.invalidate(); + }); + } + + override destroy(options?: Parameters[0]): void { + this.stopCels(); + this.thumbs.destroy(); + super.destroy(options); } /** Scrolls the column until `widget` is in view – the check scripts reveal a control before they click it. */ @@ -290,12 +300,24 @@ export class PixelArtSide extends ScrollContainer { if (id) this.canvas.selectLayer(id); } - private toggleHidden(): void { + private toggleHidden(layerId: string): void { const asset = this.canvas.asset(); - const layer = asset?.layers.find((l) => l.id === this.canvas.layerId); + const layer = asset?.layers.find((l) => l.id === layerId); if (asset && layer) setLayerHidden(this.state, asset, layer.id, !layer.hidden); } + /** A layer alone on the frame being painted, or with `frameId` that whole frame. */ + private paintThumb( + asset: PixelArtAsset, + art: Graphics, + size: number, + layerId?: string, + frameId = this.canvas.frameId, + ): void { + const frame = asset.frames.find((f) => f.id === frameId); + if (frame) this.thumbs.paint(art, size, asset, paletteColors(this.state.project, asset), frame, layerId); + } + private addFrame(copy: boolean): void { const asset = this.canvas.asset(); if (!asset) return; @@ -323,13 +345,14 @@ export class PixelArtSide extends ScrollContainer { const layerId = this.canvas.layerId; this.layers.setItems( - [...(asset?.layers ?? [])].reverse().map((layer) => ({ + (asset?.layers ?? []).map((layer) => ({ id: layer.id, - label: layer.hidden ? `${layer.name} (hidden)` : layer.name, + label: layer.name, hint: layer.opacity < 1 ? `${Math.round(layer.opacity * 100)}%` : undefined, + hidden: layer.hidden, })), + layerId ? [layerId] : [], ); - this.layers.select(layerId || null, true); const layer = asset?.layers.find((l) => l.id === layerId); this.layerName.setValue(layer?.name ?? ''); this.layerOpacity.setNumber(layer?.opacity ?? 1); @@ -337,22 +360,18 @@ export class PixelArtSide extends ScrollContainer { const lb = this.layerButtons; lb.add.setEnabled(asset !== null); lb.remove.setEnabled((asset?.layers.length ?? 0) > 1); - lb.up.setEnabled(asset !== null && index >= 0 && index < asset.layers.length - 1); - lb.down.setEnabled(index > 0); lb.merge.setEnabled(index > 0); - lb.hide.setText(layer?.hidden ? 'Show' : 'Hide'); - lb.hide.setEnabled(layer !== undefined); const frameId = this.canvas.frameId; - this.frames.setItems((asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) }))); - this.frames.select(frameId || null, true); + this.frames.setItems( + (asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })), + frameId ? [frameId] : [], + ); const frameIndex = asset ? asset.frames.findIndex((f) => f.id === frameId) : -1; const fb = this.frameButtons; fb.add.setEnabled(asset !== null); fb.duplicate.setEnabled(frameIndex >= 0); fb.remove.setEnabled((asset?.frames.length ?? 0) > 1); - fb.earlier.setEnabled(frameIndex > 0); - fb.later.setEnabled(asset !== null && frameIndex >= 0 && frameIndex < asset.frames.length - 1); this.relayoutContent(); } } diff --git a/src/editor/pixelart/PixelThumbs.ts b/src/editor/pixelart/PixelThumbs.ts new file mode 100644 index 00000000..4bfb0cd4 --- /dev/null +++ b/src/editor/pixelart/PixelThumbs.ts @@ -0,0 +1,83 @@ +/** + * The little pictures in the layer and frame lists of the pixel art tab: a layer alone on the frame + * being painted, or a whole frame. Each row keeps one canvas that is painted over when what it shows + * changes – the key says when – so a stroke recomposes only the rows it touched. + */ +import { CanvasSource, type Graphics, Texture } from 'pixi.js'; +import type { PixelArtAsset, PixelFrame } from '../../core/model/Asset'; +import { composeFrame } from '../../core/model/pixelart'; +import { pixelCels } from '../../viewport/PixelCels'; +import { drawChecker } from '../texture/checker'; + +interface Slot { + key: string; + canvas: HTMLCanvasElement; + texture: Texture; +} + +export class PixelThumbs { + private readonly slots = new Map(); + /** Textures of canvases replaced by a bigger or smaller one: a list may still draw with them. */ + private readonly retired: Texture[] = []; + + /** Paints `frame` into `art`, fitted into `size`; `layerId` narrows it to that one layer. */ + paint( + art: Graphics, + size: number, + asset: PixelArtAsset, + colors: readonly number[], + frame: PixelFrame, + layerId?: string, + ): void { + const scale = Math.min(size / asset.width, size / asset.height); + const w = asset.width * scale; + const h = asset.height * scale; + const x = (size - w) / 2; + const y = (size - h) / 2; + drawChecker(art, x, y, w, h, size, size); + const texture = this.texture(asset, colors, frame, layerId); + if (texture) art.rect(x, y, w, h).fill({ texture }); + } + + destroy(): void { + for (const slot of this.slots.values()) slot.texture.destroy(true); + for (const texture of this.retired) texture.destroy(true); + this.slots.clear(); + this.retired.length = 0; + } + + private texture( + asset: PixelArtAsset, + colors: readonly number[], + frame: PixelFrame, + layerId: string | undefined, + ): Texture | null { + // A layer is shown whole even while hidden or faded: the row says that with its eye. + const layers = layerId + ? asset.layers.filter((l) => l.id === layerId).map((l) => ({ ...l, hidden: false, opacity: 1 })) + : asset.layers; + const cels = layers.map((l) => `${frame.cels[l.id] ?? ''}:${l.hidden ? 'h' : l.opacity}`).join(','); + const key = `${asset.width}x${asset.height}|${colors.join(',')}|${cels}`; + const id = `${asset.id}/${frame.id}/${layerId ?? ''}`; + let slot = this.slots.get(id); + if (slot?.key === key) return slot.texture; + const rgba = composeFrame({ ...asset, layers }, colors, frame, (file) => pixelCels.read(file)); + if (!rgba) return slot?.texture ?? null; + if (!slot || slot.canvas.width !== asset.width || slot.canvas.height !== asset.height) { + if (slot) this.retired.push(slot.texture); + const canvas = document.createElement('canvas'); + canvas.width = asset.width; + canvas.height = asset.height; + slot = { + key, + canvas, + texture: new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'nearest' }) }), + }; + this.slots.set(id, slot); + } + slot.key = key; + slot.canvas.getContext('2d')?.putImageData(new ImageData(rgba, asset.width, asset.height), 0, 0); + slot.texture.source.update(); + return slot.texture; + } +} diff --git a/src/editor/pixelart/pixelActions.ts b/src/editor/pixelart/pixelActions.ts index 89078003..7a91cbc3 100644 --- a/src/editor/pixelart/pixelActions.ts +++ b/src/editor/pixelart/pixelActions.ts @@ -85,12 +85,9 @@ export function setLayerOpacity( return edit(state, asset, 'Layer opacity', (s) => withLayerChanged(s, layerId, { opacity })); } -/** Up is towards the top of the drawing order, the end of the list. */ -export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: string, step: 1 | -1): boolean { - const index = asset.layers.findIndex((layer) => layer.id === layerId); - return edit(state, asset, step > 0 ? 'Move layer up' : 'Move layer down', (s) => - withLayerMoved(s, layerId, index + step), - ); +/** `to` is the layer's new index in the drawing order, whose end is the top. */ +export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: string, to: number): boolean { + return edit(state, asset, 'Reorder layers', (s) => withLayerMoved(s, layerId, to)); } /** The layer laid onto the one below it on every frame; its opacity is not kept (see `mergeCels`). */ @@ -129,11 +126,8 @@ export function removeFrame(state: EditorState, asset: PixelArtAsset, frameId: s return edit(state, asset, 'Delete frame', (s) => withFrameRemoved(s, frameId)); } -export function moveFrame(state: EditorState, asset: PixelArtAsset, frameId: string, step: 1 | -1): boolean { - const index = asset.frames.findIndex((frame) => frame.id === frameId); - return edit(state, asset, step > 0 ? 'Move frame later' : 'Move frame earlier', (s) => - withFrameMoved(s, frameId, index + step), - ); +export function moveFrame(state: EditorState, asset: PixelArtAsset, frameId: string, to: number): boolean { + return edit(state, asset, 'Reorder frames', (s) => withFrameMoved(s, frameId, to)); } /** Every cel moved onto a canvas of the new size (answer 3), in one undo step. */ diff --git a/src/editor/vectorart/ShapeList.ts b/src/editor/vectorart/ShapeList.ts index 446f5c99..93f46361 100644 --- a/src/editor/vectorart/ShapeList.ts +++ b/src/editor/vectorart/ShapeList.ts @@ -2,57 +2,25 @@ * 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. + * 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. The list is the drawing order, top row drawn last, and a row is dragged to reorder it. * * 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 type { Graphics } from 'pixi.js'; +import type { VectorArtAsset } from '../../core/model/Asset'; import { findAssetOf } from '../../core/model/Project'; import { vectorCanvasSize } from '../../core/model/vectorart'; import { shapeDrawing } from '../../core/model/vectorPath'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture'; -import { type Size, Theme, textStyle, truncate, Widget } from '../../ui'; +import { SortableList, type ThumbClip, type 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; +export class ShapeList extends SortableList { private readonly paints = new PaintCache(); private readonly stop: () => void; private readonly stopPictures: () => void; @@ -65,37 +33,26 @@ export class ShapeList extends Widget { private readonly state: EditorState, private readonly assetId: string, shapeIds: readonly string[], - private readonly selectedIds: readonly string[], - private readonly onPick: (shapeId: string, toggle: boolean) => void, - private readonly onReorder: (from: number, to: number) => void, + selectedIds: readonly string[], + onPick: (shapeId: string, toggle: boolean) => void, + 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); + super({ + reversed: true, + multiSelect: true, + onPick, + onMove: onReorder, + thumb: (id, art, size) => this.paintSwatch(id, art, size), + }); + const asset = this.asset(); + this.setItems( + shapeIds.map((id) => ({ id, label: asset?.shapes.find((s) => s.id === id)?.name ?? '' })), + selectedIds, + ); // A point dragged in the canvas changes no row, only what a row shows. this.stop = state.on('project', () => this.invalidate()); // A shape filled with a picture (#186) shows its colour until the picture has landed. this.stopPictures = textureLibrary.onChange(() => 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 { @@ -105,134 +62,37 @@ export class ShapeList extends Widget { 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 }; + override redraw(): void { + super.redraw(); + this.paints.sweep(); } 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 (e.ctrlKey || e.metaKey || e.shiftKey) { - this.onPick(shapeId, true); - return; - } - if (this.selectedIds.length !== 1 || this.selectedIds[0] !== shapeId) this.onPick(shapeId, false); - 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 = this.selectedIds.includes(shapeId); - 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; - } - this.paints.sweep(); - 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. + * is in the picture. */ - private paintSwatch(row: Row, asset: VectorArtAsset, shape: VectorShape): void { + private paintSwatch(shapeId: string, art: Graphics, size: number): ThumbClip | void { + const asset = this.asset(); + const shape = asset?.shapes.find((s) => s.id === shapeId); + if (!asset || !shape) return; 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 scale = Math.min(size / width, size / height); + const clip = { + x: (size - width * scale) / 2, + y: (size - height * scale) / 2, + w: width * scale, + h: height * scale, + }; + art.position.x += clip.x; + art.position.y += clip.y; + art.scale.set(scale); const drawing = shapeDrawing(shape, this.state.project); - if (drawing) paintDrawing(row.art, drawing, this.paints); - } - - override layout(): void { - this.redraw(); + if (drawing) paintDrawing(art, drawing, this.paints); + return clip; } } diff --git a/src/ui/index.ts b/src/ui/index.ts index dd393f9c..22455629 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -31,6 +31,14 @@ export { Slider } from './widgets/Slider'; export { Splitter } from './widgets/Splitter'; export { ScrollContainer } from './widgets/ScrollContainer'; export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView'; +export { + SORTABLE_ROW_HEIGHT, + SortableList, + type SortableItem, + type SortableListOptions, + type ThumbClip, +} from './widgets/SortableList'; +export { dropGap, dropMove, rowOrder } from './sortable'; export { Popup, type PopupOptions } from './widgets/Popup'; export { currentMenu, diff --git a/src/editor/vectorart/shapeRows.ts b/src/ui/sortable.ts similarity index 54% rename from src/editor/vectorart/shapeRows.ts rename to src/ui/sortable.ts index e887dd83..e6bab3e5 100644 --- a/src/editor/vectorart/shapeRows.ts +++ b/src/ui/sortable.ts @@ -1,19 +1,19 @@ /** - * 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. + * How a sortable list maps between what is on screen and what its owner holds. A layer list shows the + * drawing order upside down – the item drawn last is the topmost row – 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 ids as the list shows them, top first. `reversed` puts the model's last item on top. */ +export function rowOrder(ids: readonly T[], reversed: boolean): T[] { + return reversed ? [...ids].reverse() : [...ids]; } /** 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 { +export function dropGap(count: number, y: number, rowHeight: number): number { if (rowHeight <= 0) return 0; return Math.max(0, Math.min(count, Math.round(y / rowHeight))); } @@ -23,14 +23,16 @@ export function shapeDropGap(count: number, y: number, rowHeight: number): numbe * `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( +export function dropMove( count: number, fromRow: number, gap: number, + reversed: boolean, ): { 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; + if (!reversed) return { from: fromRow, to: toRow }; const last = count - 1; return { from: last - fromRow, to: last - toRow }; } diff --git a/src/ui/widgets/SortableList.ts b/src/ui/widgets/SortableList.ts new file mode 100644 index 00000000..2140da56 --- /dev/null +++ b/src/ui/widgets/SortableList.ts @@ -0,0 +1,281 @@ +/** + * A list whose rows are dragged into another order – the layer list of every editor that has one: + * the shapes of vector art, the layers and frames of pixel art, the layers of a tilemap. Each row may + * carry a small picture the owner paints and an eye that hides the item without removing it. + * + * The list never changes the order itself. A drop reports the move in the owner's indices, the owner + * runs its command, and the next `setItems` shows the result. + */ +import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { dropGap, dropMove, rowOrder } from '../sortable'; +import { Theme, textStyle } from '../Theme'; +import { type Size, Widget } from '../Widget'; +import { truncate } from './Label'; + +/** Height of one row: the picture plus the air a hovered row needs to look like a row. */ +export const SORTABLE_ROW_HEIGHT = 30; +const THUMB = 22; +const PAD = 4; +/** Width at the right end of a row that belongs to the eye. */ +const EYE_SLOT = 26; +/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */ +const DRAG_THRESHOLD = 4; + +export interface SortableItem { + id: string; + label: string; + /** Small text at the right end, before the eye. */ + hint?: string; + /** Drawn faded, with a closed eye. */ + hidden?: boolean; +} + +/** Part of the picture square that is shown, in its own coordinates. */ +export interface ThumbClip { + x: number; + y: number; + w: number; + h: number; +} + +export interface SortableListOptions { + /** The model's last item is the topmost row, as in every layer list: it is drawn last. */ + reversed?: boolean; + /** + * Paints the item's picture into `art`, whose origin is the top-left corner of a `size` square. It + * may move and scale `art`; what it paints is cut to the square, or to the box it returns. + */ + thumb?: (id: string, art: Graphics, size: number) => ThumbClip | void; + /** A row pressed. `toggle` is true with Ctrl or Shift held, where `multiSelect` is on. */ + onPick: (id: string, toggle: boolean) => void; + /** A row dropped somewhere else, in the owner's indices. */ + onMove: (from: number, to: number) => void; + /** Setting this gives every row an eye; a press on it reports the item and nothing else. */ + onToggleHidden?: (id: string) => void; + multiSelect?: boolean; +} + +interface Row { + box: Container; + art: Graphics; + clip: Graphics; + label: Text; + hint: Text; + eye: Graphics; +} + +interface RowDrag { + /** Index of the pressed row in display order. */ + from: number; + /** The pressed row is to be picked once the pointer is up. */ + pick: boolean; + startY: number; + y: number; + active: boolean; +} + +export class SortableList extends Widget { + private readonly background = new Graphics(); + private readonly marker = new Graphics(); + private readonly rows: Row[] = []; + /** Display order, top first. */ + private shown: SortableItem[] = []; + private selected: readonly string[] = []; + private hoveredRow = -1; + private drag: RowDrag | null = null; + + constructor(private readonly options: SortableListOptions) { + super(); + this.cursor = Theme.hoverCursor; + this.addChild(this.background); + this.marker.eventMode = 'none'; + this.addChild(this.marker); + + 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()); + } + + /** The items in the owner's order, and which of them are marked. */ + setItems(items: readonly SortableItem[], selectedIds: readonly string[]): void { + const countChanged = items.length !== this.shown.length; + this.shown = rowOrder(items, this.options.reversed ?? false); + this.selected = selectedIds; + while (this.rows.length < this.shown.length) this.rows.push(this.buildRow()); + while (this.rows.length > this.shown.length) this.rows.pop()!.box.destroy({ children: true }); + if (countChanged) this.drag = null; + this.invalidate(); + } + + override measure(): Size { + return { w: this.minWidth, h: Math.max(SORTABLE_ROW_HEIGHT, this.shown.length * SORTABLE_ROW_HEIGHT) }; + } + + /** The ids as the list shows them, top first – for the check scripts. */ + get shownIds(): string[] { + return this.shown.map((item) => item.id); + } + + /** Where an item's row sits on screen (test hook); null when the list does not show it. */ + rowGlobal(id: string): { x: number; y: number; w: number; h: number } | null { + const index = this.shown.findIndex((item) => item.id === id); + if (index < 0) return null; + const origin = this.getGlobalPosition(); + return { x: origin.x, y: origin.y + index * SORTABLE_ROW_HEIGHT, w: this.w, h: SORTABLE_ROW_HEIGHT }; + } + + /** The centre of an item's picture (test hook); null without pictures or without that row. */ + thumbGlobal(id: string): { x: number; y: number } | null { + const row = this.rowGlobal(id); + if (!row || !this.options.thumb) return null; + return { x: Math.round(row.x + PAD + THUMB / 2), y: Math.round(row.y + row.h / 2) }; + } + + /** The centre of an item's eye (test hook); null without eyes or without that row. */ + eyeGlobal(id: string): { x: number; y: number } | null { + const row = this.rowGlobal(id); + if (!row || !this.options.onToggleHidden) return null; + return { x: row.x + row.w - EYE_SLOT / 2, y: row.y + row.h / 2 }; + } + + private buildRow(): Row { + const box = new Container(); + const art = new Graphics(); + const clip = new Graphics(); + const label = new Text({ text: '', style: textStyle('body') }); + const hint = new Text({ text: '', style: textStyle('small') }); + const eye = new Graphics(); + box.eventMode = 'none'; + box.addChild(art, clip, label, hint, eye); + art.mask = clip; + this.addChildAt(box, this.getChildIndex(this.marker)); + return { box, art, clip, label, hint, eye }; + } + + private rowAt(y: number): number { + const index = Math.floor(y / SORTABLE_ROW_HEIGHT); + return index >= 0 && index < this.shown.length ? index : -1; + } + + private onPress(e: FederatedPointerEvent): void { + if (e.button !== 0) return; + const local = this.toLocal(e.global); + const index = this.rowAt(local.y); + if (index < 0) return; + e.stopPropagation(); + const id = this.shown[index]!.id; + if (this.options.onToggleHidden && local.x >= this.w - EYE_SLOT) { + this.options.onToggleHidden(id); + return; + } + if (this.options.multiSelect && (e.ctrlKey || e.metaKey || e.shiftKey)) { + this.options.onPick(id, true); + return; + } + // Picked on release: a pick rebuilds what the list stands in, which would end the drag. + const pick = this.selected.length !== 1 || this.selected[0] !== id; + this.drag = { from: index, pick, startY: local.y, y: local.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(); + const id = this.shown[drag.from]?.id; + if (drag.active) { + const gap = dropGap(this.shown.length, drag.y, SORTABLE_ROW_HEIGHT); + const move = dropMove(this.shown.length, drag.from, gap, this.options.reversed ?? false); + if (move) this.options.onMove(move.from, move.to); + } + if (drag.pick && id) this.options.onPick(id, false); + } + + override redraw(): void { + this.background.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.control); + this.marker.clear(); + const eyes = Boolean(this.options.onToggleHidden); + const thumbs = this.options.thumb; + const textX = thumbs ? PAD * 2 + THUMB : PAD * 2; + for (const [index, item] of this.shown.entries()) { + const row = this.rows[index]!; + const y = index * SORTABLE_ROW_HEIGHT; + row.box.position.set(0, y); + const selected = this.selected.includes(item.id); + if (selected || index === this.hoveredRow) + this.background + .rect(0, y, this.w, SORTABLE_ROW_HEIGHT) + .fill(selected ? Theme.colors.selection : Theme.colors.controlHover); + + const thumbY = (SORTABLE_ROW_HEIGHT - THUMB) / 2; + row.art.clear(); + row.art.position.set(PAD, thumbY); + row.art.scale.set(1); + row.clip.clear(); + if (thumbs) { + const clip = thumbs(item.id, row.art, THUMB) ?? { x: 0, y: 0, w: THUMB, h: THUMB }; + row.clip.rect(PAD + clip.x, thumbY + clip.y, clip.w, clip.h).fill(0xffffff); + } + row.art.alpha = item.hidden ? 0.35 : 1; + + const eyeW = eyes ? EYE_SLOT : 0; + row.hint.text = item.hint ?? ''; + const hintW = row.hint.text ? row.hint.width + PAD * 2 : 0; + row.hint.position.set( + Math.round(this.w - eyeW - PAD - row.hint.width), + (SORTABLE_ROW_HEIGHT - row.hint.height) / 2, + ); + row.label.style = textStyle(selected && !item.hidden ? 'body' : 'muted'); + row.label.text = truncate( + item.label, + row.label.style, + Math.max(0, this.w - textX - hintW - eyeW - PAD), + ); + row.label.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2); + + row.eye.clear(); + if (eyes) drawEye(row.eye, this.w - EYE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, !item.hidden); + row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1; + } + if (this.drag?.active) { + const y = dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT; + this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent); + } + } + + override layout(): void { + this.redraw(); + } +} + +/** An open eye, or a closed one struck through – drawn rather than a glyph, so it stays crisp. */ +function drawEye(g: Graphics, x: number, y: number, open: boolean): void { + const color = open ? Theme.colors.text : Theme.colors.textMuted; + g.ellipse(x, y, 6, 3.5).stroke({ width: 1.2, color }); + if (open) g.circle(x, y, 1.8).fill(color); + else + g.moveTo(x - 6, y + 5) + .lineTo(x + 6, y - 5) + .stroke({ width: 1.2, color }); +} diff --git a/tests/editor/shape-rows.test.ts b/tests/editor/shape-rows.test.ts index 1fcfd964..4e44605b 100644 --- a/tests/editor/shape-rows.test.ts +++ b/tests/editor/shape-rows.test.ts @@ -33,12 +33,9 @@ import { setPaintKind, shapeSignature, } from '../../src/editor/inspector/shapeSection'; -import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows'; import { EditorState, shapeSelection } from '../../src/editor/state'; import { PaintCache } from '../../src/viewport/vectorArtPicture'; -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, @@ -101,36 +98,6 @@ function widgets(host: InspectorHost, type: new (...args: neve 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); diff --git a/tests/ui/sortable.test.ts b/tests/ui/sortable.test.ts new file mode 100644 index 00000000..1cd27d3d --- /dev/null +++ b/tests/ui/sortable.test.ts @@ -0,0 +1,48 @@ +/** + * The arithmetic of the sortable list shared by the shape, pixel art and tilemap lists: which row + * shows which item, which gap a drop falls into, and what move that makes in the owner's order. + */ +import { describe, expect, it } from 'vitest'; +import { dropGap, dropMove, rowOrder } from '../../src/ui/sortable'; + +const ROW = 30; + +describe('sortable list', () => { + it('shows a layer list upside down – the item drawn last is the top row', () => { + expect(rowOrder(['bottom', 'middle', 'top'], true)).toEqual(['top', 'middle', 'bottom']); + expect(rowOrder(['first', 'second'], false)).toEqual(['first', 'second']); + }); + + it('drops into the gap the pointer is nearest to', () => { + expect(dropGap(3, 2, ROW)).toBe(0); + expect(dropGap(3, 20, ROW)).toBe(1); + expect(dropGap(3, 44, ROW)).toBe(1); + expect(dropGap(3, 46, ROW)).toBe(2); + // Outside the list there is no gap: a drop above it is the first, below it the last. + expect(dropGap(3, 400, ROW)).toBe(3); + expect(dropGap(3, -40, ROW)).toBe(0); + }); + + it('turns a drop into a move of the model, the top row being the last item when reversed', () => { + // Three items, so the rows are the model indices [2, 1, 0]: the top row dropped at the + // bottom gap becomes the first item of the model, the one drawn first. + expect(dropMove(3, 0, 3, true)).toEqual({ from: 2, to: 0 }); + expect(dropMove(3, 2, 0, true)).toEqual({ from: 0, to: 2 }); + expect(dropMove(3, 1, 0, true)).toEqual({ from: 1, to: 2 }); + }); + + it('keeps the indices as they are in a list shown in model order', () => { + // Frames: the first row is the first frame. The top row dropped at the bottom becomes the last. + expect(dropMove(3, 0, 3, false)).toEqual({ from: 0, to: 2 }); + expect(dropMove(3, 2, 0, false)).toEqual({ from: 2, to: 0 }); + expect(dropMove(3, 0, 2, false)).toEqual({ from: 0, to: 1 }); + }); + + it('is no move when the row lands where it already is', () => { + for (const reversed of [true, false]) { + expect(dropMove(3, 1, 1, reversed)).toBeNull(); + expect(dropMove(3, 1, 2, reversed)).toBeNull(); + expect(dropMove(3, 5, 1, reversed)).toBeNull(); + } + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 843d2c68..18b9ef5f 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -429,6 +429,40 @@ export async function tilemap(t) { await undo(); await settle(); + // 40l-ii. The rows are dragged into another order: the upper layer dropped below the lower one is + // drawn first. Undone again, so the blocks below find the layers as they were. + const upperRow = await inPanel(t, '__gm.tilemap.layerRowAt(0)'); + const lowerRow = await inPanel(t, '__gm.tilemap.layerRowAt(1)'); + const orderBefore = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id); + if (upperRow && lowerRow) await drag(upperRow.name.x, upperRow.y, lowerRow.name.x, lowerRow.y + 20); + await settle(); + const orderAfter = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id); + const topRowNow = await json('(__gm.tilemap.layerRowAt(0) || {}).id'); + check( + 'dragging the upper layer row below the lower one puts that layer first in the drawing order', + orderBefore.length === 2 && + orderAfter.join() === [orderBefore[1], orderBefore[0]].join() && + topRowNow === orderBefore[0], + JSON.stringify({ orderBefore, orderAfter, topRowNow }), + ); + await undo(); + await settle(); + // With no layer chosen the topmost is painted on, so that is the one Delete layer takes. + const chosenLayer = (await json('__gm.tilemap.paint()')).layerId; + const paintedLayer = orderBefore.includes(chosenLayer) ? chosenLayer : orderBefore.at(-1); + const removeAt = await inPanel(t, '__gm.tilemap.removeLayerAt()'); + if (removeAt?.enabled) await click(removeAt.x, removeAt.y); + await settle(); + const leftOver = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id); + const lastOne = await json('__gm.tilemap.removeLayerAt()'); + check( + 'Delete layer takes out the layer painted on, and is off for the last one', + leftOver.length === 1 && !leftOver.includes(paintedLayer) && lastOne?.enabled === false, + JSON.stringify({ paintedLayer, leftOver, lastOne }), + ); + await undo(); + await settle(); + // 40m. The painted layer is named in the field below the list, and Show singles it out in the // scene view (#138). It sits here, not at the end, because here both layers exist: the upper // one is painted on and holds the cell (4,0), the lower one the 3 × 3 block. diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index 42f86dce..d9c06ed8 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -55,6 +55,13 @@ export async function pixelArt(t) { await click(at.x, at.y); await settle(); }; + /** A row of the layer or frame list, scrolled into view: its centre, and for a layer its eye. */ + const listRow = async (list, rowId) => { + const hook = `__gm.pixelArt.${list}Row(${JSON.stringify(rowId)})`; + await json(hook); + await settle(); + return json(hook); + }; const at = (x, y) => json(`__gm.pixelArt.pixelAt(${x}, ${y})`); const paint = async (from, to, modifiers = {}) => { const a = await at(from[0], from[1]); @@ -217,11 +224,15 @@ export async function pixelArt(t) { ); await click(filledBox.x, filledBox.y); - // 48f. Hide and opacity change what the canvas shows, not the pixels. + // 48f. The eye in the layer's row and the opacity change what the canvas shows, not the pixels. const blueShown = await screen(10, 10); - await press_('layer:hide'); + const eyeAt = (await listRow('layer', top)).eye; + await click(eyeAt.x, eyeAt.y); + await settle(); const hidden = await screen(10, 10); - await press_('layer:hide'); + const hiddenFlag = (await asset(id)).layers.at(-1).hidden; + await click(eyeAt.x, eyeAt.y); + await settle(); const opacity = await control('layerOpacity'); await click(opacity.x, opacity.y); await press('a', { ctrl: true }); @@ -245,14 +256,45 @@ export async function pixelArt(t) { ) .join(''); check( - 'a hidden layer is not drawn, and a layer at half opacity is mixed with the one below', + 'the eye hides a layer, which is then not drawn, and a layer at half opacity is mixed with the one below', colourDistance(blueShown, HEX[BLUE]) < 10 && + hiddenFlag === true && colourDistance(hidden, HEX[GREEN]) < 10 && + !art.layers.at(-1).hidden && art.layers.at(-1).opacity === 0.5 && colourDistance(half, mixed) < 12, - JSON.stringify({ blueShown, hidden, half, mixed, opacity: art.layers.at(-1).opacity }), + JSON.stringify({ blueShown, hiddenFlag, hidden, half, mixed, opacity: art.layers.at(-1).opacity }), ); + // 48f-ii. Each layer row shows that layer alone, on the frame painted, and the list is dragged into + // another order. The picture's centre is canvas pixel (16, 16): green below, empty above. + const bottom = art.layers[0].id; + const topRow = await listRow('layer', top); + const bottomRow = await listRow('layer', bottom); + const topThumb = await json(`__gm.pixelAt(${topRow.thumb.x}, ${topRow.thumb.y})`); + const bottomThumb = await json(`__gm.pixelAt(${bottomRow.thumb.x}, ${bottomRow.thumb.y})`); + check( + 'a layer row pictures that layer alone: green below, nothing painted there above', + colourDistance(bottomThumb, HEX[GREEN]) < 12 && + colourDistance(topThumb, HEX[GREEN]) > 60 && + colourDistance(topThumb, HEX[BLUE]) > 60, + JSON.stringify({ topThumb, bottomThumb }), + ); + const rowsBefore = await json('__gm.pixelArt.layerRows()'); + await drag(topRow.x, topRow.y, bottomRow.x, bottomRow.y + 20, { steps: 8 }); + await settle(); + const movedLayers = (await asset(id)).layers.map((l) => l.id); + const rowsAfter = await json('__gm.pixelArt.layerRows()'); + check( + 'dragging the top layer row below the other puts that layer first in the drawing order', + rowsBefore.join() === [top, bottom].join() && + movedLayers.join() === [top, bottom].join() && + rowsAfter.join() === [bottom, top].join(), + JSON.stringify({ rowsBefore, movedLayers, rowsAfter }), + ); + await undo(); + await settle(); + // 48g. Merge down: one layer left, holding both; the upper layer's opacity is not kept. await press_('layer:merge'); art = await asset(id); @@ -308,6 +350,27 @@ export async function pixelArt(t) { } check('the preview plays through the frames', seen.size >= 2, JSON.stringify([...seen])); + // 48h-ii. The frame rows picture their frames and are dragged into another order, first on top. + const framesBefore = (await asset(id)).frames.map((f) => f.id); + const firstFrameRow = await listRow('frame', framesBefore[0]); + const lastFrameRow = await listRow('frame', framesBefore[2]); + const firstThumb = await json(`__gm.pixelAt(${firstFrameRow.thumb.x}, ${firstFrameRow.thumb.y})`); + const lastThumb = await json(`__gm.pixelAt(${lastFrameRow.thumb.x}, ${lastFrameRow.thumb.y})`); + await drag(firstFrameRow.x, firstFrameRow.y, lastFrameRow.x, lastFrameRow.y + 20, { steps: 8 }); + await settle(); + const framesAfter = (await asset(id)).frames.map((f) => f.id); + const frameRowsAfter = await json('__gm.pixelArt.frameRows()'); + check( + 'a frame row pictures its frame, and dragging the first row to the bottom makes it the last frame', + colourDistance(firstThumb, HEX[GREEN]) < 12 && + colourDistance(lastThumb, HEX[GREEN]) > 60 && + framesAfter.join() === [framesBefore[1], framesBefore[2], framesBefore[0]].join() && + frameRowsAfter.join() === framesAfter.join(), + JSON.stringify({ firstThumb, lastThumb, framesBefore, framesAfter, frameRowsAfter }), + ); + await undo(); + await settle(); + // 48i. Tile preview: the picture repeats around the canvas. Shown on the first frame, which is painted all over. const first = (await asset(id)).frames[0].id; const firstRowAt = await json(`__gm.pixelArt.frameRow(${JSON.stringify(first)})`);