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

+20
View File
@@ -49,6 +49,7 @@ import {
withFrameRemoved,
withLayerAdded,
withLayerChanged,
withLayerDuplicated,
withLayerMergedDown,
withLayerMoved,
withLayerRemoved,
@@ -361,6 +362,25 @@ describe('edits', () => {
expect(() => withLayerMergedDown(s, layer, {})).toThrow('no layer below');
});
it('duplicates a layer right above it, naming the same cels on every frame', () => {
const { asset, layer, frame } = start();
let s = withLayerAdded(pixelArtState(asset), 'top');
s = withLayerChanged(s, layer, { opacity: 0.5, hidden: true });
s = withCels(s, [{ frameId: frame, layerId: layer, file: FILE_A }]);
s = withLayerDuplicated(s, 'copy', layer);
s = withLayerDuplicated(s, 'again', layer);
expect(s.layers.map((l) => [l.id, l.name])).toEqual([
[layer, 'Layer 1'],
['again', 'Layer 1 copy 2'],
['copy', 'Layer 1 copy'],
['top', 'Layer 2'],
]);
expect(s.layers[2]).toMatchObject({ opacity: 0.5, hidden: true });
expect(s.frames[0]!.cels).toMatchObject({ [layer]: FILE_A, copy: FILE_A, again: FILE_A });
expect(s.frames[0]!.cels).not.toHaveProperty('top');
expect(() => withLayerDuplicated(s, 'x', 'nobody')).toThrow('not found');
});
it('adds a frame empty or as a copy, moves it and keeps the last one', () => {
const { asset, layer, frame } = start();
let s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]);
+21
View File
@@ -7,11 +7,13 @@ import {
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
import {
AddTilemapLayerCommand,
DuplicateTilemapLayerCommand,
MoveTilemapLayerCommand,
PaintTilesCommand,
RemoveTilemapLayerCommand,
RenameTilemapLayerCommand,
SetTilemapLayerHiddenCommand,
SetTilemapLayerZCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TilemapAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
@@ -319,6 +321,25 @@ describe('the layers of a tilemap', () => {
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']);
});
it('duplicates a layer right above it, with its cells, its z and a name of its own', () => {
const f = makeTilemap();
f.paint([{ x: 1, y: 1 }]);
f.stack.execute(new SetTilemapLayerZCommand(f.tilemapId, f.layerId(), 3));
f.stack.execute(new AddTilemapLayerCommand(f.tilemapId));
const copy = new DuplicateTilemapLayerCommand(f.tilemapId, f.layerId());
f.stack.execute(copy);
const layers = f.assetOf().layers;
expect(layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 1 copy', 'Layer 2']);
expect(layers[1]).toMatchObject({ id: copy.layerId, z: 3, cells: layers[0]!.cells });
// Cells of its own: painting on the copy leaves the original alone.
f.stack.execute(new PaintTilesCommand(f.tilemapId, copy.layerId, [{ x: 1, y: 1, tileId: '' }]));
expect(f.assetOf().layers[0]!.cells[cellKey(1, 1)]).toBeTruthy();
f.stack.undo();
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
});
it('renames a layer without the spaces around it, refuses a blank name and undoes to the old one', () => {
const f = makeTilemap();
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' Ground '));
+24
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import {
AddVectorShapeCommand,
DuplicateVectorShapeCommand,
MoveVectorShapeCommand,
RemoveVectorShapeCommand,
RenameVectorShapeCommand,
@@ -231,6 +232,29 @@ describe('editing the shapes', () => {
expect(f.art().shapes).toEqual([]);
});
it('duplicates a shape right above it, named as a copy, with outlines of its own', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
const top = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
f.stack.execute(top);
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { opacity: 0.5 }));
const copy = new DuplicateVectorShapeCommand(f.assetId, add.shapeId);
f.stack.execute(copy);
f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, add.shapeId));
const shapes = f.art().shapes;
expect(shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 1 copy 2', 'Shape 1 copy', 'Shape 2']);
expect(shapes[2]).toMatchObject({ id: copy.shapeId, fill: 0x112233, opacity: 0.5 });
expect(shapes[2]!.rings).toEqual(shapes[0]!.rings);
// Its own outline: a point dragged on the copy leaves the original where it is.
expect(shapes[2]!.rings[0]).not.toBe(shapes[0]!.rings[0]);
f.stack.undo();
f.stack.undo();
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
expect(() => f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, 'nobody'))).toThrow(/not found/);
});
it('keeps the drawing order apart from the list order in no way at all', () => {
const f = drawing();
const ids = ['a', 'b', 'c'].map(() => {