Lists duplicate, rename and edit values right in their rows
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:
1 parent
ae0247d86f
commit
bbbf34109b
19 files changed
+747
-153
No files matched your search
+38
-17
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
Reference in new issue
Block a user