Files
feuerwerk/src/editor/list/InlineList.ts
T
adminandClaude Opus 5.5 c74b6b1cab
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 16m38s
Show where an inspector row can be dragged and answer the pointer
After #310 rows could be dragged by their mark, but nothing said which
marks take a drag, and the pointer on one changed nothing but the
cursor.

- A mark that drags is now a Grip: the same widget wrapped, the same
  size, so the value column stays where it was. Once sortBlocks takes
  it, it carries six faint dots at its left edge.
- Marks that cannot drag stay plain: the variables of a placed object
  and the fields of a typed struct show no dots.
- With the pointer on a grip its dots light up and the whole block that
  would move is washed in the accent colour, a struct with its fields.
  While the block is carried the wash gives way to the dimming, and the
  grip stays lit.
- While a block is carried the other grips let the pointer pass: no
  lit dots, no wash. Every grip of the column shows grabbing instead of
  grab until the drop.
- The wash first used controlHover like list rows. In the inspector it
  is darker than the panel and vanished next to the dark fields, so it
  became the accent at 15 percent, the colour of the drop line.
- __gm.inspectorMark also reports the dots: none, idle or lit.

Tests:
- Gate on melektaus-atx: 236 files, 3576 tests (+4 in
  block-drag.test.ts, one of them a drag passing over another grip).
- e2e 22: 40 checks (+1, sum 1740). 22f-iii reads idle dots, moves the
  mouse onto the mark and reads them lit.
- Two counter-checks bite: no wash on hover fails 1 test, grips never
  made grabbable fail 3. A third: hover ignoring the drag fails 1.
- Screenshots of a lit single row and a lit struct block looked at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:11:41 +02:00

