Lists duplicate, rename and edit values right in their rows
game-maker / gate (push) Successful in 2m20s
game-maker / suite (push) Successful in 8m25s

Name, opacity and Z each sat in a separate field below or beside
the list. You had to select a row first and then look elsewhere to
change it. Duplicating was a separate button or not possible at all.
Now the row itself carries everything.

- SortableList gets three optional callbacks. onDuplicate shows a
  copy button between the eye and the delete button. onRename opens
  a text field on double click, Enter or a click outside commits,
  Esc cancels, an empty name is ignored. value puts a NumberField
  into the row.
- Dragging on the number changes the value and never moves the row.
  A whole drag is one undo step, the canvas follows live.
- Copy and delete only show on the hovered and the selected row.
  Their space stays reserved, so nothing jumps on hover.
- Pixel layers: copy right above with the same pixels on all
  frames, rename, opacity in percent. The Name and Opacity fields
  below the list are gone.
- Pixel frames: copy right after, replaces the Duplicate button.
- Tilemap layers: copy with cells, Z and visibility, rename, Z in
  the row. The Name and Z fields in the panel are gone.
- Vector shapes: copy at the same place and select it, rename.
- New in core: DuplicateVectorShapeCommand,
  DuplicateTilemapLayerCommand, withLayerDuplicated and copyName
  ("X copy", "X copy 2"). copyName lives in src/core/util/names.ts
  because the model never imports from commands/.

Findings:

- Double clicking a shape that is not selected yet only selects it.
  The first click moves the list up, so the second click misses the
  name. Module 47 selects the row first.

Tests:

- ./check.sh: 138 files, 2055 tests, CHECK EXIT 0 (before 2052).
- GM_STEPS=47,48 (fresh, twice): ALL PASSED, 91 checks (before 86).
- GM_STEPS=39,40,47,48: module 40 green, 47 died on the rename
  check, which was a test fault and is fixed.
- Counter probes: a constant copyName fails the shape and pixel
  layer tests. A dead copy hit fails the copy check in 47.
