Layer, frame and shape lists all reorder by drag and drop

Reordering shapes by dragging worked well in the vector tab. Pixel
art layers and frames and tilemap layers still used up/down buttons.
All three lists now share one widget built from the shape list.

- SortableList in the UI toolkit, with the drag rules next to it in
  src/ui/sortable.ts. A row has an optional thumbnail, a name, a hint
  on the right and an eye toggle. Every drop is one undo step.
- The shape list is a thin wrapper around it. shapeRows.ts is gone,
  its tests moved to the new rules.
- Pixel art layers show the layer alone on the current frame, frames
  show the whole frame. Up/Down, Hide, Earlier and Later are gone.
- Tilemap layers get the eye instead of the checkbox. The per-row
  up, down and delete buttons are replaced by Delete layer next to
  Add layer.
- A row is selected on release instead of press. Selecting rebuilt
  the inspector or tilemap panel and cancelled a drag that started
  on a row not yet selected.

Tests:
- ./check.sh: 138 files, 2052 tests, exit 0 (before 137 / 2051).
- GM_STEPS=39,40,47,48 (fresh seed): ALL PASSED, 154 checks.
- 5 new e2e checks. Check total 1264 in the README is computed
  (1259 + 5), CI never measured 1259.
- Counter-checks bite: dropMove ignoring reversed fails a unit test,
  a tilemap drop not applied fails the drag check, a layer thumbnail
  showing all layers fails the thumbnail check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 18:48:50 +02:00
1 parent 8adef83f23
commit 5b9b57fd19
14 files changed
+699 -326

No files matched your search

