diff --git a/README.md b/README.md index a24fd0ff..b0f9af53 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, 1264 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1266 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 dbcffa15..97742f30 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -32,7 +32,7 @@ import { currentFontPicker } from './font/FontPicker'; import { currentNodePalette } from './graph/NodePalette'; import { currentSoundPicker } from './sound/SoundPicker'; import { currentImagePicker } from './texture/ImagePicker'; -import { currentTilemapWidgets } from './panels/TilemapPanel'; +import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from './panels/TilemapPanel'; import { currentResizeDialog } from './pixelart/ResizeCanvasDialog'; import { currentGradientBar, currentShapeList } from './inspector/shapeSection'; import type { SnapToggle } from './graph/SnapToggle'; @@ -514,6 +514,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E ...Object.fromEntries(Object.entries(side.layerButtons).map(([k, b]) => [`layer:${k}`, b])), ...Object.fromEntries(Object.entries(side.frameButtons).map(([k, b]) => [`frame:${k}`, b])), }; + // The plus in a list's header is no widget of its own. + const list = name === 'layer:add' ? side.layers : name === 'frame:add' ? side.frames : null; + if (list) { + side.reveal(list); + const at = list.addGlobal(); + return at ? { ...at, enabled: true, text: '+' } : null; + } const widget = byName[name]; if (!widget || !widget.visible) return null; if (widget !== editor.pixelArtEditor.resizeButton) side.reveal(widget); @@ -530,7 +537,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, /** * 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. + * `eye` is the layer's switch, `remove` the cross (null on the last one). */ layerRow: (layerId: string) => { const side = editor.pixelArtEditor.side; @@ -542,6 +549,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E y: row.y + row.h / 2, thumb: side.layers.thumbGlobal(layerId), eye: side.layers.eyeGlobal(layerId), + remove: side.layers.removeGlobal(layerId), } : null; }, @@ -550,7 +558,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E side.reveal(side.frames); 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) } + ? { + x: row.x + row.w / 2, + y: row.y + row.h / 2, + thumb: side.frames.thumbGlobal(frameId), + remove: side.frames.removeGlobal(frameId), + } : null; }, /** The ids as the two lists show them, top first. */ @@ -647,6 +660,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E */ shapeRows: () => currentShapeList()?.shownIds ?? null, shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null, + /** The cross on a shape's row. */ + shapeRemoveAt: (shapeId: string) => currentShapeList()?.removeGlobal(shapeId) ?? null, /** The Paint tool (#186): its button, the "Handles" menu, and which part it shows. */ paintButton: () => centreOf(vectorArtEditor.paintButton), paintTarget: () => ({ @@ -767,10 +782,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const swatch = palette?.swatchGlobal(assetId); return palette && swatch ? { ...swatch, columns: palette.columns } : null; }, - addLayerAt: () => { - const button = currentTilemapWidgets()?.addLayer; - return button ? centreOf(button) : null; - }, + /** The plus in the layer list's header. */ + addLayerAt: () => currentTilemapWidgets()?.layers.addGlobal() ?? null, /** The name field of the painted layer and the Show dropdown, with what they hold (#138). */ layerNameAt: () => { const field = currentTilemapWidgets()?.layerName; @@ -795,9 +808,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E // `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) }; }, + /** The cross on the painted layer's row; `enabled` is false where the row has none (the last layer). */ removeLayerAt: () => { - const button = currentTilemapWidgets()?.removeLayer; - return button ? { ...centreOf(button), enabled: button.enabled } : null; + const widgets = currentTilemapWidgets(); + const target = paintedTilemap(state); + if (!widgets || !target) return null; + const layerId = paintedLayerId(state, target.asset); + const at = widgets.layers.removeGlobal(layerId); + const row = widgets.layers.rowGlobal(layerId); + return at ? { ...at, enabled: true } : row ? { x: row.x, y: row.y, enabled: false } : 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/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts index ad298d57..ff0f8f03 100644 --- a/src/editor/inspector/shapeSection.ts +++ b/src/editor/inspector/shapeSection.ts @@ -239,7 +239,6 @@ function selectedShapes(asset: VectorArtAsset, shapeIds: readonly string[]): Vec /** 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); @@ -260,7 +259,6 @@ export function buildShapeSection( const shape = asset && findShape(asset, shapeId); if (!asset || !shape) return; - addTitle(host, 'SHAPES'); addShapeList(host, asset, shapeIds); addNote(host, 'Drag a row to draw it earlier or later.', 'small'); @@ -319,20 +317,17 @@ export function buildShapeSection( } else addNote(host, 'Without an outline only the fill is drawn.', 'small'); addGap(host); - const several = shapeIds.length > 1; - host.body.add( - new Button(several ? 'Delete shapes' : 'Delete shape', { - variant: 'danger', - onClick: () => { - host.state.execute( - several - ? new RemoveVectorShapesCommand(assetId, shapeIds) - : new RemoveVectorShapeCommand(assetId, shapeId), - ); - host.state.select({ kind: 'asset', assetId }); - }, - }), - ); + // One shape goes by the cross on its row; several at once only from here. + if (shapeIds.length > 1) + host.body.add( + new Button('Delete shapes', { + variant: 'danger', + onClick: () => { + host.state.execute(new RemoveVectorShapesCommand(assetId, shapeIds)); + host.state.select({ kind: 'asset', assetId }); + }, + }), + ); host.body.add(addShapeButton(host, assetId)); } @@ -383,6 +378,7 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedIds: readonly string[]): void { if (asset.shapes.length === 0) { built = null; + addTitle(host, 'SHAPES'); addNote(host, 'Nothing drawn yet – take the Pen in the Vector art tab.'); return; } @@ -396,11 +392,20 @@ function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedIds: r ? host.state.toggleShape(asset.id, shapeId) : host.state.select(shapeSelection(asset.id, [shapeId])), (from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)), + (shapeId) => removeShape(host, asset.id, shapeId), ); built = list; host.body.add(list); } +/** The cross on a row: the shape goes, and with it out of the selection – the rest stays selected. */ +function removeShape(host: InspectorHost, assetId: string, shapeId: string): void { + const rest = host.state.selectedShapeIds.filter((id) => id !== shapeId); + const wasSelected = rest.length !== host.state.selectedShapeIds.length; + if (!host.state.execute(new RemoveVectorShapeCommand(assetId, shapeId)) || !wasSelected) return; + host.state.select(rest.length > 0 ? shapeSelection(assetId, rest) : { kind: 'asset', assetId }); +} + function addShapeButton(host: InspectorHost, assetId: string): Button { return new Button('Add shape', { onClick: () => { diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index 5543c07d..8a31275b 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -38,7 +38,7 @@ import { import type { EditorState, TilemapFocus, TilemapTool } from '../state'; import { TilePalette } from '../tile/TilePalette'; import { labelledRow } from '../inspector/fieldFactory'; -import { addGap, addNote, addTitle, type InspectorHost } from '../inspector/host'; +import { addNote, addTitle, type InspectorHost } from '../inspector/host'; const FOCUS: { focus: TilemapFocus; label: string }[] = [ { focus: 'all', label: 'All layers' }, @@ -133,15 +133,14 @@ export interface TilemapPanelWidgets { tools: Map; /** Null while the project has no tile. */ palette: TilePalette | null; + /** The layers, with the plus that adds one and a cross on each that removes it. */ layers: SortableList; - removeLayer: Button; /** The name of the layer painted on (#138). */ layerName: TextField; /** Its `z` above the map's own (#174). */ layerZ: NumberField; /** How the other layers are shown in the scene view (#138). */ focus: Dropdown; - addLayer: Button; } let built: TilemapPanelWidgets | null = null; @@ -288,23 +287,21 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { tools: new Map(), palette: null, layers: new SortableList({ + title: 'Layers', reversed: true, + keepOne: 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)), + onAdd: () => state.execute(new AddTilemapLayerCommand(asset.id)), + onRemove: (layerId) => state.execute(new RemoveTilemapLayerCommand(asset.id, layerId)), }), layerName, layerZ: layerZField, focus, - addLayer: new Button('Add layer', { - onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)), - }), }; built = widgets; @@ -339,7 +336,7 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { if (tiles.length > 0 && !tiles.includes(paint.tileId)) addNote(host, 'Choose a tile to paint with.', 'small'); - addTitle(host, 'LAYERS'); + // The list carries its own "LAYERS" header, with the plus that adds one. widgets.layers.setItems( asset.layers.map((layer) => ({ id: layer.id, label: layer.name, hidden: !isLayerVisible(layer) })), [painted], @@ -359,12 +356,6 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { showRow.add(focus, 1); host.body.add(showRow); addNote(host, 'Show only changes the scene view, not the game.', 'small'); - addGap(host); - 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 58a7fb21..20a075fd 100644 --- a/src/editor/pixelart/PixelArtSide.ts +++ b/src/editor/pixelart/PixelArtSide.ts @@ -72,9 +72,9 @@ export class PixelArtSide extends ScrollContainer { 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' | 'merge', Button>; + readonly layerButtons: Record<'merge', Button>; readonly frames: SortableList; - readonly frameButtons: Record<'add' | 'duplicate' | 'remove', Button>; + readonly frameButtons: Record<'duplicate', Button>; private readonly thumbs = new PixelThumbs(); private readonly stopCels: () => void; readonly onionBox = new Checkbox(false); @@ -128,13 +128,16 @@ export class PixelArtSide extends ScrollContainer { this.createPaletteButton.onClick = () => this.createPalette(); c.add(row(this.paletteNote, new Spacer(), this.createPaletteButton)); - c.add(heading('Layers')); this.layers = new SortableList({ + title: 'Layers', reversed: true, + keepOne: 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), + onAdd: () => this.addLayer(), + onRemove: (id) => this.withAsset((a) => removeLayer(state, a, id)), thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, id)), }); c.add(this.layers); @@ -145,34 +148,27 @@ export class PixelArtSide extends ScrollContainer { c.add(row(new Label('Name', { style: 'muted' }), this.layerName)); c.add(row(new Label('Opacity', { style: 'muted' }), this.layerOpacity)); this.layerButtons = { - add: new Button('Add', { onClick: () => this.addLayer() }), - remove: new Button('Delete', { - onClick: () => this.withAsset((a) => removeLayer(state, a, this.canvas.layerId)), - }), merge: new Button('Merge down', { onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)), }), }; - const lb = this.layerButtons; - c.add(row(lb.add, lb.remove, lb.merge)); + c.add(row(this.layerButtons.merge)); - c.add(heading('Frames')); this.frames = new SortableList({ + title: 'Frames', + keepOne: true, onPick: (id) => this.canvas.selectFrame(id), onMove: (from, to) => this.withAsset((a) => a.frames[from] && moveFrame(state, a, a.frames[from].id, to)), + onAdd: () => this.addFrame(false), + onRemove: (id) => this.withAsset((a) => removeFrame(state, a, id)), 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) }), duplicate: new Button('Duplicate', { onClick: () => this.addFrame(true) }), - remove: new Button('Delete', { - onClick: () => this.withAsset((a) => removeFrame(state, a, this.canvas.frameId)), - }), }; - const fb = this.frameButtons; - c.add(row(fb.add, fb.duplicate, fb.remove)); + c.add(row(this.frameButtons.duplicate)); c.add(heading('View')); this.onionBox.onChange = (on) => { @@ -357,10 +353,7 @@ export class PixelArtSide extends ScrollContainer { this.layerName.setValue(layer?.name ?? ''); this.layerOpacity.setNumber(layer?.opacity ?? 1); const index = asset && layer ? asset.layers.indexOf(layer) : -1; - const lb = this.layerButtons; - lb.add.setEnabled(asset !== null); - lb.remove.setEnabled((asset?.layers.length ?? 0) > 1); - lb.merge.setEnabled(index > 0); + this.layerButtons.merge.setEnabled(index > 0); const frameId = this.canvas.frameId; this.frames.setItems( @@ -368,16 +361,14 @@ export class PixelArtSide extends ScrollContainer { 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); + this.frameButtons.duplicate.setEnabled(frameIndex >= 0); this.relayoutContent(); } } function heading(text: string): Label { - return new Label(text.toUpperCase(), { style: 'muted' }); + // The style the lists' own headers use, so the column reads as one. + return new Label(text.toUpperCase(), { style: 'title' }); } function row(...widgets: Widget[]): HStack { diff --git a/src/editor/vectorart/ShapeList.ts b/src/editor/vectorart/ShapeList.ts index 93f46361..81b754e7 100644 --- a/src/editor/vectorart/ShapeList.ts +++ b/src/editor/vectorart/ShapeList.ts @@ -27,7 +27,8 @@ export class ShapeList extends SortableList { /** * `selectedIds` are the rows marked. `onPick` is told the row pressed and whether it was pressed with - * Ctrl or Shift, which adds the shape to the selection or takes it out (#187). + * Ctrl or Shift, which adds the shape to the selection or takes it out (#187). `onRemove` is the + * cross on a row. There is no plus: a shape is drawn with the Pen, and "Add shape" stays below. */ constructor( private readonly state: EditorState, @@ -36,12 +37,15 @@ export class ShapeList extends SortableList { selectedIds: readonly string[], onPick: (shapeId: string, toggle: boolean) => void, onReorder: (from: number, to: number) => void, + onRemove: (shapeId: string) => void, ) { super({ + title: 'Shapes', reversed: true, multiSelect: true, onPick, onMove: onReorder, + onRemove, thumb: (id, art, size) => this.paintSwatch(id, art, size), }); const asset = this.asset(); diff --git a/src/ui/widgets/SortableList.ts b/src/ui/widgets/SortableList.ts index 2140da56..701d67fe 100644 --- a/src/ui/widgets/SortableList.ts +++ b/src/ui/widgets/SortableList.ts @@ -1,10 +1,11 @@ /** * 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. + * carry a small picture the owner paints, an eye that hides the item without removing it and a cross + * that removes it. Over the rows a header names the list and holds the plus that adds an item. * - * 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. + * The list never changes its items itself. A drop, a cross or the plus reports to the owner, 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'; @@ -18,6 +19,10 @@ const THUMB = 22; const PAD = 4; /** Width at the right end of a row that belongs to the eye. */ const EYE_SLOT = 26; +/** Width at the very right end of a row that belongs to the cross, and of the header to the plus. */ +const REMOVE_SLOT = 24; +/** Height of the header, where there is one. */ +const HEADER_HEIGHT = 24; /** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */ const DRAG_THRESHOLD = 4; @@ -52,6 +57,14 @@ export interface SortableListOptions { 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; + /** Setting this gives every row a cross at its right end; a press on it reports the item. */ + onRemove?: (id: string) => void; + /** No cross while a single item is left – a map or a picture needs one layer to paint on. */ + keepOne?: boolean; + /** Stands in the header, left. */ + title?: string; + /** Setting this puts a plus at the right end of the header. */ + onAdd?: () => void; multiSelect?: boolean; } @@ -62,6 +75,7 @@ interface Row { label: Text; hint: Text; eye: Graphics; + cross: Graphics; } interface RowDrag { @@ -69,37 +83,47 @@ interface RowDrag { from: number; /** The pressed row is to be picked once the pointer is up. */ pick: boolean; + /** Both in the rows' own coordinates, below the header. */ startY: number; y: number; active: boolean; } +/** What the pointer is over: a row, a row's cross, or the plus. */ +type Hover = { row: number; cross: boolean } | 'add' | null; + export class SortableList extends Widget { private readonly background = new Graphics(); private readonly marker = new Graphics(); + private readonly heading = new Text({ text: '', style: textStyle('title') }); + private readonly plus = new Graphics(); private readonly rows: Row[] = []; /** Display order, top first. */ private shown: SortableItem[] = []; private selected: readonly string[] = []; - private hoveredRow = -1; + private over: Hover = null; private drag: RowDrag | null = null; constructor(private readonly options: SortableListOptions) { super(); this.cursor = Theme.hoverCursor; this.addChild(this.background); + this.heading.text = (options.title ?? '').toUpperCase(); + this.heading.eventMode = 'none'; + this.plus.eventMode = 'none'; + this.addChild(this.heading, this.plus); 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; + const hovered = this.hitAt(this.toLocal(e.global)); + if (sameHover(hovered, this.over)) return; + this.over = hovered; this.invalidate(); }); this.on('pointerout', () => { - if (this.hoveredRow < 0) return; - this.hoveredRow = -1; + if (!this.over) return; + this.over = null; this.invalidate(); }); this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e)); @@ -120,7 +144,8 @@ export class SortableList extends Widget { } override measure(): Size { - return { w: this.minWidth, h: Math.max(SORTABLE_ROW_HEIGHT, this.shown.length * SORTABLE_ROW_HEIGHT) }; + const rows = Math.max(SORTABLE_ROW_HEIGHT, this.shown.length * SORTABLE_ROW_HEIGHT); + return { w: this.minWidth, h: this.headerHeight + rows }; } /** The ids as the list shows them, top first – for the check scripts. */ @@ -133,7 +158,12 @@ export class SortableList extends Widget { 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 }; + return { + x: origin.x, + y: origin.y + this.headerHeight + 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. */ @@ -147,7 +177,35 @@ export class SortableList extends Widget { 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 }; + return { x: row.x + row.w - this.crossWidth - EYE_SLOT / 2, y: row.y + row.h / 2 }; + } + + /** The centre of an item's cross (test hook); null where the row has none. */ + removeGlobal(id: string): { x: number; y: number } | null { + const row = this.rowGlobal(id); + if (!row || !this.canRemove) return null; + return { x: row.x + row.w - REMOVE_SLOT / 2, y: row.y + row.h / 2 }; + } + + /** The centre of the plus (test hook); null without one. */ + addGlobal(): { x: number; y: number } | null { + if (!this.options.onAdd) return null; + const origin = this.getGlobalPosition(); + return { x: origin.x + this.w - REMOVE_SLOT / 2, y: origin.y + HEADER_HEIGHT / 2 }; + } + + private get headerHeight(): number { + return this.options.title || this.options.onAdd ? HEADER_HEIGHT : 0; + } + + /** Whether the rows show a cross now – with `keepOne`, not on the last item. */ + private get canRemove(): boolean { + return Boolean(this.options.onRemove) && !(this.options.keepOne && this.shown.length <= 1); + } + + /** Width the cross takes at the right end of every row, or none. */ + private get crossWidth(): number { + return this.options.onRemove ? REMOVE_SLOT : 0; } private buildRow(): Row { @@ -157,26 +215,44 @@ export class SortableList extends Widget { const label = new Text({ text: '', style: textStyle('body') }); const hint = new Text({ text: '', style: textStyle('small') }); const eye = new Graphics(); + const cross = new Graphics(); box.eventMode = 'none'; - box.addChild(art, clip, label, hint, eye); + box.addChild(art, clip, label, hint, eye, cross); art.mask = clip; this.addChildAt(box, this.getChildIndex(this.marker)); - return { box, art, clip, label, hint, eye }; + return { box, art, clip, label, hint, eye, cross }; } private rowAt(y: number): number { - const index = Math.floor(y / SORTABLE_ROW_HEIGHT); + const index = Math.floor((y - this.headerHeight) / SORTABLE_ROW_HEIGHT); return index >= 0 && index < this.shown.length ? index : -1; } + private hitAt(local: { x: number; y: number }): Hover { + if (local.y < this.headerHeight) + return this.options.onAdd && local.x >= this.w - REMOVE_SLOT ? 'add' : null; + const row = this.rowAt(local.y); + if (row < 0) return null; + return { row, cross: this.canRemove && local.x >= this.w - REMOVE_SLOT }; + } + 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; + const hit = this.hitAt(local); + if (!hit) return; e.stopPropagation(); - const id = this.shown[index]!.id; - if (this.options.onToggleHidden && local.x >= this.w - EYE_SLOT) { + if (hit === 'add') { + this.options.onAdd?.(); + return; + } + const id = this.shown[hit.row]!.id; + if (hit.cross) { + this.options.onRemove?.(id); + return; + } + const eyeRight = this.w - this.crossWidth; + if (this.options.onToggleHidden && local.x >= eyeRight - EYE_SLOT && local.x < eyeRight) { this.options.onToggleHidden(id); return; } @@ -186,13 +262,14 @@ export class SortableList extends Widget { } // 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 }; + const y = local.y - this.headerHeight; + this.drag = { from: hit.row, pick, startY: y, y, active: false }; } private onMove(e: FederatedPointerEvent): void { const drag = this.drag; if (!drag) return; - drag.y = this.toLocal(e.global).y; + drag.y = this.toLocal(e.global).y - this.headerHeight; if (!drag.active && Math.abs(drag.y - drag.startY) < DRAG_THRESHOLD) return; drag.active = true; this.invalidate(); @@ -213,17 +290,25 @@ export class SortableList extends Widget { } override redraw(): void { - this.background.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.control); + const top = this.headerHeight; + this.background + .clear() + .rect(0, top, this.w, this.h - top) + .fill(Theme.colors.control); this.marker.clear(); + this.redrawHeader(); const eyes = Boolean(this.options.onToggleHidden); + const crosses = this.canRemove; + const crossW = this.crossWidth; const thumbs = this.options.thumb; const textX = thumbs ? PAD * 2 + THUMB : PAD * 2; + const hovered = this.over !== 'add' ? this.over : null; for (const [index, item] of this.shown.entries()) { const row = this.rows[index]!; - const y = index * SORTABLE_ROW_HEIGHT; + const y = top + index * SORTABLE_ROW_HEIGHT; row.box.position.set(0, y); const selected = this.selected.includes(item.id); - if (selected || index === this.hoveredRow) + if (selected || hovered?.row === index) this.background .rect(0, y, this.w, SORTABLE_ROW_HEIGHT) .fill(selected ? Theme.colors.selection : Theme.colors.controlHover); @@ -243,32 +328,60 @@ export class SortableList extends Widget { 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), + Math.round(this.w - crossW - 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), + Math.max(0, this.w - textX - hintW - eyeW - crossW - 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); + if (eyes) drawEye(row.eye, this.w - crossW - EYE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, !item.hidden); + row.cross.clear(); + if (crosses) { + const hot = hovered?.row === index && hovered.cross; + drawCross(row.cross, this.w - REMOVE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, hot); + } 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; + const y = top + 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); } } + private redrawHeader(): void { + const top = this.headerHeight; + this.heading.visible = top > 0; + this.heading.position.set(0, Math.round((top - this.heading.height) / 2)); + this.plus.clear(); + if (!this.options.onAdd) return; + const x = this.w - REMOVE_SLOT / 2; + const y = top / 2; + if (this.over === 'add') + this.plus.roundRect(this.w - REMOVE_SLOT, 1, REMOVE_SLOT, top - 2, 3).fill(Theme.colors.controlHover); + this.plus + .moveTo(x - 5, y) + .lineTo(x + 5, y) + .moveTo(x, y - 5) + .lineTo(x, y + 5) + .stroke({ width: 1.5, color: this.over === 'add' ? Theme.colors.text : Theme.colors.textMuted }); + } + override layout(): void { this.redraw(); } } +function sameHover(a: Hover, b: Hover): boolean { + if (a === null || b === null || a === 'add' || b === 'add') return a === b; + return a.row === b.row && a.cross === b.cross; +} + /** 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; @@ -279,3 +392,12 @@ function drawEye(g: Graphics, x: number, y: number, open: boolean): void { .lineTo(x + 6, y - 5) .stroke({ width: 1.2, color }); } + +/** The cross that removes a row: muted, and in the danger colour under the pointer. */ +function drawCross(g: Graphics, x: number, y: number, hot: boolean): void { + g.moveTo(x - 4, y - 4) + .lineTo(x + 4, y + 4) + .moveTo(x + 4, y - 4) + .lineTo(x - 4, y + 4) + .stroke({ width: 1.4, color: hot ? Theme.colors.danger : Theme.colors.textMuted }); +} diff --git a/tests/editor/shape-rows.test.ts b/tests/editor/shape-rows.test.ts index 4e44605b..372e5f6a 100644 --- a/tests/editor/shape-rows.test.ts +++ b/tests/editor/shape-rows.test.ts @@ -28,6 +28,7 @@ import type { InspectorHost } from '../../src/editor/inspector/host'; import { buildShapeListSection, buildShapeSection, + currentShapeList, refreshShapeSection, setFillTexture, setPaintKind, @@ -195,7 +196,8 @@ describe('shape rows', () => { 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'); + // The list carries its "SHAPES" header itself. + expect(currentShapeList()?.shownIds).toHaveLength(2); expect(texts(host)).toContain('The top row is drawn over the ones below it.'); }); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 18b9ef5f..b6f4f914 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -447,7 +447,7 @@ export async function tilemap(t) { ); await undo(); await settle(); - // With no layer chosen the topmost is painted on, so that is the one Delete layer takes. + // With no layer chosen the topmost is painted on; the hook points at that row's cross. 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()'); @@ -456,7 +456,7 @@ export async function tilemap(t) { 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', + 'the cross takes out the layer painted on, and the last layer has none', leftOver.length === 1 && !leftOver.includes(paintedLayer) && lastOne?.enabled === false, JSON.stringify({ paintedLayer, leftOver, lastOne }), ); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 3dcfa26a..240d288f 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -371,6 +371,20 @@ export async function vectorArt(t) { JSON.stringify({ added: added.length, selected: await json('__gm.vectorArt.selected()') }), ); + // The cross on its row takes that shape out again, and the selection with it. + const cross = await json(`__gm.vectorArt.shapeRemoveAt(${JSON.stringify(added.at(-1))})`); + if (cross) await click(cross.x, cross.y); + await settle(); + const afterCross = await shapeIds(); + check( + 'the cross on a shape row removes that shape and nothing else', + cross !== null && + afterCross.length === before47e && + !afterCross.includes(added.at(-1)) && + (await json('__gm.vectorArt.selected()')) === '', + JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }), + ); + // 47f (stage 5). The way a user takes, which the blocks before took by hand: the asset is made // from the + menu, its row opens the tab, and a sprite wears the drawing through its // Texture row – the picture picker of #185, which until here only unit tests had seen. @@ -848,7 +862,10 @@ export async function vectorArt(t) { // out and in, a canvas of the old editor used to swallow the news (#196). let leftHalf = await tabPixel(60, 100); let rightHalf = await tabPixel(196, 100); - for (let waited = 0; waited < 3000 && colourDistance(rightHalf, HALVES_RIGHT) >= 20; waited += 100) { + // Both halves, or the picture may land between the two reads and leave the left one stale. + const landed = () => + colourDistance(leftHalf, HALVES_LEFT) < 20 && colourDistance(rightHalf, HALVES_RIGHT) < 20; + for (let waited = 0; waited < 3000 && !landed(); waited += 100) { await sleep(100); leftHalf = await tabPixel(60, 100); rightHalf = await tabPixel(196, 100); diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index d9c06ed8..2c2a40a3 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -371,6 +371,19 @@ export async function pixelArt(t) { await undo(); await settle(); + // 48h-iii. The cross on a frame row takes that frame out; undo brings it back. + const lastFrameAgain = await listRow('frame', framesBefore[2]); + if (lastFrameAgain?.remove) await click(lastFrameAgain.remove.x, lastFrameAgain.remove.y); + await settle(); + const framesCrossed = (await asset(id)).frames.map((f) => f.id); + check( + 'the cross on a frame row removes that frame', + lastFrameAgain?.remove != null && framesCrossed.join() === framesBefore.slice(0, 2).join(), + JSON.stringify({ remove: lastFrameAgain?.remove, framesBefore, framesCrossed }), + ); + 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)})`);