640 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The items of a list, drawn right where the list stands (#252): under its row in the inspector,
* under a struct field, inside another list, and as the whole content of a Variables tab. A row per
* item with the same field widgets the inspector builds; a list item is drawn inline again, one
* step further in.
*
* A list declares nothing, so this is also where an item's kind is chosen: the mark in front of a
* row is a picker, and changing it (or a struct item's shape) is an ordinary value edit. A typed list
* (#254) is the exception: its items are its type, fixed – and in a mixed list a struct item may pick
* a type of its own, which is a value edit again, since the item carries it.
*/
import {
Button,
Dropdown,
Grip,
HStack,
Label,
Menu,
type MenuEntry,
ScrollContainer,
type SortBlock,
sortBlocks,
Spacer,
TextField,
Theme,
ui,
VStack,
type Widget,
} from '../../ui';
import {
isList,
isStruct,
itemTypeOf,
type ListValue,
type PropertyValue,
type StructValue,
textOf,
untag,
withFieldRenamed,
withItemType,
withoutField,
} from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes';
import { nextFieldName } from '../../core/commands/naming';
import { moveItem } from '../../core/util/list';
import { type FieldKind, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
import {
type ListItem,
itemKindOf,
makeItem,
structItemFields,
structItemOf,
} from '../../core/properties/listItems';
import { flatDef, type StructFieldDef } from '../../core/properties/PropertySchema';
import { pathLabel, valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath';
import {
addValue,
createField,
type FieldContext,
FIELD_INDENT,
MARK_WIDTH,
nameWidth,
removeButton,
} from '../inspector/fieldFactory';
import { kindMenu, markLabel, markPicker } from '../inspector/variableRows';
import { shownItems } from '../variables/target';
import type { ListAccess } from './slot';
/** Room for three digits; a longer index widens its own label. */
const INDEX_WIDTH = 28;
/** What a check script may ask a row for: its value widget, its mark, and the same per struct field. */
interface RowWidgets {
value: Widget;
mark: Widget;
remove?: Widget;
parts: Map<string, Widget>;
marks: Map<string, Widget>;
names: Map<string, Widget>;
/** The kind each struct field is drawn as – the type's where it has one (#254). */
kinds: Map<string, NestedKind>;
/** The Type picker of the struct item (`''`) and of struct fields inside it. */
types: Map<string, Widget>;
/** A struct item drawn with its type's fields: no names to change, no + Field. */
locked: boolean;
}
const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({
mark,
value,
parts: new Map(),
marks: new Map(),
names: new Map(),
kinds: new Map(),
types: new Map(),
locked: false,
});
const centreOf = (widget: Widget | undefined): { x: number; y: number } | null => {
if (!widget || widget.destroyed || !widget.visible) return null;
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
};
/**
* What a list's head reads: its count, and the way to add an item. Where the list has a tab of its
* own the count is the button that opens it – an inspector row has no room for a third control.
*/
export class ListHead extends HStack {
private readonly count: Label | Button;
readonly addButton: Dropdown;
readonly openButton: Button | null;
/** `openable` false: the head of the tab the list is open in already. */
constructor(access: () => ListAccess | null, add: (kind: NestedKind) => void, openable = true) {
super({ gap: Theme.gap, align: 'center' });
this.minHeight = Theme.rowHeight;
const now = access();
this.openButton =
openable && now?.openList
? new Button('', { variant: 'ghost', onClick: () => access()?.openList?.([]) })
: null;
this.count = this.openButton ?? new Label('', { style: 'muted', padding: Theme.fieldPadding });
this.add(this.count);
this.add(new Spacer(), 1);
this.addButton = new Dropdown('+ Item', { variant: 'ghost' });
// Built on click, not once: which kinds fit depends on the list as it is then. A typed list
// has one kind only, so there is nothing to pick.
this.addButton.onClick = () => {
const now = access();
if (now?.itemType) add('struct');
else new Menu(kindMenu(now?.kinds ?? NESTED_KINDS, add)).openBelow(this.addButton);
};
this.add(this.addButton);
this.setCount(now?.value.length ?? 0);
}
setCount(count: number): void {
const text = `${count} items`;
if (this.count.text === text) return;
this.count.setText(text);
// Its width follows the number, and the spacer after it has to give or take that much.
if (this.w > 0) this.relayout();
}
}
export interface InlineListOptions {
/** Items drawn before "… N more"; none means all of them. */
limit?: number;
}
export class InlineList extends VStack {
/** The structure the rows were built for; only a change here may destroy a row being typed in. */
private signature = '';
private setters: ((item: PropertyValue) => void)[] = [];
private rowWidgets: RowWidgets[] = [];
/** The lists drawn inside this one: each follows its own items. */
private inner: InlineList[] = [];
private innerHeads: ListHead[] = [];
private more: Widget | null = null;
/** The head drawn for this list – count, "+ Item", "Open" – wherever its builder put it. */
head: ListHead | null = null;
constructor(
private readonly ctx: FieldContext,
readonly access: () => ListAccess | null,
private readonly options: InlineListOptions = {},
) {
super({ gap: Theme.gap });
this.build();
}
/** The title of the list this draws – what the check scripts find it by. */
get title(): string {
return this.access()?.title ?? '';
}
/** Adds an item of `kind` at the end – the head's `+ Item`; in a typed list one of its type. */
addItem(kind: NestedKind): void {
const access = this.access();
if (!access) return;
const type = this.listType(access);
access.replace([...access.value, type ? typedItem(this.project, type) : makeItem(kind, undefined)]);
}
private get project(): Project {
return this.ctx.project();
}
private listType(access: ListAccess) {
return structAssetOf(this.project, access.itemType);
}
/** Re-reads the list: new values go into the rows, a new structure builds them again. */
refresh(): void {
const access = this.access();
if (this.signatureOf(access) !== this.signature) {
// A rebuild throws away the field being typed in; the next refresh after it is left does it.
if (ui().focus.isWithin(this)) return;
this.build();
this.scrollArea()?.relayoutContent();
return;
}
if (!access) return;
this.setters.forEach((set, i) => set(access.value[i] ?? ''));
this.inner.forEach((list) => list.refresh());
this.innerHeads.forEach((head, i) => head.setCount(this.inner[i]?.access()?.value.length ?? 0));
}
/** For the check scripts: where an item's widget sits, since a number field carries no findable text. */
fieldAt(index: number, field = ''): { x: number; y: number } | null {
const row = this.rowWidgets[index];
return centreOf(field ? row?.parts.get(field) : row?.value);
}
/** For the check scripts: the kind mark of a row, or of one field of a struct item. */
markAt(index: number, field = ''): { x: number; y: number } | null {
const row = this.rowWidgets[index];
return centreOf(field ? row?.marks.get(field) : row?.mark);
}
/** For the check scripts: the button that deletes a row – every row has the same `×`. */
removeAt(index: number): { x: number; y: number } | null {
return centreOf(this.rowWidgets[index]?.remove);
}
/** For the check scripts: the name field of one field of a struct item. */
nameAt(index: number, field: string): { x: number; y: number } | null {
return centreOf(this.rowWidgets[index]?.names.get(field));
}
/** For the check scripts: the Type picker of a struct item, or of a struct field inside it. */
typeAt(index: number, field = ''): { x: number; y: number } | null {
return centreOf(this.rowWidgets[index]?.types.get(field));
}
/** For the check scripts: the "… N more" line, which opens the list in its tab. */
moreAt(): { x: number; y: number } | null {
return centreOf(this.more ?? undefined);
}
/** For the check scripts: what the list holds and how much of it the last build drew. */
debugInfo(): {
title: string;
items: string[];
kinds: NestedKind[];
offers: NestedKind[];
rows: number;
more: number;
itemType: string;
/** The type each item is marked with, `''` for none. */
types: string[];
/** Per drawn row: whether its fields are the type's, fixed. */
locked: boolean[];
/** Per drawn row: the kind each struct field is drawn as, by its dotted id. */
fieldKinds: Record<string, NestedKind>[];
} {
const access = this.access();
const base = { rows: this.rowWidgets.length, more: 0 };
if (!access)
return {
...base,
title: '',
items: [],
kinds: [],
offers: [],
itemType: '',
types: [],
locked: [],
fieldKinds: [],
};
return {
...base,
// Drawn, not read: `items` re-reads the list, `rows` is what the last build put on screen.
more: shownItems(access.value.length, this.options.limit).more,
title: access.title,
items: access.value.map((item) => textOf(item)),
kinds: access.value.map(itemKindOf),
offers: [...access.kinds],
itemType: access.itemType ?? '',
types: access.value.map((item) => itemTypeOf(item) ?? ''),
locked: this.rowWidgets.map((row) => row.locked),
fieldKinds: this.rowWidgets.map((row) => Object.fromEntries(row.kinds)),
};
}
/** Everything that decides how many rows there are and of what kind – a struct item's fields included, at any depth. */
private signatureOf(access: ListAccess | null): string {
if (!access) return '';
const { shown } = shownItems(access.value.length, this.options.limit);
// A type's name is on screen too, so a renamed or deleted type has to rebuild the rows.
const typeName = (id: string | undefined) =>
id ? `@${structAssetOf(this.project, id)?.name ?? ''}` : '';
const shape = access.value
.slice(0, shown)
.map((item) => shapeOf(item, typeName))
.join(',');
return `${access.title}|${typeName(access.itemType)}|${shape}|${access.value.length}`;
}
private scrollArea(): ScrollContainer | null {
for (let parent = this.parent; parent; parent = parent.parent)
if (parent instanceof ScrollContainer) return parent;
return null;
}
private build(): void {
const access = this.access();
this.signature = this.signatureOf(access);
for (const child of [...this.children]) child.destroy({ children: true });
this.setters = [];
this.rowWidgets = [];
this.inner = [];
this.innerHeads = [];
this.more = null;
if (access) {
const { shown, more } = shownItems(access.value.length, this.options.limit);
const blocks = access.value.slice(0, shown).map((item, index) => this.itemRow(access, item, index));
sortBlocks(blocks, (from, to) => this.moveItem(from, to));
if (more > 0) this.add(this.moreLine(access, more));
}
this.relayout();
}
/** The line under a cut-off list: how much is missing, and – where the list has a tab – the way there. */
private moreLine(access: ListAccess, more: number): Widget {
const text = `… ${more} more`;
const open = access.openList;
this.more = open
? new Button(`${text} – open in tab`, { variant: 'ghost', onClick: () => open([]) })
: new Label(text, { style: 'muted' });
const line = new HStack({ gap: Theme.gap, align: 'center' });
line.add(new Spacer(INDEX_WIDTH));
line.add(this.more);
line.add(new Spacer(), 1);
return line;
}
/** An item dragged by its number to another place (#310): the order is part of the value. */
private moveItem(from: number, to: number): void {
const access = this.access();
if (!access) return;
const next = [...access.value];
if (moveItem(next, from, to)) access.replace(next);
}
/** The item's rows, its number being where it is dragged from. */
private itemRow(access: ListAccess, item: ListValue[number], index: number): SortBlock {
const kind = itemKindOf(item);
// A struct item is a stack of field rows: its row grows with it and aligns to the top.
const tall = kind === 'struct';
const row = new HStack({ gap: Theme.gap, align: tall ? 'start' : 'center' });
if (!tall) row.minHeight = Theme.rowHeight;
const label = new Label(String(index), { style: 'small', align: 'right' });
label.minWidth = INDEX_WIDTH;
if (tall) label.minHeight = Theme.rowHeight;
const grip = row.add(new Grip(label));
// A typed list's items are its type: there is no other kind to turn one into.
const mark = this.listType(access) ? markLabel(kind) : this.kindPicker(access, [index], kind);
if (tall) mark.minHeight = Theme.rowHeight;
row.add(mark);
let below: Widget | null = null;
if (tall) this.addStructItem(access, row, mark, index);
else if (kind === 'list') {
const nested = this.nestedList(access, [index]);
row.add(nested.head, 1);
below = nested.list;
this.setters.push(() => {});
this.rowWidgets.push(rowWidgets(mark, nested.head));
} else {
const field = this.valueField(access, [index], kind, item);
addValue(row, field);
this.setters.push((next) => field.setValue(untag(next) ?? ''));
this.rowWidgets.push(rowWidgets(mark, field.widget));
}
const remove = removeButton(() => access.replace(access.value.filter((_, i) => i !== index)));
row.add(remove);
const widgets = this.rowWidgets[index];
if (widgets) widgets.remove = remove;
this.add(row);
if (!below) return { grip, rows: [row] };
const items = indented(below, INDEX_WIDTH + Theme.gap);
this.add(items);
return { grip, rows: [row, items] };
}
/** A list inside this one: its head goes where a value would, its items below, one step further in. */
private nestedList(access: ListAccess, path: ValuePath): { head: ListHead; list: InlineList } {
const inner = () => access.sub(path);
const list = new InlineList(this.ctx, inner, this.options);
const head = new ListHead(inner, (kind) => list.addItem(kind));
list.head = head;
this.inner.push(list);
this.innerHeads.push(head);
return { head, list };
}
/** The mark of the value at `path`, offering the other kinds. Through `untag`, so a color keeps its number when it becomes one. */
private kindPicker(access: ListAccess, path: ValuePath, kind: NestedKind): Widget {
return markPicker(
kind,
kindMenu(
access.kinds.filter((next) => next !== kind),
(next) => this.replaceAt(access, path, makeItem(next, untag(valueAt(access.value, path)))),
),
);
}
/** The widget of a value that is neither a struct nor a list. */
private valueField(
access: ListAccess,
path: ValuePath,
kind: NestedKind,
value: PropertyValue | undefined,
) {
const key = String(path.at(-1));
return createField(
this.ctx,
flatDef(key, kind as FieldKind),
untag(value) ?? '',
access.handlersAt(path),
[access.def.label, ...pathLabel(path)].join(' · '),
);
}
/**
* A struct item carries its own shape, so its fields are named, typed and removed right here –
* unless it has a type (#254): of the list, or picked for the item. Then its fields are the type's.
*/
private addStructItem(access: ListAccess, row: HStack, mark: Widget, index: number): void {
const stack = new VStack({ gap: Theme.gap });
const widgets = rowWidgets(mark, stack);
const leaves: Leaf[] = [];
const item = valueAt(access.value, [index]);
const typed = this.listType(access) !== undefined;
if (!typed && access.typable) stack.add(this.typeLine(access, [index], 0, widgets, ''));
const declared = this.declaredFields(this.listType(access)?.id ?? itemTypeOf(item));
widgets.locked = declared !== undefined;
this.addStructFields(access, stack, [index], 0, widgets, leaves, declared);
row.add(stack, 1);
this.setters.push((next) => {
for (const leaf of leaves) leaf.set(untag(valueAt(next, leaf.within)) ?? '');
});
this.rowWidgets.push(widgets);
}
/** The fields of a Struct asset, or none for a struct of its own fields. */
private declaredFields(typeId: string | undefined): readonly StructFieldDef[] | undefined {
const type = structAssetOf(this.project, typeId);
return type ? typeFields(type) : undefined;
}
/**
* The fields of the struct at `path`, a line each; a field that is a struct again gets its own
* lines below, indented one step further (#239), and so does the item list of a list field. Check
* scripts find a nested field by its keys joined with dots (`loot.count`).
*
* `declared` are the type's fields when the struct has one: its shape is fixed then, and a field
* is the kind the type says – an item keeps an asset id as a plain text, which alone reads as one.
*/
private addStructFields(
access: ListAccess,
stack: VStack,
path: ValuePath,
depth: number,
widgets: RowWidgets,
leaves: Leaf[],
declared: readonly StructFieldDef[] | undefined,
): void {
const locked = declared !== undefined;
// A field value edit does not rebuild the row, so every button reads the struct as it is now.
const now = () => structItemOf(valueAt(access.value, path));
for (const own of structItemFields(valueAt(access.value, path))) {
const decl = declared?.find((d) => d.key === own.key);
const field = { key: own.key, kind: declaredKind(own.kind, decl) };
const fieldPath = [...path, field.key];
const id = fieldPath.slice(1).join('.');
const line = indentedLine(depth);
// The fields of a typed struct are the type's: its values change here, its shape on the asset.
const fieldMark = locked ? markLabel(field.kind) : this.kindPicker(access, fieldPath, field.kind);
const name = locked
? new Label(field.key, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding })
: nameField(field.key, depth, (to) => {
if (to && to !== field.key) this.replaceAt(access, path, withFieldRenamed(now(), field.key, to));
});
line.add(fieldMark);
line.add(name);
let below: Widget | null = null;
if (field.kind === 'struct') line.add(new Spacer(), 1);
else if (field.kind === 'list') {
const nested = this.nestedList(access, fieldPath);
line.add(nested.head, 1);
below = nested.list;
widgets.parts.set(id, nested.head);
} else {
const value = valueAt(access.value, fieldPath);
const sub = this.valueField(access, fieldPath, field.kind, value);
leaves.push({ within: fieldPath.slice(1), set: addValue(line, sub) });
widgets.parts.set(id, sub.widget);
}
widgets.marks.set(id, fieldMark);
widgets.kinds.set(id, field.kind);
widgets.names.set(id, name);
if (!locked) line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key))));
stack.add(line);
// Items under the field's own line, one step further in than the field.
if (below) stack.add(indented(below, (depth + 1) * (FIELD_INDENT + Theme.gap)));
if (field.kind === 'struct') {
// A struct in a struct of its own fields may take a type as well; inside a type it is the type's.
if (!locked && access.typable) stack.add(this.typeLine(access, fieldPath, depth + 1, widgets, id));
const inner =
decl?.kind === 'struct'
? (this.declaredFields(decl.structType) ?? decl.fields)
: locked
? []
: this.declaredFields(itemTypeOf(valueAt(access.value, fieldPath)));
this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves, inner);
}
}
if (locked) return;
const foot = indentedLine(depth);
foot.add(addFieldMenu((kind) => this.addField(access, path, kind)));
foot.add(new Spacer(), 1);
stack.add(foot);
}
/**
* Which Struct asset the struct at `path` follows (#254): its own fields, or a type – picking one
* brings the struct into that shape, keeping what it has of the type's fields.
*/
private typeLine(
access: ListAccess,
path: ValuePath,
depth: number,
widgets: RowWidgets,
id: string,
): HStack {
const current = structAssetOf(this.project, itemTypeOf(valueAt(access.value, path)));
const picker = new Dropdown(current?.name ?? 'Own fields', { variant: 'ghost' });
const struct = (): StructValue => structItemOf(valueAt(access.value, path));
picker.onClick = () => {
const entries: MenuEntry[] = [
{
label: 'Own fields',
current: !current,
onSelect: () => this.replaceAt(access, path, withItemType(struct(), '')),
},
...this.project.assets
.filter((a) => a.type === 'struct')
.map((type) => ({
label: type.name,
current: type.id === current?.id,
onSelect: () => {
const asset = structAssetOf(this.project, type.id);
if (asset) this.replaceAt(access, path, typedItem(this.project, asset, struct()));
},
})),
];
new Menu(entries).openBelow(picker);
};
widgets.types.set(id, picker);
const line = indentedLine(depth);
line.add(new Spacer(MARK_WIDTH));
line.add(new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding }));
line.add(picker, 1);
return line;
}
private addField(access: ListAccess, path: ValuePath, kind: NestedKind): void {
const bundle = valueAt(access.value, path);
const key = nextFieldName(structItemFields(bundle));
this.replaceAt(access, path, { ...structItemOf(bundle), [key]: makeItem(kind, undefined) });
}
/** Every shape change is a value edit: the kind lives in the item, so there is nothing else to set. */
private replaceAt(access: ListAccess, path: ValuePath, value: ListItem): void {
const next = withValueAt(access.value, path, value);
if (isList(next)) access.replace(next);
}
}
/**
* The kind a field is drawn as: the declared one for a value that is not a list or a struct – a
* struct or list stays what its value is until the type brings it into shape.
*/
function declaredKind(own: NestedKind, decl: StructFieldDef | undefined): NestedKind {
if (!decl || own === 'list' || own === 'struct' || decl.kind === 'list' || decl.kind === 'struct')
return own;
return decl.kind;
}
/** One value of a struct item's tree: where it sits inside the item, and how its widget is set. */
interface Leaf {
within: ValuePath;
set: (value: PropertyValue) => void;
}
/** A widget pushed `by` pixels right, taking the rest of the width. */
function indented(widget: Widget, by: number): HStack {
const line = new HStack({ gap: 0 });
if (by > 0) line.add(new Spacer(by));
line.add(widget, 1);
return line;
}
/** A line of a struct item, pushed right one step per level it sits below the item. */
function indentedLine(depth: number): HStack {
const line = new HStack({ gap: Theme.gap, align: 'center' });
line.minHeight = Theme.rowHeight;
if (depth > 0) line.add(new Spacer(depth * (FIELD_INDENT + Theme.gap) - Theme.gap));
return line;
}
/**
* The field's name, changeable: a struct item's shape is its own, not a declaration elsewhere. It
* narrows as it is indented, so the values of every level start on one column.
*/
function nameField(key: string, depth: number, rename: (next: string) => void): TextField {
return new TextField(key, { width: nameWidth(depth), onChange: (next) => rename(next.trim()) });
}
function addFieldMenu(add: (kind: NestedKind) => void): Dropdown {
const button = new Dropdown('+ Field', { variant: 'ghost' });
button.onClick = () => new Menu(kindMenu(NESTED_KINDS, add)).openBelow(button);
return button;
}
/**
* An item's kind, and for a struct its type and the shape of every field below it – what its rows
* are built from.
*/
function shapeOf(item: PropertyValue | undefined, typeName: (id: string | undefined) => string): string {
const kind = itemKindOf(item);
if (kind !== 'struct' || !isStruct(item)) return kind;
return `struct${typeName(itemTypeOf(item))}(${structItemFields(item)
.map((f) => `${f.key}:${shapeOf(item[f.key], typeName)}`)
.join(',')})`;
}