- Check total 1272 in README and wiki, computed (1266 + 6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-22 06:02:47 +02:00
1 parent ae0247d86f
commit bbbf34109b
19 files changed
+747 -153

No files matched your search

+54 -16
View File
@@ -502,8 +502,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
addColor: side.addColorButton,
removeColor: side.removeColorButton,
createPalette: side.createPaletteButton,
layerName: side.layerName,
layerOpacity: side.layerOpacity,
onion: side.onionBox,
tiles: side.tilesBox,
fps: side.fpsField,
@@ -512,7 +510,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
resize: editor.pixelArtEditor.resizeButton,
...Object.fromEntries([...side.toolButtons].map(([tool, button]) => [tool, button])),
...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;
@@ -537,19 +534,29 @@ 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, `remove` the cross (null on the last one).
* `eye` is the layer's switch, `duplicate` and `remove` the marks at the end (no cross on the
* last one), `name` where a double-click edits the name, `opacity` the field with its text and
* `renaming` the field a name is being edited in.
*/
layerRow: (layerId: string) => {
const side = editor.pixelArtEditor.side;
side.reveal(side.layers);
const row = side.layers.rowGlobal(layerId);
return row
const opacity = side.layers.valueField(layerId);
const renaming = side.layers.renameField;
// On the name: the middle of the row may be the opacity field, which a press would scrub.
const name = side.layers.nameGlobal(layerId);
return row && name
? {
x: row.x + row.w / 2,
y: row.y + row.h / 2,
x: name.x,
y: name.y,
thumb: side.layers.thumbGlobal(layerId),
name,
eye: side.layers.eyeGlobal(layerId),
duplicate: side.layers.duplicateGlobal(layerId),
remove: side.layers.removeGlobal(layerId),
opacity: opacity ? { ...centreOf(opacity), value: opacity.value } : null,
renaming: renaming ? { ...centreOf(renaming), value: renaming.value } : null,
}
: null;
},
@@ -562,6 +569,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
x: row.x + row.w / 2,
y: row.y + row.h / 2,
thumb: side.frames.thumbGlobal(frameId),
duplicate: side.frames.duplicateGlobal(frameId),
remove: side.frames.removeGlobal(frameId),
}
: null;
@@ -662,6 +670,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null,
/** The cross on a shape's row. */
shapeRemoveAt: (shapeId: string) => currentShapeList()?.removeGlobal(shapeId) ?? null,
/** The duplicate mark on a shape's row, and where a double-click edits its name. */
shapeDuplicateAt: (shapeId: string) => currentShapeList()?.duplicateGlobal(shapeId) ?? null,
shapeNameAt: (shapeId: string) => currentShapeList()?.nameGlobal(shapeId) ?? null,
/** The field a shape's name is being edited in, with what it holds; null while none is. */
shapeRenaming: () => {
const field = currentShapeList()?.renameField;
return field ? { ...centreOf(field), value: field.value } : null;
},
/** The Paint tool (#186): its button, the "Handles" menu, and which part it shows. */
paintButton: () => centreOf(vectorArtEditor.paintButton),
paintTarget: () => ({
@@ -784,15 +800,37 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
},
/** 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). */
/**
* Where the painted layer's name stands in its row – a double-click there edits it (#138) –
* with the name the row shows and the field it is being edited in, while it is.
*/
layerNameAt: () => {
const field = currentTilemapWidgets()?.layerName;
const widgets = currentTilemapWidgets();
const target = paintedTilemap(state);
if (!widgets || !target) return null;
const layerId = paintedLayerId(state, target.asset);
const at = widgets.layers.nameGlobal(layerId);
const editing = widgets.layers.renameField;
return at
? {
...at,
value: findLayer(target.asset, layerId)?.name ?? '',
editing: editing ? { ...centreOf(editing), value: editing.value } : null,
}
: null;
},
/** The Z field in the painted layer's row, with what it holds (#174). */
layerZAt: () => {
const widgets = currentTilemapWidgets();
const target = paintedTilemap(state);
const field = widgets && target && widgets.layers.valueField(paintedLayerId(state, target.asset));
return field ? { ...centreOf(field), value: field.value } : null;
},
/** The Z field of the painted layer, with what it holds (#174). */
layerZAt: () => {
const field = currentTilemapWidgets()?.layerZ;
return field ? { ...centreOf(field), value: field.value } : null;
/** The duplicate mark on the painted layer's row. */
duplicateLayerAt: () => {
const widgets = currentTilemapWidgets();
const target = paintedTilemap(state);
return widgets && target ? widgets.layers.duplicateGlobal(paintedLayerId(state, target.asset)) : null;
},
focusAt: () => {
const button = currentTilemapWidgets()?.focus;
@@ -802,9 +840,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
layerRowAt: (index: number) => {
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 };
// On the name: the middle of the row may be the Z field, which a press would scrub.
const name = id ? list.nameGlobal(id) : null;
if (!list || !id || !name) return null;
// `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) };
},
+15 -6
View File
@@ -11,6 +11,7 @@
import {
AddVectorShapeCommand,
CombineVectorShapesCommand,
DuplicateVectorShapeCommand,
MoveVectorShapeCommand,
RemoveVectorShapeCommand,
RemoveVectorShapesCommand,
@@ -387,12 +388,20 @@ function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedIds: r
asset.id,
asset.shapes.map((shape) => shape.id),
selectedIds,
(shapeId, toggle) =>
toggle
? 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),
{
onPick: (shapeId, toggle) =>
toggle
? host.state.toggleShape(asset.id, shapeId)
: host.state.select(shapeSelection(asset.id, [shapeId])),
onReorder: (from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)),
onDuplicate: (shapeId) => {
const command = new DuplicateVectorShapeCommand(asset.id, shapeId);
if (host.state.execute(command)) host.state.select(shapeSelection(asset.id, [command.shapeId]));
},
onRemove: (shapeId) => removeShape(host, asset.id, shapeId),
onRename: (shapeId, name) =>
void host.state.execute(new RenameVectorShapeCommand(asset.id, shapeId, name)),
},
);
built = list;
host.body.add(list);
+22 -35
View File
@@ -9,6 +9,7 @@
*/
import {
AddTilemapLayerCommand,
DuplicateTilemapLayerCommand,
MoveTilemapLayerCommand,
RemoveTilemapLayerCommand,
RenameTilemapLayerCommand,
@@ -25,11 +26,9 @@ import {
Dropdown,
HStack,
Menu,
NumberField,
Panel,
ScrollContainer,
SortableList,
TextField,
Theme,
ui,
VStack,
@@ -133,12 +132,11 @@ 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. */
/**
* The layers, with the plus that adds one. Each row names its layer (#138, a double-click edits the
* name), holds its `z` above the map's own (#174) and duplicates or removes it.
*/
layers: SortableList;
/** 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;
}
@@ -258,22 +256,6 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
const { state } = host;
const paint = state.tilemapPaint;
const painted = paintedLayerId(state, asset);
const paintedLayer = findLayer(asset, painted);
const paintedName = paintedLayer?.name ?? '';
const layerName: TextField = new TextField(paintedName, {
onChange: (name) => {
// The command rejects a blank name but cannot put the old text back into the field,
// so an empty one is not sent at all.
if (name.trim()) state.execute(new RenameTilemapLayerCommand(asset.id, painted, name));
else layerName.setValue(paintedName);
},
});
const layerZField = new NumberField(paintedLayer ? layerZ(paintedLayer) : 0, {
onChange: (z) => {
if (paintedLayer && z !== layerZ(paintedLayer))
state.execute(new SetTilemapLayerZCommand(asset.id, painted, z));
},
});
const focus: Dropdown = new Dropdown(FOCUS.find((f) => f.focus === paint.focus)?.label ?? '', {
onClick: () =>
new Menu(
@@ -297,10 +279,23 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
new SetTilemapLayerHiddenCommand(asset.id, layerId, isLayerVisible(findLayer(asset, layerId)!)),
),
onAdd: () => state.execute(new AddTilemapLayerCommand(asset.id)),
onDuplicate: (layerId) => {
const command = new DuplicateTilemapLayerCommand(asset.id, layerId);
if (state.execute(command)) state.setTilemapPaint({ layerId: command.layerId });
},
onRemove: (layerId) => state.execute(new RemoveTilemapLayerCommand(asset.id, layerId)),
onRename: (layerId, name) => state.execute(new RenameTilemapLayerCommand(asset.id, layerId, name)),
value: {
get: (layerId) => {
const layer = findLayer(asset, layerId);
return layer ? layerZ(layer) : null;
},
set: (layerId, z) => {
const layer = findLayer(asset, layerId);
if (layer && z !== layerZ(layer)) state.execute(new SetTilemapLayerZCommand(asset.id, layerId, z));
},
},
}),
layerName,
layerZ: layerZField,
focus,
};
built = widgets;
@@ -342,16 +337,8 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
[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);
const zRow = labelledRow('Z').row;
zRow.add(layerZField, 1);
host.body.add(zRow);
addNote(host, 'Added to the map’s own Z, as a child’s is.', 'small');
addNote(host, 'Drag a row to reorder, double-click a name to rename.', 'small');
addNote(host, 'The number is the layer’s Z, added to the map’s.', 'small');
const showRow = labelledRow('Show').row;
showRow.add(focus, 1);
host.body.add(showRow);
+55 -28
View File
@@ -35,6 +35,7 @@ import {
addFrame,
addLayer,
addPaletteColor,
duplicateLayer,
mergeLayerDown,
moveFrame,
moveLayer,
@@ -69,12 +70,11 @@ export class PixelArtSide extends ScrollContainer {
readonly removeColorButton = new Button('Remove last');
readonly createPaletteButton = new Button('Create palette');
private readonly paletteNote = new Label('', { style: 'muted' });
/** Each row names its layer (a double-click edits it), holds its opacity in percent and duplicates it. */
readonly layers: SortableList;
readonly layerName = new TextField('');
readonly layerOpacity = new NumberField(1, { min: 0, max: 1, step: 0.05, decimals: 2 });
readonly layerButtons: Record<'merge', Button>;
/** Each row duplicates its frame right after it. */
readonly frames: SortableList;
readonly frameButtons: Record<'duplicate', Button>;
private readonly thumbs = new PixelThumbs();
private readonly stopCels: () => void;
readonly onionBox = new Checkbox(false);
@@ -137,16 +137,25 @@ export class PixelArtSide extends ScrollContainer {
this.withAsset((a) => a.layers[from] && moveLayer(state, a, a.layers[from].id, to)),
onToggleHidden: (id) => this.toggleHidden(id),
onAdd: () => this.addLayer(),
onDuplicate: (id) => this.duplicateLayer(id),
onRemove: (id) => this.withAsset((a) => removeLayer(state, a, id)),
onRename: (id, name) => this.withAsset((a) => renameLayer(state, a, id, name)),
value: {
get: (id) => {
const layer = this.canvas.asset()?.layers.find((l) => l.id === id);
return layer ? Math.round(layer.opacity * 100) : null;
},
preview: (id, percent) => this.previewOpacity(id, percent / 100),
set: (id, percent) => this.commitOpacity(id, percent / 100),
min: 0,
max: 100,
step: 1,
decimals: 0,
suffix: '%',
},
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));
this.layerOpacity.onNumberChange = (value) =>
this.withAsset((asset) => setLayerOpacity(state, asset, this.canvas.layerId, value));
c.add(row(new Label('Name', { style: 'muted' }), this.layerName));
c.add(row(new Label('Opacity', { style: 'muted' }), this.layerOpacity));
this.layerButtons = {
merge: new Button('Merge down', {
onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)),
@@ -160,15 +169,12 @@ export class PixelArtSide extends ScrollContainer {
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),
onAdd: () => this.addFrame(this.canvas.frameId, false),
onDuplicate: (id) => this.addFrame(id, true),
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 = {
duplicate: new Button('Duplicate', { onClick: () => this.addFrame(true) }),
};
c.add(row(this.frameButtons.duplicate));
c.add(heading('View'));
this.onionBox.onChange = (on) => {
@@ -296,6 +302,36 @@ export class PixelArtSide extends ScrollContainer {
if (id) this.canvas.selectLayer(id);
}
private duplicateLayer(layerId: string): void {
const asset = this.canvas.asset();
if (!asset) return;
const id = duplicateLayer(this.state, asset, layerId);
if (id) this.canvas.selectLayer(id);
}
/** The opacity a scrub started from, per layer – what the commit puts back first, as with a colour. */
private readonly opacityBefore = new Map<string, number>();
/** The opacity under the scrub straight into the layer, so the canvas shows it before it is an undo step. */
private previewOpacity(layerId: string, opacity: number): void {
const layer = this.canvas.asset()?.layers.find((l) => l.id === layerId);
if (!layer) return;
if (!this.opacityBefore.has(layerId)) this.opacityBefore.set(layerId, layer.opacity);
layer.opacity = opacity;
this.state.preview();
}
private commitOpacity(layerId: string, opacity: number): void {
const asset = this.canvas.asset();
const layer = asset?.layers.find((l) => l.id === layerId);
const before = this.opacityBefore.get(layerId);
this.opacityBefore.delete(layerId);
if (!asset || !layer) return;
if (before !== undefined) layer.opacity = before;
if (opacity !== layer.opacity) setLayerOpacity(this.state, asset, layerId, opacity);
else if (before !== undefined) this.state.preview();
}
private toggleHidden(layerId: string): void {
const asset = this.canvas.asset();
const layer = asset?.layers.find((l) => l.id === layerId);
@@ -314,10 +350,11 @@ export class PixelArtSide extends ScrollContainer {
if (frame) this.thumbs.paint(art, size, asset, paletteColors(this.state.project, asset), frame, layerId);
}
private addFrame(copy: boolean): void {
/** A frame after `afterId`: empty, or a copy of it. */
private addFrame(afterId: string, copy: boolean): void {
const asset = this.canvas.asset();
if (!asset) return;
const id = addFrame(this.state, asset, this.canvas.frameId, copy);
const id = addFrame(this.state, asset, afterId, copy);
if (id) this.canvas.selectFrame(id);
}
@@ -341,18 +378,10 @@ export class PixelArtSide extends ScrollContainer {
const layerId = this.canvas.layerId;
this.layers.setItems(
(asset?.layers ?? []).map((layer) => ({
id: layer.id,
label: layer.name,
hint: layer.opacity < 1 ? `${Math.round(layer.opacity * 100)}%` : undefined,
hidden: layer.hidden,
})),
(asset?.layers ?? []).map((layer) => ({ id: layer.id, label: layer.name, hidden: layer.hidden })),
layerId ? [layerId] : [],
);
const layer = asset?.layers.find((l) => l.id === layerId);
this.layerName.setValue(layer?.name ?? '');
this.layerOpacity.setNumber(layer?.opacity ?? 1);
const index = asset && layer ? asset.layers.indexOf(layer) : -1;
const index = asset ? asset.layers.findIndex((l) => l.id === layerId) : -1;
this.layerButtons.merge.setEnabled(index > 0);
const frameId = this.canvas.frameId;
@@ -360,8 +389,6 @@ export class PixelArtSide extends ScrollContainer {
(asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })),
frameId ? [frameId] : [],
);
const frameIndex = asset ? asset.frames.findIndex((f) => f.id === frameId) : -1;
this.frameButtons.duplicate.setEnabled(frameIndex >= 0);
this.relayoutContent();
}
}
+7
View File
@@ -17,6 +17,7 @@ import {
withFrameRemoved,
withLayerAdded,
withLayerChanged,
withLayerDuplicated,
withLayerMergedDown,
withLayerMoved,
withLayerRemoved,
@@ -52,6 +53,12 @@ export function addLayer(state: EditorState, asset: PixelArtAsset, aboveId: stri
return edit(state, asset, 'Add layer', (s) => withLayerAdded(s, id, aboveId)) ? id : '';
}
/** A copy of the layer right above it; its id, so the tab can go on painting on the copy. */
export function duplicateLayer(state: EditorState, asset: PixelArtAsset, layerId: string): string {
const id = newId();
return edit(state, asset, 'Duplicate layer', (s) => withLayerDuplicated(s, id, layerId)) ? id : '';
}
export function removeLayer(state: EditorState, asset: PixelArtAsset, layerId: string): boolean {
return edit(state, asset, 'Delete layer', (s) => withLayerRemoved(s, layerId));
}
+15 -8
View File
@@ -27,25 +27,32 @@ 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). `onRemove` is the
* cross on a row. There is no plus: a shape is drawn with the Pen, and "Add shape" stays below.
* Ctrl or Shift, which adds the shape to the selection or takes it out (#187). `onDuplicate` and
* `onRemove` are the marks at the end of a row, `onRename` a name edited in its row. There is no
* plus: a shape is drawn with the Pen, and "Add shape" stays below.
*/
constructor(
private readonly state: EditorState,
private readonly assetId: string,
shapeIds: readonly string[],
selectedIds: readonly string[],
onPick: (shapeId: string, toggle: boolean) => void,
onReorder: (from: number, to: number) => void,
onRemove: (shapeId: string) => void,
rows: {
onPick: (shapeId: string, toggle: boolean) => void;
onReorder: (from: number, to: number) => void;
onDuplicate: (shapeId: string) => void;
onRemove: (shapeId: string) => void;
onRename: (shapeId: string, name: string) => void;
},
) {
super({
title: 'Shapes',
reversed: true,
multiSelect: true,
onPick,
onMove: onReorder,
onRemove,
onPick: rows.onPick,
onMove: rows.onReorder,
onDuplicate: rows.onDuplicate,
onRemove: rows.onRemove,
onRename: rows.onRename,
thumb: (id, art, size) => this.paintSwatch(id, art, size),
});
const asset = this.asset();