Lists add and delete items right in the list header and rows

The layer, frame and shape lists still had their add and delete
buttons scattered below the list, each in its own layout. They now
live in the shared SortableList, so every list works the same way.

- SortableList gets an optional header with the title on the left
  and a + on the right, plus an optional x per row next to the eye.
- The keepOne option hides the x on the last remaining row, so a
  pixel art image and a tilemap always keep one layer.
- Pixel art layers and frames and tilemap layers use header, + and
  x. Only Merge down and Duplicate stay below the lists.
- Vector art shapes get the x but no +. Add shape stays below the
  list and Delete shapes stays for a multi-selection.
- The section headings in the pixel art column use the same style
  as the list titles and the inspector.

Findings:

- The wait loop added to check 47i for #196 only waited for the
  right half of the shape. If the texture landed between the two
  reads, the left half was still blue and the check failed. It now
  waits for both halves.

Tests:

- ./check.sh: 138 files, 2052 tests, exit 0.
- GM_STEPS=39,40,47,48 (fresh): ALL PASSED, 156 checks (was 154).
- GM_STEPS=47,48 (fresh, final): ALL PASSED, 86 checks.
- Two new e2e checks: x on a shape (47) and x on a frame (48). The
  tilemap check in 40 now uses the x instead of Delete layer.
- Counter-check: with the x not reporting to its owner, exactly the
  two new checks fail.
