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) };
},