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

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