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

+1
View File
@@ -36,6 +36,7 @@ export {
SortableList,
type SortableItem,
type SortableListOptions,
type SortableValue,
type ThumbClip,
} from './widgets/SortableList';
export { dropGap, dropMove, rowOrder } from './sortable';
+267 -37
View File
@@ -1,26 +1,41 @@
/**
* 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, 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.
* carry a small picture the owner paints, a number typed or scrubbed in place, an eye that hides the
* item without removing it, a mark that duplicates it and a cross that removes it. A double-click on
* the name edits it in the row. Over the rows a header names the list and holds the plus that adds
* an item.
*
* 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.
* The duplicate mark and the cross only show on the row under the pointer and on the marked ones, so
* a long list stays quiet; their places are kept free on every row, so nothing moves when they show.
*
* The list never changes its items itself. A drop, a cross, a name or a number 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 { pressCount } from '../clicks';
import { dropGap, dropMove, rowOrder } from '../sortable';
import { Theme, textStyle } from '../Theme';
import { ui } from '../UIContext';
import { type Size, Widget } from '../Widget';
import { truncate } from './Label';
import { NumberField } from './NumberField';
import { TextField } from './TextField';
/** Height of one row: the picture plus the air a hovered row needs to look like a row. */
export const SORTABLE_ROW_HEIGHT = 30;
const THUMB = 22;
const PAD = 4;
/** Height of the fields that stand in a row: the name being edited and the number. */
const FIELD_HEIGHT = 22;
/** Width at the right end of a row that belongs to the eye. */
const EYE_SLOT = 26;
/** Width left of the cross that belongs to the duplicate mark. */
const DUPLICATE_SLOT = 22;
/** 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;
/** Width of the number field, where the rows have one. */
const VALUE_WIDTH = 44;
/** 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. */
@@ -43,6 +58,22 @@ export interface ThumbClip {
h: number;
}
/** A number each row shows in a field of its own – a layer's opacity, its z. */
export interface SortableValue {
/** Null leaves the row without a field. */
get: (id: string) => number | null;
/** Once, when a scrub ends or a typed number is committed. */
set: (id: string, value: number) => void;
/** While the field is scrubbed, for an owner that shows the number before it commits it. */
preview?: (id: string, value: number) => void;
min?: number;
max?: number;
step?: number;
decimals?: number;
/** Small text right of the field, such as "%". */
suffix?: string;
}
export interface SortableListOptions {
/** The model's last item is the topmost row, as in every layer list: it is drawn last. */
reversed?: boolean;
@@ -57,10 +88,15 @@ 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 duplicate mark left of the cross; a press on it reports the item. */
onDuplicate?: (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;
/** Setting this makes a double-click on a name edit it in the row; a blank or unchanged name is not reported. */
onRename?: (id: string, name: string) => void;
value?: SortableValue;
/** Stands in the header, left. */
title?: string;
/** Setting this puts a plus at the right end of the header. */
@@ -75,7 +111,10 @@ interface Row {
label: Text;
hint: Text;
eye: Graphics;
duplicate: Graphics;
cross: Graphics;
/** Only with `value`; a child of the list, not of `box`, so it takes the pointer. */
field: NumberField | null;
}
interface RowDrag {
@@ -89,8 +128,21 @@ interface RowDrag {
active: boolean;
}
/** What the pointer is over: a row, a row's cross, or the plus. */
type Hover = { row: number; cross: boolean } | 'add' | null;
/** Which part of a row a point is on. */
type RowPart = 'row' | 'eye' | 'duplicate' | 'cross' | 'name';
/** What the pointer is over: a part of a row, or the plus. */
type Hover = { row: number; part: RowPart } | 'add' | null;
/** A name edited in its row: it goes away as soon as it loses the focus. */
class RowNameField extends TextField {
onDone: () => void = () => {};
override onBlur(): void {
super.onBlur();
this.onDone();
}
}
export class SortableList extends Widget {
private readonly background = new Graphics();
@@ -103,6 +155,8 @@ export class SortableList extends Widget {
private selected: readonly string[] = [];
private over: Hover = null;
private drag: RowDrag | null = null;
/** The name being edited in its row, and whose it is. */
private renaming: { id: string; field: RowNameField } | null = null;
constructor(private readonly options: SortableListOptions) {
super();
@@ -138,8 +192,13 @@ export class SortableList extends Widget {
this.shown = rowOrder(items, this.options.reversed ?? false);
this.selected = selectedIds;
while (this.rows.length < this.shown.length) this.rows.push(this.buildRow());
while (this.rows.length > this.shown.length) this.rows.pop()!.box.destroy({ children: true });
while (this.rows.length > this.shown.length) {
const row = this.rows.pop()!;
row.box.destroy({ children: true });
row.field?.destroy();
}
if (countChanged) this.drag = null;
if (this.renaming && !this.shown.some((item) => item.id === this.renaming?.id)) ui().focus.blur();
this.invalidate();
}
@@ -173,11 +232,25 @@ export class SortableList extends Widget {
return { x: Math.round(row.x + PAD + THUMB / 2), y: Math.round(row.y + row.h / 2) };
}
/** A point on an item's name, where a double-click edits it (test hook); null without that row. */
nameGlobal(id: string): { x: number; y: number } | null {
const row = this.rowGlobal(id);
if (!row) return null;
return { x: Math.round(row.x + this.textX + 12), y: Math.round(row.y + row.h / 2) };
}
/** The centre of an item's eye (test hook); null without eyes or without that row. */
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 - this.crossWidth - EYE_SLOT / 2, y: row.y + row.h / 2 };
return { x: row.x + this.slots.eye - EYE_SLOT / 2, y: row.y + row.h / 2 };
}
/** The centre of an item's duplicate mark (test hook); null without marks or without that row. */
duplicateGlobal(id: string): { x: number; y: number } | null {
const row = this.rowGlobal(id);
if (!row || !this.options.onDuplicate) return null;
return { x: row.x + this.slots.duplicate - DUPLICATE_SLOT / 2, y: row.y + row.h / 2 };
}
/** The centre of an item's cross (test hook); null where the row has none. */
@@ -187,6 +260,18 @@ export class SortableList extends Widget {
return { x: row.x + row.w - REMOVE_SLOT / 2, y: row.y + row.h / 2 };
}
/** The number field of an item's row (test hook); null where the row has none. */
valueField(id: string): NumberField | null {
const index = this.shown.findIndex((item) => item.id === id);
const field = this.rows[index]?.field;
return field?.visible ? field : null;
}
/** The field a name is being edited in (test hook); null while no name is. */
get renameField(): TextField | null {
return this.renaming?.field ?? null;
}
/** The centre of the plus (test hook); null without one. */
addGlobal(): { x: number; y: number } | null {
if (!this.options.onAdd) return null;
@@ -203,9 +288,30 @@ export class SortableList extends Widget {
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;
/** Where the name starts. */
private get textX(): number {
return this.options.thumb ? PAD * 2 + THUMB : PAD * 2;
}
/**
* The right edge of each part of a row, from the right: the cross, the duplicate mark, the eye and
* the number. A part a list does not have takes no room.
*/
private get slots(): { cross: number; duplicate: number; eye: number; value: number } {
const cross = this.w;
const duplicate = cross - (this.options.onRemove ? REMOVE_SLOT : 0);
const eye = duplicate - (this.options.onDuplicate ? DUPLICATE_SLOT : 0);
const value = eye - (this.options.onToggleHidden ? EYE_SLOT : 0);
return { cross, duplicate, eye, value };
}
/** Where the name has to end: before the number and its suffix, or before the eye. */
private get nameRight(): number {
const { value } = this.slots;
const options = this.options.value;
if (!options) return value;
const suffix = options.suffix ? suffixWidth(options.suffix) + PAD : 0;
return value - PAD - suffix - VALUE_WIDTH;
}
private buildRow(): Row {
@@ -215,12 +321,35 @@ 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 duplicate = new Graphics();
const cross = new Graphics();
box.eventMode = 'none';
box.addChild(art, clip, label, hint, eye, cross);
box.addChild(art, clip, label, hint, eye, duplicate, cross);
art.mask = clip;
this.addChildAt(box, this.getChildIndex(this.marker));
return { box, art, clip, label, hint, eye, cross };
const row: Row = { box, art, clip, label, hint, eye, duplicate, cross, field: null };
const value = this.options.value;
if (value) {
const field = new NumberField(0, {
min: value.min,
max: value.max,
step: value.step,
decimals: value.decimals,
});
// The row the field stands in now: a drop or a new item can give it another one.
const idOf = () => this.shown[this.rows.indexOf(row)]?.id;
field.onNumberInput = (n) => {
const id = idOf();
if (id) value.preview?.(id, n);
};
field.onNumberChange = (n) => {
const id = idOf();
if (id) value.set(id, n);
};
row.field = field;
this.addChildAt(field, this.getChildIndex(this.marker));
}
return row;
}
private rowAt(y: number): number {
@@ -233,7 +362,16 @@ export class SortableList extends Widget {
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 };
return { row, part: this.partAt(local.x) };
}
private partAt(x: number): RowPart {
const slots = this.slots;
if (this.canRemove && x >= slots.duplicate) return 'cross';
if (this.options.onDuplicate && x >= slots.eye && x < slots.duplicate) return 'duplicate';
if (this.options.onToggleHidden && x >= slots.value && x < slots.eye) return 'eye';
if (this.options.onRename && x >= this.textX && x < this.nameRight) return 'name';
return 'row';
}
private onPress(e: FederatedPointerEvent): void {
@@ -247,13 +385,22 @@ export class SortableList extends Widget {
return;
}
const id = this.shown[hit.row]!.id;
if (hit.cross) {
if (hit.part === '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);
if (hit.part === 'duplicate') {
this.options.onDuplicate?.(id);
return;
}
if (hit.part === 'eye') {
this.options.onToggleHidden?.(id);
return;
}
// Keyed by list and item, not by this widget: the first click's pick may rebuild the list.
if (hit.part === 'name' && pressCount(e, `sortable:${this.options.title ?? ''}:${id}`) >= 2) {
this.drag = null;
this.startRename(hit.row);
return;
}
if (this.options.multiSelect && (e.ctrlKey || e.metaKey || e.shiftKey)) {
@@ -266,6 +413,43 @@ export class SortableList extends Widget {
this.drag = { from: hit.row, pick, startY: y, y, active: false };
}
private startRename(index: number): void {
const item = this.shown[index];
const onRename = this.options.onRename;
if (!item || !onRename) return;
const field = new RowNameField(item.label);
field.onChange = (name) => {
const trimmed = name.trim();
if (trimmed && trimmed !== item.label) onRename(item.id, trimmed);
};
field.onDone = () => {
if (this.renaming?.field === field) this.renaming = null;
// Not while the focus manager is still busy with it.
queueMicrotask(() => {
if (!field.destroyed) field.destroy();
});
if (!this.destroyed) this.invalidate();
};
this.renaming = { id: item.id, field };
this.addChild(field);
this.placeRenameField();
ui().focus.focus(field);
field.selectAll();
this.invalidate();
}
private placeRenameField(): void {
const renaming = this.renaming;
if (!renaming) return;
const index = this.shown.findIndex((item) => item.id === renaming.id);
const x = this.textX - PAD;
renaming.field.position.set(
x,
this.headerHeight + index * SORTABLE_ROW_HEIGHT + (SORTABLE_ROW_HEIGHT - FIELD_HEIGHT) / 2,
);
renaming.field.resize(Math.max(40, this.nameRight - x - PAD), FIELD_HEIGHT);
}
private onMove(e: FederatedPointerEvent): void {
const drag = this.drag;
if (!drag) return;
@@ -297,18 +481,22 @@ export class SortableList extends Widget {
.fill(Theme.colors.control);
this.marker.clear();
this.redrawHeader();
const slots = this.slots;
const nameRight = this.nameRight;
const eyes = Boolean(this.options.onToggleHidden);
const crosses = this.canRemove;
const crossW = this.crossWidth;
const duplicates = Boolean(this.options.onDuplicate);
const value = this.options.value;
const thumbs = this.options.thumb;
const textX = thumbs ? PAD * 2 + THUMB : PAD * 2;
const textX = this.textX;
const hovered = this.over !== 'add' ? this.over : null;
for (const [index, item] of this.shown.entries()) {
const row = this.rows[index]!;
const y = top + index * SORTABLE_ROW_HEIGHT;
row.box.position.set(0, y);
const selected = this.selected.includes(item.id);
if (selected || hovered?.row === index)
const underPointer = hovered?.row === index;
if (selected || underPointer)
this.background
.rect(0, y, this.w, SORTABLE_ROW_HEIGHT)
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
@@ -324,30 +512,51 @@ export class SortableList extends Widget {
}
row.art.alpha = item.hidden ? 0.35 : 1;
const eyeW = eyes ? EYE_SLOT : 0;
row.hint.text = item.hint ?? '';
const hintW = row.hint.text ? row.hint.width + PAD * 2 : 0;
// The number, with its suffix right of it; without one, the owner's hint stands there.
const number = value?.get(item.id) ?? null;
row.hint.text = value ? (number !== null ? (value.suffix ?? '') : '') : (item.hint ?? '');
row.hint.position.set(
Math.round(this.w - crossW - eyeW - PAD - row.hint.width),
Math.round(slots.value - PAD - row.hint.width),
(SORTABLE_ROW_HEIGHT - row.hint.height) / 2,
);
const hintW = !value && row.hint.text ? row.hint.width + PAD * 2 : 0;
if (row.field) {
row.field.visible = number !== null;
if (number !== null) row.field.setNumber(number);
const suffix = value?.suffix ? row.hint.width + PAD : 0;
row.field.position.set(
slots.value - PAD - suffix - VALUE_WIDTH,
y + (SORTABLE_ROW_HEIGHT - FIELD_HEIGHT) / 2,
);
row.field.resize(VALUE_WIDTH, FIELD_HEIGHT);
}
const renamed = this.renaming?.id === item.id;
row.label.visible = !renamed;
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 - crossW - PAD),
);
row.label.text = truncate(item.label, row.label.style, Math.max(0, nameRight - textX - hintW - PAD));
row.label.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2);
const mid = SORTABLE_ROW_HEIGHT / 2;
row.eye.clear();
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);
if (eyes) drawEye(row.eye, slots.eye - EYE_SLOT / 2, mid, !item.hidden);
// Only where the pointer or the selection is: a mark on every row would crowd a long list.
const active = selected || underPointer;
row.duplicate.clear();
if (duplicates && active) {
const hot = underPointer && hovered.part === 'duplicate';
drawDuplicate(row.duplicate, slots.duplicate - DUPLICATE_SLOT / 2, mid, hot);
}
row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1;
row.cross.clear();
if (crosses && active) {
const hot = underPointer && hovered.part === 'cross';
drawCross(row.cross, slots.cross - REMOVE_SLOT / 2, mid, hot);
}
const dragged = this.drag?.active && this.drag.from === index;
row.box.alpha = dragged ? 0.4 : 1;
if (row.field) row.field.alpha = dragged ? 0.4 : 1;
}
this.placeRenameField();
if (this.drag?.active) {
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);
@@ -379,7 +588,21 @@ export class SortableList extends Widget {
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;
return a.row === b.row && a.part === b.part;
}
const suffixWidths = new Map<string, number>();
/** How wide a suffix is drawn – measured once per text, the layout asks on every redraw. */
function suffixWidth(suffix: string): number {
let width = suffixWidths.get(suffix);
if (width === undefined) {
const text = new Text({ text: suffix, style: textStyle('small') });
width = text.width;
text.destroy();
suffixWidths.set(suffix, width);
}
return width;
}
/** An open eye, or a closed one struck through – drawn rather than a glyph, so it stays crisp. */
@@ -393,6 +616,13 @@ function drawEye(g: Graphics, x: number, y: number, open: boolean): void {
.stroke({ width: 1.2, color });
}
/** Two overlapping squares, the usual sign for a copy: muted, and in the text colour under the pointer. */
function drawDuplicate(g: Graphics, x: number, y: number, hot: boolean): void {
const color = hot ? Theme.colors.text : Theme.colors.textMuted;
g.rect(x - 5, y - 5, 7, 7).stroke({ width: 1.2, color });
g.rect(x - 2, y - 2, 7, 7).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)