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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 21:18:26 +02:00
1 parent 5b9b57fd19
commit ae0247d86f
11 files changed
+264 -100

No files matched your search

+28 -9
View File
@@ -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),
+21 -16
View File
@@ -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: () => {
+7 -16
View File
@@ -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<TilemapTool, Button>;
/** 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[] {
+16 -25
View File
@@ -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 {
+5 -1
View File
@@ -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();