- Check total 1266 in README and wiki is computed (1264 + 2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 21:18:26 +02:00
1 parent 5b9b57fd19
commit ae0247d86f
11 files changed
+264 -100

No files matched your search

+150 -28
View File
@@ -1,10 +1,11 @@
/**
* A list whose rows are dragged into another order – the layer list of every editor that has one:
* the shapes of vector art, the layers and frames of pixel art, the layers of a tilemap. Each row may
* carry a small picture the owner paints and an eye that hides the item without removing it.
* carry a small picture the owner paints, an eye that hides the item without removing it and a cross
* that removes it. Over the rows a header names the list and holds the plus that adds an item.
*
* The list never changes the order itself. A drop reports the move in the owner's indices, the owner
* runs its command, and the next `setItems` shows the result.
* The list never changes its items itself. A drop, a cross or the plus reports to the owner, the
* owner runs its command, and the next `setItems` shows the result.
*/
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import { dropGap, dropMove, rowOrder } from '../sortable';
@@ -18,6 +19,10 @@ const THUMB = 22;
const PAD = 4;
/** Width at the right end of a row that belongs to the eye. */
const EYE_SLOT = 26;
/** Width at the very right end of a row that belongs to the cross, and of the header to the plus. */
const REMOVE_SLOT = 24;
/** Height of the header, where there is one. */
const HEADER_HEIGHT = 24;
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
const DRAG_THRESHOLD = 4;
@@ -52,6 +57,14 @@ export interface SortableListOptions {
onMove: (from: number, to: number) => void;
/** Setting this gives every row an eye; a press on it reports the item and nothing else. */
onToggleHidden?: (id: string) => void;
/** Setting this gives every row a cross at its right end; a press on it reports the item. */
onRemove?: (id: string) => void;
/** No cross while a single item is left – a map or a picture needs one layer to paint on. */
keepOne?: boolean;
/** Stands in the header, left. */
title?: string;
/** Setting this puts a plus at the right end of the header. */
onAdd?: () => void;
multiSelect?: boolean;
}
@@ -62,6 +75,7 @@ interface Row {
label: Text;
hint: Text;
eye: Graphics;
cross: Graphics;
}
interface RowDrag {
@@ -69,37 +83,47 @@ interface RowDrag {
from: number;
/** The pressed row is to be picked once the pointer is up. */
pick: boolean;
/** Both in the rows' own coordinates, below the header. */
startY: number;
y: number;
active: boolean;
}
/** What the pointer is over: a row, a row's cross, or the plus. */
type Hover = { row: number; cross: boolean } | 'add' | null;
export class SortableList extends Widget {
private readonly background = new Graphics();
private readonly marker = new Graphics();
private readonly heading = new Text({ text: '', style: textStyle('title') });
private readonly plus = new Graphics();
private readonly rows: Row[] = [];
/** Display order, top first. */
private shown: SortableItem[] = [];
private selected: readonly string[] = [];
private hoveredRow = -1;
private over: Hover = null;
private drag: RowDrag | null = null;
constructor(private readonly options: SortableListOptions) {
super();
this.cursor = Theme.hoverCursor;
this.addChild(this.background);
this.heading.text = (options.title ?? '').toUpperCase();
this.heading.eventMode = 'none';
this.plus.eventMode = 'none';
this.addChild(this.heading, this.plus);
this.marker.eventMode = 'none';
this.addChild(this.marker);
this.on('pointermove', (e: FederatedPointerEvent) => {
const index = this.rowAt(this.toLocal(e.global).y);
if (index === this.hoveredRow) return;
this.hoveredRow = index;
const hovered = this.hitAt(this.toLocal(e.global));
if (sameHover(hovered, this.over)) return;
this.over = hovered;
this.invalidate();
});
this.on('pointerout', () => {
if (this.hoveredRow < 0) return;
this.hoveredRow = -1;
if (!this.over) return;
this.over = null;
this.invalidate();
});
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
@@ -120,7 +144,8 @@ export class SortableList extends Widget {
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(SORTABLE_ROW_HEIGHT, this.shown.length * SORTABLE_ROW_HEIGHT) };
const rows = Math.max(SORTABLE_ROW_HEIGHT, this.shown.length * SORTABLE_ROW_HEIGHT);
return { w: this.minWidth, h: this.headerHeight + rows };
}
/** The ids as the list shows them, top first – for the check scripts. */
@@ -133,7 +158,12 @@ export class SortableList extends Widget {
const index = this.shown.findIndex((item) => item.id === id);
if (index < 0) return null;
const origin = this.getGlobalPosition();
return { x: origin.x, y: origin.y + index * SORTABLE_ROW_HEIGHT, w: this.w, h: SORTABLE_ROW_HEIGHT };
return {
x: origin.x,
y: origin.y + this.headerHeight + index * SORTABLE_ROW_HEIGHT,
w: this.w,
h: SORTABLE_ROW_HEIGHT,
};
}
/** The centre of an item's picture (test hook); null without pictures or without that row. */
@@ -147,7 +177,35 @@ export class SortableList extends Widget {
eyeGlobal(id: string): { x: number; y: number } | null {
const row = this.rowGlobal(id);
if (!row || !this.options.onToggleHidden) return null;
return { x: row.x + row.w - EYE_SLOT / 2, y: row.y + row.h / 2 };
return { x: row.x + row.w - this.crossWidth - EYE_SLOT / 2, y: row.y + row.h / 2 };
}
/** The centre of an item's cross (test hook); null where the row has none. */
removeGlobal(id: string): { x: number; y: number } | null {
const row = this.rowGlobal(id);
if (!row || !this.canRemove) return null;
return { x: row.x + row.w - REMOVE_SLOT / 2, y: row.y + row.h / 2 };
}
/** The centre of the plus (test hook); null without one. */
addGlobal(): { x: number; y: number } | null {
if (!this.options.onAdd) return null;
const origin = this.getGlobalPosition();
return { x: origin.x + this.w - REMOVE_SLOT / 2, y: origin.y + HEADER_HEIGHT / 2 };
}
private get headerHeight(): number {
return this.options.title || this.options.onAdd ? HEADER_HEIGHT : 0;
}
/** Whether the rows show a cross now – with `keepOne`, not on the last item. */
private get canRemove(): boolean {
return Boolean(this.options.onRemove) && !(this.options.keepOne && this.shown.length <= 1);
}
/** Width the cross takes at the right end of every row, or none. */
private get crossWidth(): number {
return this.options.onRemove ? REMOVE_SLOT : 0;
}
private buildRow(): Row {
@@ -157,26 +215,44 @@ export class SortableList extends Widget {
const label = new Text({ text: '', style: textStyle('body') });
const hint = new Text({ text: '', style: textStyle('small') });
const eye = new Graphics();
const cross = new Graphics();
box.eventMode = 'none';
box.addChild(art, clip, label, hint, eye);
box.addChild(art, clip, label, hint, eye, cross);
art.mask = clip;
this.addChildAt(box, this.getChildIndex(this.marker));
return { box, art, clip, label, hint, eye };
return { box, art, clip, label, hint, eye, cross };
}
private rowAt(y: number): number {
const index = Math.floor(y / SORTABLE_ROW_HEIGHT);
const index = Math.floor((y - this.headerHeight) / SORTABLE_ROW_HEIGHT);
return index >= 0 && index < this.shown.length ? index : -1;
}
private hitAt(local: { x: number; y: number }): Hover {
if (local.y < this.headerHeight)
return this.options.onAdd && local.x >= this.w - REMOVE_SLOT ? 'add' : null;
const row = this.rowAt(local.y);
if (row < 0) return null;
return { row, cross: this.canRemove && local.x >= this.w - REMOVE_SLOT };
}
private onPress(e: FederatedPointerEvent): void {
if (e.button !== 0) return;
const local = this.toLocal(e.global);
const index = this.rowAt(local.y);
if (index < 0) return;
const hit = this.hitAt(local);
if (!hit) return;
e.stopPropagation();
const id = this.shown[index]!.id;
if (this.options.onToggleHidden && local.x >= this.w - EYE_SLOT) {
if (hit === 'add') {
this.options.onAdd?.();
return;
}
const id = this.shown[hit.row]!.id;
if (hit.cross) {
this.options.onRemove?.(id);
return;
}
const eyeRight = this.w - this.crossWidth;
if (this.options.onToggleHidden && local.x >= eyeRight - EYE_SLOT && local.x < eyeRight) {
this.options.onToggleHidden(id);
return;
}
@@ -186,13 +262,14 @@ export class SortableList extends Widget {
}
// Picked on release: a pick rebuilds what the list stands in, which would end the drag.
const pick = this.selected.length !== 1 || this.selected[0] !== id;
this.drag = { from: index, pick, startY: local.y, y: local.y, active: false };
const y = local.y - this.headerHeight;
this.drag = { from: hit.row, pick, startY: y, y, active: false };
}
private onMove(e: FederatedPointerEvent): void {
const drag = this.drag;
if (!drag) return;
drag.y = this.toLocal(e.global).y;
drag.y = this.toLocal(e.global).y - this.headerHeight;
if (!drag.active && Math.abs(drag.y - drag.startY) < DRAG_THRESHOLD) return;
drag.active = true;
this.invalidate();
@@ -213,17 +290,25 @@ export class SortableList extends Widget {
}
override redraw(): void {
this.background.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.control);
const top = this.headerHeight;
this.background
.clear()
.rect(0, top, this.w, this.h - top)
.fill(Theme.colors.control);
this.marker.clear();
this.redrawHeader();
const eyes = Boolean(this.options.onToggleHidden);
const crosses = this.canRemove;
const crossW = this.crossWidth;
const thumbs = this.options.thumb;
const textX = thumbs ? PAD * 2 + THUMB : PAD * 2;
const hovered = this.over !== 'add' ? this.over : null;
for (const [index, item] of this.shown.entries()) {
const row = this.rows[index]!;
const y = index * SORTABLE_ROW_HEIGHT;
const y = top + index * SORTABLE_ROW_HEIGHT;
row.box.position.set(0, y);
const selected = this.selected.includes(item.id);
if (selected || index === this.hoveredRow)
if (selected || hovered?.row === index)
this.background
.rect(0, y, this.w, SORTABLE_ROW_HEIGHT)
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
@@ -243,32 +328,60 @@ export class SortableList extends Widget {
row.hint.text = item.hint ?? '';
const hintW = row.hint.text ? row.hint.width + PAD * 2 : 0;
row.hint.position.set(
Math.round(this.w - eyeW - PAD - row.hint.width),
Math.round(this.w - crossW - eyeW - PAD - row.hint.width),
(SORTABLE_ROW_HEIGHT - row.hint.height) / 2,
);
row.label.style = textStyle(selected && !item.hidden ? 'body' : 'muted');
row.label.text = truncate(
item.label,
row.label.style,
Math.max(0, this.w - textX - hintW - eyeW - PAD),
Math.max(0, this.w - textX - hintW - eyeW - crossW - PAD),
);
row.label.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2);
row.eye.clear();
if (eyes) drawEye(row.eye, this.w - EYE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, !item.hidden);
if (eyes) drawEye(row.eye, this.w - crossW - EYE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, !item.hidden);
row.cross.clear();
if (crosses) {
const hot = hovered?.row === index && hovered.cross;
drawCross(row.cross, this.w - REMOVE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, hot);
}
row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1;
}
if (this.drag?.active) {
const y = dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT;
const y = top + dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT;
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent);
}
}
private redrawHeader(): void {
const top = this.headerHeight;
this.heading.visible = top > 0;
this.heading.position.set(0, Math.round((top - this.heading.height) / 2));
this.plus.clear();
if (!this.options.onAdd) return;
const x = this.w - REMOVE_SLOT / 2;
const y = top / 2;
if (this.over === 'add')
this.plus.roundRect(this.w - REMOVE_SLOT, 1, REMOVE_SLOT, top - 2, 3).fill(Theme.colors.controlHover);
this.plus
.moveTo(x - 5, y)
.lineTo(x + 5, y)
.moveTo(x, y - 5)
.lineTo(x, y + 5)
.stroke({ width: 1.5, color: this.over === 'add' ? Theme.colors.text : Theme.colors.textMuted });
}
override layout(): void {
this.redraw();
}
}
function sameHover(a: Hover, b: Hover): boolean {
if (a === null || b === null || a === 'add' || b === 'add') return a === b;
return a.row === b.row && a.cross === b.cross;
}
/** An open eye, or a closed one struck through – drawn rather than a glyph, so it stays crisp. */
function drawEye(g: Graphics, x: number, y: number, open: boolean): void {
const color = open ? Theme.colors.text : Theme.colors.textMuted;
@@ -279,3 +392,12 @@ function drawEye(g: Graphics, x: number, y: number, open: boolean): void {
.lineTo(x + 6, y - 5)
.stroke({ width: 1.2, color });
}
/** The cross that removes a row: muted, and in the danger colour under the pointer. */
function drawCross(g: Graphics, x: number, y: number, hot: boolean): void {
g.moveTo(x - 4, y - 4)
.lineTo(x + 4, y + 4)
.moveTo(x + 4, y - 4)
.lineTo(x - 4, y + 4)
.stroke({ width: 1.4, color: hot ? Theme.colors.danger : Theme.colors.textMuted });
}