From ae0247d86f25acd369f79742dfb701875eeac273 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 21 Sep 2026 21:18:26 +0200 Subject: [PATCH] Lists add and delete items right in the list header and rows The layer, frame and shape lists still had their add and delete buttons scattered below the list, each in its own layout. They now live in the shared SortableList, so every list works the same way. - SortableList gets an optional header with the title on the left and a + on the right, plus an optional x per row next to the eye. - The keepOne option hides the x on the last remaining row, so a pixel art image and a tilemap always keep one layer. - Pixel art layers and frames and tilemap layers use header, + and x. Only Merge down and Duplicate stay below the lists. - Vector art shapes get the x but no +. Add shape stays below the list and Delete shapes stays for a multi-selection. - The section headings in the pixel art column use the same style as the list titles and the inspector. Findings: - The wait loop added to check 47i for #196 only waited for the right half of the shape. If the texture landed between the two reads, the left half was still blue and the check failed. It now waits for both halves. Tests: - ./check.sh: 138 files, 2052 tests, exit 0. - GM_STEPS=39,40,47,48 (fresh): ALL PASSED, 156 checks (was 154). - GM_STEPS=47,48 (fresh, final): ALL PASSED, 86 checks. - Two new e2e checks: x on a shape (47) and x on a frame (48). The tilemap check in 40 now uses the x instead of Delete layer. - Counter-check: with the x not reporting to its owner, exactly the two new checks fail. - Check total 1266 in README and wiki is computed (1264 + 2). Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/editor/debugHook.ts | 37 ++++-- src/editor/inspector/shapeSection.ts | 37 +++--- src/editor/panels/TilemapPanel.ts | 23 ++-- src/editor/pixelart/PixelArtSide.ts | 41 +++--- src/editor/vectorart/ShapeList.ts | 6 +- src/ui/widgets/SortableList.ts | 178 ++++++++++++++++++++++----- tests/editor/shape-rows.test.ts | 4 +- tools/e2e/40-tilemap.mjs | 4 +- tools/e2e/47-vectorart.mjs | 19 ++- tools/e2e/48-pixelart.mjs | 13 ++ 11 files changed, 264 insertions(+), 100 deletions(-) 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)})`);