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

+38 -17
View File
@@ -463,31 +463,33 @@ export async function tilemap(t) {
await undo();
await settle();
// 40m. The painted layer is named in the field below the list, and Show singles it out in the
// scene view (#138). It sits here, not at the end, because here both layers exist: the upper
// one is painted on and holds the cell (4,0), the lower one the 3 × 3 block.
const nameField = await inPanel(t, '__gm.tilemap.layerNameAt()');
if (nameField) {
await click(nameField.x, nameField.y);
await press('a', { ctrl: true });
await typeText('Roofs');
await press('Enter');
}
// 40m. A double-click on the painted layer's name edits it in its row, and Show singles it out in
// the scene view (#138). It sits here, not at the end, because here both layers exist: the
// upper one is painted on and holds the cell (4,0), the lower one the 3 × 3 block.
const nameSpot = await inPanel(t, '__gm.tilemap.layerNameAt()');
if (nameSpot) await t.doubleClick(nameSpot.x, nameSpot.y);
await settle();
const nameEditing = (await json('__gm.tilemap.layerNameAt()'))?.editing;
await press('a', { ctrl: true });
await typeText('Roofs');
await press('Enter');
await settle();
layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
const renamedRow = await json('(__gm.tilemap.layerRowAt(0) || {}).id');
const nameNow = await json('(__gm.tilemap.layerNameAt() || {}).value');
const nameNow = await json('__gm.tilemap.layerNameAt()');
check(
'the name field renames the layer painted on, and the list shows the new name',
layers[1].name === 'Roofs' &&
'a double-click on a layer name edits it in the row, and the list shows the new name',
nameEditing?.value === 'Layer 2' &&
layers[1].name === 'Roofs' &&
layers[0].name === 'Layer 1' &&
renamedRow === layers[1].id &&
nameNow === 'Roofs',
JSON.stringify({ layers, renamedRow, nameNow }),
nameNow?.value === 'Roofs' &&
nameNow.editing === null,
JSON.stringify({ nameEditing, layers, renamedRow, nameNow }),
);
// 40p. The Z field below the name gives the painted layer a z of its own, added to the map's
// (#174): at −5 the upper layer is sorted in below the map itself and below the lower layer.
// 40p. The Z field in the row gives the painted layer a z of its own, added to the map's (#174):
// at −5 the upper layer is sorted in below the map itself and below the lower layer.
const zField = await inPanel(t, '__gm.tilemap.layerZAt()');
if (zField) {
await click(zField.x, zField.y);
@@ -519,6 +521,25 @@ export async function tilemap(t) {
JSON.stringify({ withZ, orderZ, orderBack, zBack }),
);
// 40p-ii. The duplicate mark copies the painted layer right above it, cells and all, and paints
// on the copy. Undone again: the blocks below count on two layers.
const copyMark = await inPanel(t, '__gm.tilemap.duplicateLayerAt()');
if (copyMark) await click(copyMark.x, copyMark.y);
await settle();
const withCopy = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
const paintedCopy = (await json('__gm.tilemap.paint()')).layerId;
check(
'the duplicate mark on a layer row puts a copy with the same cells above it and paints on it',
copyMark !== null &&
withCopy.length === 3 &&
withCopy[2].name === 'Roofs copy' &&
withCopy[2].cells === withCopy[1].cells &&
paintedCopy === withCopy[2].id,
JSON.stringify({ copyMark, withCopy, paintedCopy }),
);
await undo();
await settle();
// Pixels again, so the animation is cut down to one step for the reading, as in 40e.
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']");
await settle();
+45
View File
@@ -385,6 +385,51 @@ export async function vectorArt(t) {
JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }),
);
// 47e-ii. The duplicate mark puts a copy right above its shape and selects it; a double-click on
// a name edits it in the row. Both are undone again, the blocks after count on the shapes.
const fieldName = (await shapeOf('va-field')).name;
const duplicateAt = await json("__gm.vectorArt.shapeDuplicateAt('va-field')");
if (duplicateAt) await click(duplicateAt.x, duplicateAt.y);
await settle();
const withCopy = await json("__gm.vectorArt.shapes('va-art')");
const copyIndex = withCopy.findIndex((shape) => shape.id === 'va-field') + 1;
check(
'the duplicate mark on a shape row puts a copy right above it and selects the copy',
duplicateAt !== null &&
withCopy.length === before47e + 1 &&
withCopy[copyIndex]?.name === `${fieldName} copy` &&
withCopy[copyIndex]?.fill === 0x20c0e8 &&
(await json('__gm.vectorArt.selected()')) === withCopy[copyIndex]?.id,
JSON.stringify({ duplicateAt, withCopy: withCopy.map((s) => s.name), copyIndex }),
);
await undo();
await settle();
// Picked first: with only the asset selected the list stands lower, under the asset's rows, and
// the first click of a double-click would move it up from under the pointer.
const pickAt = await json("__gm.vectorArt.shapeNameAt('va-field')");
if (pickAt) await click(pickAt.x, pickAt.y);
await settle();
const nameAt = await json("__gm.vectorArt.shapeNameAt('va-field')");
if (nameAt) await t.doubleClick(nameAt.x, nameAt.y);
await settle();
const renaming = await json('__gm.vectorArt.shapeRenaming()');
await press('a', { ctrl: true });
await typeText('Meadow');
await press('Enter');
await settle();
const renamed = await shapeOf('va-field');
check(
'a double-click on a shape name edits it in the row, and Enter keeps the new name',
renaming?.value === fieldName &&
renamed?.name === 'Meadow' &&
(await json('__gm.vectorArt.shapeRenaming()')) === null &&
(await shapeIds()).length === before47e,
JSON.stringify({ nameAt, renaming, renamed: renamed?.name }),
);
await undo();
await settle();
// 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.
+69 -5
View File
@@ -233,10 +233,11 @@ export async function pixelArt(t) {
const hiddenFlag = (await asset(id)).layers.at(-1).hidden;
await click(eyeAt.x, eyeAt.y);
await settle();
const opacity = await control('layerOpacity');
// The opacity stands in the layer's row, in percent.
const opacity = (await listRow('layer', top)).opacity;
await click(opacity.x, opacity.y);
await press('a', { ctrl: true });
await typeText('0.5');
await typeText('50');
await press('Enter');
await settle();
const half = await screen(10, 10);
@@ -256,7 +257,7 @@ export async function pixelArt(t) {
)
.join('');
check(
'the eye hides a layer, which is then not drawn, and a layer at half opacity is mixed with the one below',
'the eye hides a layer, which is then not drawn, and a layer at 50 % in its row is mixed with the one below',
colourDistance(blueShown, HEX[BLUE]) < 10 &&
hiddenFlag === true &&
colourDistance(hidden, HEX[GREEN]) < 10 &&
@@ -266,6 +267,67 @@ export async function pixelArt(t) {
JSON.stringify({ blueShown, hiddenFlag, hidden, half, mixed, opacity: art.layers.at(-1).opacity }),
);
// 48f-i. Scrubbing the opacity drags the number, not the row: the order stays, the canvas follows
// while the pointer moves, and the whole scrub is one undo step back to 50 %.
const orderBeforeScrub = await json('__gm.pixelArt.layerRows()');
const scrubFrom = (await listRow('layer', top)).opacity;
await drag(scrubFrom.x, scrubFrom.y, scrubFrom.x + 30, scrubFrom.y + 12, { steps: 10 });
await settle();
const scrubbed = (await asset(id)).layers.at(-1).opacity;
const orderAfterScrub = await json('__gm.pixelArt.layerRows()');
const scrubText = (await listRow('layer', top)).opacity?.value;
await undo();
await settle();
const scrubUndone = (await asset(id)).layers.at(-1).opacity;
check(
'scrubbing the opacity in a row changes it without moving the row, and one undo takes it back',
scrubbed === 0.8 &&
scrubText === '80' &&
orderAfterScrub.join() === orderBeforeScrub.join() &&
scrubUndone === 0.5,
JSON.stringify({ scrubbed, scrubText, orderBeforeScrub, orderAfterScrub, scrubUndone }),
);
// 48f-ii-a. The duplicate mark copies the layer right above it and paints on the copy; a
// double-click on its name edits it in the row. Both are undone again.
const layersBeforeCopy = (await asset(id)).layers;
const copyMark = (await listRow('layer', top)).duplicate;
if (copyMark) await click(copyMark.x, copyMark.y);
await settle();
const withLayerCopy = await asset(id);
const layerCopy = withLayerCopy.layers.at(-1);
check(
'the duplicate mark on a layer row puts a copy above it, with the same pixels, and paints on it',
copyMark != null &&
withLayerCopy.layers.length === layersBeforeCopy.length + 1 &&
layerCopy.name === `${layersBeforeCopy.at(-1).name} copy` &&
layerCopy.opacity === 0.5 &&
withLayerCopy.frames[0].cels[layerCopy.id] === withLayerCopy.frames[0].cels[top] &&
(await json('__gm.pixelArt.layerId()')) === layerCopy.id,
JSON.stringify({ copyMark, layers: withLayerCopy.layers, cels: withLayerCopy.frames[0].cels }),
);
await undo();
await settle();
const nameSpot = (await listRow('layer', top)).name;
await t.doubleClick(nameSpot.x, nameSpot.y);
await settle();
const editing = (await listRow('layer', top)).renaming;
await press('a', { ctrl: true });
await typeText('Ink');
await press('Enter');
await settle();
const renamedLayer = (await asset(id)).layers.find((l) => l.id === top);
check(
'a double-click on a layer name edits it in the row, and Enter keeps the new name',
editing?.value === layersBeforeCopy.at(-1).name &&
renamedLayer?.name === 'Ink' &&
(await listRow('layer', top)).renaming === null,
JSON.stringify({ editing, renamed: renamedLayer?.name }),
);
await undo();
await settle();
// 48f-ii. Each layer row shows that layer alone, on the frame painted, and the list is dragged into
// another order. The picture's centre is canvas pixel (16, 16): green below, empty above.
const bottom = art.layers[0].id;
@@ -311,11 +373,13 @@ export async function pixelArt(t) {
);
// 48h. Frames: a copy, a painted difference, onion skin and the playing preview.
await press_('frame:duplicate');
const frameMark = (await listRow('frame', await json('__gm.pixelArt.frameId()'))).duplicate;
await click(frameMark.x, frameMark.y);
await settle();
art = await asset(id);
const second = await json('__gm.pixelArt.frameId()');
check(
'Duplicate adds a copy of the frame after it and paints on the copy',
'the duplicate mark on a frame row adds a copy of the frame after it and paints on the copy',
art.frames.length === 2 &&
art.frames[1].id === second &&
JSON.stringify(art.frames[1].cels) === JSON.stringify(art.frames[0].cels) &&