+58 -39
View File
@@ -6,6 +6,7 @@
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { oneStep } from '../../core/commands/CompoundCommand';
import { nextAssetName } from '../../core/commands/naming';
import type { Graphics } from 'pixi.js';
import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset';
import { colorAt, frameLabel, PALETTE_KEY, paletteAssetOf, paletteColors } from '../../core/model/pixelart';
import { BRUSH_MAX, BRUSH_MIN } from '../../core/model/pixelDraw';
@@ -16,16 +17,17 @@ import {
Dropdown,
HStack,
Label,
ListView,
Menu,
NumberField,
ScrollContainer,
SortableList,
Spacer,
TextField,
Theme,
VStack,
type Widget,
} from '../../ui';
import { pixelCels } from '../../viewport/PixelCels';
import type { EditorState } from '../state';
import { PaletteGrid } from './PaletteGrid';
import { type PixelArtCanvas, PIXEL_TOOL_LABELS, PIXEL_TOOLS, type PixelTool } from './PixelArtCanvas';
@@ -45,10 +47,10 @@ import {
setPaletteColor,
} from './pixelActions';
import { PixelPreview } from './PixelPreview';
import { PixelThumbs } from './PixelThumbs';
/** Four tools to a row, so the column stays narrow enough for the canvas beside it. */
const TOOLS_PER_ROW = 4;
const LIST_ROWS = 4;
function brushLabel(size: number): string {
return `Brush: ${size} px`;
@@ -67,12 +69,14 @@ export class PixelArtSide extends ScrollContainer {
readonly removeColorButton = new Button('Remove last');
readonly createPaletteButton = new Button('Create palette');
private readonly paletteNote = new Label('', { style: 'muted' });
readonly layers = new ListView();
readonly layers: SortableList;
readonly layerName = new TextField('');
readonly layerOpacity = new NumberField(1, { min: 0, max: 1, step: 0.05, decimals: 2 });
readonly layerButtons: Record<'add' | 'remove' | 'up' | 'down' | 'merge' | 'hide', Button>;
readonly frames = new ListView();
readonly frameButtons: Record<'add' | 'duplicate' | 'remove' | 'earlier' | 'later', Button>;
readonly layerButtons: Record<'add' | 'remove' | 'merge', Button>;
readonly frames: SortableList;
readonly frameButtons: Record<'add' | 'duplicate' | 'remove', Button>;
private readonly thumbs = new PixelThumbs();
private readonly stopCels: () => void;
readonly onionBox = new Checkbox(false);
readonly tilesBox = new Checkbox(false);
readonly preview: PixelPreview;
@@ -125,8 +129,14 @@ export class PixelArtSide extends ScrollContainer {
c.add(row(this.paletteNote, new Spacer(), this.createPaletteButton));
c.add(heading('Layers'));
this.layers.minHeight = LIST_ROWS * this.layers.rowHeight;
this.layers.onSelect = (id) => id && this.canvas.selectLayer(id);
this.layers = new SortableList({
reversed: true,
onPick: (id) => this.canvas.selectLayer(id),
onMove: (from, to) =>
this.withAsset((a) => a.layers[from] && moveLayer(state, a, a.layers[from].id, to)),
onToggleHidden: (id) => this.toggleHidden(id),
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));
@@ -139,24 +149,20 @@ export class PixelArtSide extends ScrollContainer {
remove: new Button('Delete', {
onClick: () => this.withAsset((a) => removeLayer(state, a, this.canvas.layerId)),
}),
up: new Button('Up', {
onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, 1)),
}),
down: new Button('Down', {
onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, -1)),
}),
merge: new Button('Merge down', {
onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)),
}),
hide: new Button('Hide', { minWidth: 56, onClick: () => this.toggleHidden() }),
};
const lb = this.layerButtons;
c.add(row(lb.add, lb.remove, lb.up, lb.down));
c.add(row(lb.merge, lb.hide));
c.add(row(lb.add, lb.remove, lb.merge));
c.add(heading('Frames'));
this.frames.minHeight = LIST_ROWS * this.frames.rowHeight;
this.frames.onSelect = (id) => id && this.canvas.selectFrame(id);
this.frames = new SortableList({
onPick: (id) => this.canvas.selectFrame(id),
onMove: (from, to) =>
this.withAsset((a) => a.frames[from] && moveFrame(state, a, a.frames[from].id, to)),
thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, undefined, id)),
});
c.add(this.frames);
this.frameButtons = {
add: new Button('Add', { onClick: () => this.addFrame(false) }),
@@ -164,16 +170,9 @@ export class PixelArtSide extends ScrollContainer {
remove: new Button('Delete', {
onClick: () => this.withAsset((a) => removeFrame(state, a, this.canvas.frameId)),
}),
earlier: new Button('Earlier', {
onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, -1)),
}),
later: new Button('Later', {
onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, 1)),
}),
};
const fb = this.frameButtons;
c.add(row(fb.add, fb.duplicate, fb.remove));
c.add(row(fb.earlier, fb.later));
c.add(heading('View'));
this.onionBox.onChange = (on) => {
@@ -197,6 +196,17 @@ export class PixelArtSide extends ScrollContainer {
this.setContent(this.column);
this.setTool('pencil');
state.on('project', () => this.refresh());
// A cel that lands after the list was drawn shows in its picture.
this.stopCels = pixelCels.onChange(() => {
this.layers.invalidate();
this.frames.invalidate();
});
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.stopCels();
this.thumbs.destroy();
super.destroy(options);
}
/** Scrolls the column until `widget` is in view – the check scripts reveal a control before they click it. */
@@ -290,12 +300,24 @@ export class PixelArtSide extends ScrollContainer {
if (id) this.canvas.selectLayer(id);
}
private toggleHidden(): void {
private toggleHidden(layerId: string): void {
const asset = this.canvas.asset();
const layer = asset?.layers.find((l) => l.id === this.canvas.layerId);
const layer = asset?.layers.find((l) => l.id === layerId);
if (asset && layer) setLayerHidden(this.state, asset, layer.id, !layer.hidden);
}
/** A layer alone on the frame being painted, or with `frameId` that whole frame. */
private paintThumb(
asset: PixelArtAsset,
art: Graphics,
size: number,
layerId?: string,
frameId = this.canvas.frameId,
): void {
const frame = asset.frames.find((f) => f.id === frameId);
if (frame) this.thumbs.paint(art, size, asset, paletteColors(this.state.project, asset), frame, layerId);
}
private addFrame(copy: boolean): void {
const asset = this.canvas.asset();
if (!asset) return;
@@ -323,13 +345,14 @@ export class PixelArtSide extends ScrollContainer {
const layerId = this.canvas.layerId;
this.layers.setItems(
[...(asset?.layers ?? [])].reverse().map((layer) => ({
(asset?.layers ?? []).map((layer) => ({
id: layer.id,
label: layer.hidden ? `${layer.name} (hidden)` : layer.name,
label: layer.name,
hint: layer.opacity < 1 ? `${Math.round(layer.opacity * 100)}%` : undefined,
hidden: layer.hidden,
})),
layerId ? [layerId] : [],
);
this.layers.select(layerId || null, true);
const layer = asset?.layers.find((l) => l.id === layerId);
this.layerName.setValue(layer?.name ?? '');
this.layerOpacity.setNumber(layer?.opacity ?? 1);
@@ -337,22 +360,18 @@ export class PixelArtSide extends ScrollContainer {
const lb = this.layerButtons;
lb.add.setEnabled(asset !== null);
lb.remove.setEnabled((asset?.layers.length ?? 0) > 1);
lb.up.setEnabled(asset !== null && index >= 0 && index < asset.layers.length - 1);
lb.down.setEnabled(index > 0);
lb.merge.setEnabled(index > 0);
lb.hide.setText(layer?.hidden ? 'Show' : 'Hide');
lb.hide.setEnabled(layer !== undefined);
const frameId = this.canvas.frameId;
this.frames.setItems((asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })));
this.frames.select(frameId || null, true);
this.frames.setItems(
(asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })),
frameId ? [frameId] : [],
);
const frameIndex = asset ? asset.frames.findIndex((f) => f.id === frameId) : -1;
const fb = this.frameButtons;
fb.add.setEnabled(asset !== null);
fb.duplicate.setEnabled(frameIndex >= 0);
fb.remove.setEnabled((asset?.frames.length ?? 0) > 1);
fb.earlier.setEnabled(frameIndex > 0);
fb.later.setEnabled(asset !== null && frameIndex >= 0 && frameIndex < asset.frames.length - 1);
this.relayoutContent();
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* The little pictures in the layer and frame lists of the pixel art tab: a layer alone on the frame
* being painted, or a whole frame. Each row keeps one canvas that is painted over when what it shows
* changes – the key says when – so a stroke recomposes only the rows it touched.
*/
import { CanvasSource, type Graphics, Texture } from 'pixi.js';
import type { PixelArtAsset, PixelFrame } from '../../core/model/Asset';
import { composeFrame } from '../../core/model/pixelart';
import { pixelCels } from '../../viewport/PixelCels';
import { drawChecker } from '../texture/checker';
interface Slot {
key: string;
canvas: HTMLCanvasElement;
texture: Texture;
}
export class PixelThumbs {
private readonly slots = new Map<string, Slot>();
/** Textures of canvases replaced by a bigger or smaller one: a list may still draw with them. */
private readonly retired: Texture[] = [];
/** Paints `frame` into `art`, fitted into `size`; `layerId` narrows it to that one layer. */
paint(
art: Graphics,
size: number,
asset: PixelArtAsset,
colors: readonly number[],
frame: PixelFrame,
layerId?: string,
): void {
const scale = Math.min(size / asset.width, size / asset.height);
const w = asset.width * scale;
const h = asset.height * scale;
const x = (size - w) / 2;
const y = (size - h) / 2;
drawChecker(art, x, y, w, h, size, size);
const texture = this.texture(asset, colors, frame, layerId);
if (texture) art.rect(x, y, w, h).fill({ texture });
}
destroy(): void {
for (const slot of this.slots.values()) slot.texture.destroy(true);
for (const texture of this.retired) texture.destroy(true);
this.slots.clear();
this.retired.length = 0;
}
private texture(
asset: PixelArtAsset,
colors: readonly number[],
frame: PixelFrame,
layerId: string | undefined,
): Texture | null {
// A layer is shown whole even while hidden or faded: the row says that with its eye.
const layers = layerId
? asset.layers.filter((l) => l.id === layerId).map((l) => ({ ...l, hidden: false, opacity: 1 }))
: asset.layers;
const cels = layers.map((l) => `${frame.cels[l.id] ?? ''}:${l.hidden ? 'h' : l.opacity}`).join(',');
const key = `${asset.width}x${asset.height}|${colors.join(',')}|${cels}`;
const id = `${asset.id}/${frame.id}/${layerId ?? ''}`;
let slot = this.slots.get(id);
if (slot?.key === key) return slot.texture;
const rgba = composeFrame({ ...asset, layers }, colors, frame, (file) => pixelCels.read(file));
if (!rgba) return slot?.texture ?? null;
if (!slot || slot.canvas.width !== asset.width || slot.canvas.height !== asset.height) {
if (slot) this.retired.push(slot.texture);
const canvas = document.createElement('canvas');
canvas.width = asset.width;
canvas.height = asset.height;
slot = {
key,
canvas,
texture: new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'nearest' }) }),
};
this.slots.set(id, slot);
}
slot.key = key;
slot.canvas.getContext('2d')?.putImageData(new ImageData(rgba, asset.width, asset.height), 0, 0);
slot.texture.source.update();
return slot.texture;
}
}
+5 -11
View File
@@ -85,12 +85,9 @@ export function setLayerOpacity(
return edit(state, asset, 'Layer opacity', (s) => withLayerChanged(s, layerId, { opacity }));
}
/** Up is towards the top of the drawing order, the end of the list. */
export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: string, step: 1 | -1): boolean {
const index = asset.layers.findIndex((layer) => layer.id === layerId);
return edit(state, asset, step > 0 ? 'Move layer up' : 'Move layer down', (s) =>
withLayerMoved(s, layerId, index + step),
);
/** `to` is the layer's new index in the drawing order, whose end is the top. */
export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: string, to: number): boolean {
return edit(state, asset, 'Reorder layers', (s) => withLayerMoved(s, layerId, to));
}
/** The layer laid onto the one below it on every frame; its opacity is not kept (see `mergeCels`). */
@@ -129,11 +126,8 @@ export function removeFrame(state: EditorState, asset: PixelArtAsset, frameId: s
return edit(state, asset, 'Delete frame', (s) => withFrameRemoved(s, frameId));
}
export function moveFrame(state: EditorState, asset: PixelArtAsset, frameId: string, step: 1 | -1): boolean {
const index = asset.frames.findIndex((frame) => frame.id === frameId);
return edit(state, asset, step > 0 ? 'Move frame later' : 'Move frame earlier', (s) =>
withFrameMoved(s, frameId, index + step),
);
export function moveFrame(state: EditorState, asset: PixelArtAsset, frameId: string, to: number): boolean {
return edit(state, asset, 'Reorder frames', (s) => withFrameMoved(s, frameId, to));
}
/** Every cel moved onto a canvas of the new size (answer 3), in one undo step. */