Define a struct once as an asset and reuse its shape (#253)
A struct used to declare its fields wherever it stood. The same shape in two variables meant typing it twice, and Pack/Expand copied it once without staying in sync. A Struct asset now holds the shape in one place, and every struct that picks it as its type follows it. - New asset type "Struct" (not placeable, group Structs, own icon). The inspector shows its fields with name, kind and default value, and "Open" shows them in the variables tab. - Struct variables and struct fields get a Type row: own fields, any Struct asset, or "Make struct type". With a type the fields come from the asset and cannot be added, renamed or removed there. The values stay per variable, overrides on placed objects still work. - Pack and Expand get the setting Type. With a type their ports are the fields of the asset and read-only in the inspector. - A change to the asset reaches every typed place in one undo step (syncStructTypes). A new field arrives with its default. A removed field goes along with its port, edge and typed value. A renamed field keeps its value in variables, overrides and on ports with edges, also through nested types. - Undo for these edits records plain assignments (Recorder), because the reach of a change is not known in advance. - Cycles such as Loot in Loot, also through another type, are refused and not offered in the picker. - Deleting the asset names how many places use it. Afterwards each place keeps its fields as its own. - Loading reshapes a stale copy in a project file to its type. - "Make struct type" creates an asset from an existing struct and links the place to it. Differs from the plan: - A changed default value applies to new fields and newly typed places only. Existing values stay. - Removing a single field shows no warning. Undo brings it back. Typed lists are left for #254. Lists stay dynamic for now. Found by e2e: after deleting or renaming the type the inspector did not rebuild and kept showing the old name. Fixed, the check was red before. Tests: - ./check.sh: 182 files, 2661 tests, CHECK EXIT 0 - GM_STEPS=1,4,21,22,54: ALL PASSED, 176 checks - 14 unit tests in tests/core/struct-types.test.ts - New e2e module 54 with 14 checks, real mouse. Module 04 counts 23 asset types. - Counter-probes: without the rename pass 4 unit tests fail, without reshaping values 8, without the cycle rule 1. In the browser, without spreading changes and with + Field despite a type, 3 checks fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4857bb75e6
commit
61bbb96a96
29 files changed
+1523
-66
No files matched your search
@@ -8,6 +8,7 @@ import {
|
||||
Icon,
|
||||
Label,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
type MenuItem,
|
||||
Spacer,
|
||||
TextField,
|
||||
@@ -56,8 +57,19 @@ export interface VariableKeep {
|
||||
set: (on: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of a struct (#253), offered where its declaration is edited. `path` names the struct
|
||||
* from the variable down, as for `VariableEditing`.
|
||||
*/
|
||||
export interface StructTyping {
|
||||
/** The Struct asset the struct follows, or null while it holds its own fields – a deleted type too. */
|
||||
typeName: (structType: string | undefined) => string | null;
|
||||
menu: (path: readonly string[], structType: string | undefined) => MenuEntry[];
|
||||
}
|
||||
|
||||
export interface VariableOptions {
|
||||
edit?: VariableEditing;
|
||||
typing?: StructTyping;
|
||||
keep?: VariableKeep;
|
||||
/** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */
|
||||
actions?: RowActions;
|
||||
@@ -74,14 +86,16 @@ export function buildVariable(
|
||||
handlers: FieldHandlers,
|
||||
options: VariableOptions = {},
|
||||
): FieldRow {
|
||||
const { edit, keep, actions } = options;
|
||||
const { edit, typing, keep, actions } = options;
|
||||
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
|
||||
const head = gridRow(0, markLabel(def.kind), name);
|
||||
let setValue: (next: PropertyValue) => void = () => {};
|
||||
let below: Widget | undefined;
|
||||
// A struct with a type has the type's fields: they are changed on the Struct asset, not here.
|
||||
const typed = def.kind === 'struct' ? (typing?.typeName(def.structType) ?? null) : null;
|
||||
if (def.kind === 'struct') {
|
||||
head.add(new Spacer(), 1);
|
||||
if (edit) head.add(addFieldMenu(edit, []));
|
||||
if (edit && typed === null) head.add(addFieldMenu(edit, []));
|
||||
} else {
|
||||
const field = createField(host, def, value, handlers);
|
||||
setValue = addValue(head, field);
|
||||
@@ -94,8 +108,18 @@ export function buildVariable(
|
||||
if (below) host.body.add(indentedBy(below, 1));
|
||||
|
||||
if (def.kind === 'struct') {
|
||||
if (typing) host.body.add(typeRow(1, typed, () => typing.menu([], def.structType)));
|
||||
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
|
||||
setValue = structFieldRows(host, def.fields, [], value, handlers, edit);
|
||||
const own = typed === null;
|
||||
setValue = structFieldRows(
|
||||
host,
|
||||
def.fields,
|
||||
[],
|
||||
value,
|
||||
handlers,
|
||||
own ? edit : undefined,
|
||||
own ? typing : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -132,17 +156,33 @@ function structFieldRows(
|
||||
value: PropertyValue,
|
||||
handlers: FieldHandlers,
|
||||
edit: VariableEditing | undefined,
|
||||
typing: StructTyping | undefined,
|
||||
): (next: PropertyValue) => void {
|
||||
const setters = new Map<string, (item: PropertyValue) => void>();
|
||||
for (const field of fields) {
|
||||
const row = fieldRow(field, path, edit);
|
||||
const item = structFieldValue(value, field);
|
||||
if (field.kind === 'struct') {
|
||||
const inner = [...path, field.key];
|
||||
const typed = typing?.typeName(field.structType) ?? null;
|
||||
row.add(new Spacer(), 1);
|
||||
if (edit) row.add(addFieldMenu(edit, [...path, field.key]));
|
||||
if (edit && typed === null) row.add(addFieldMenu(edit, inner));
|
||||
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
||||
host.body.add(row);
|
||||
setters.set(field.key, structFieldRows(host, field.fields, [...path, field.key], item, handlers, edit));
|
||||
if (typing) host.body.add(typeRow(inner.length + 1, typed, () => typing.menu(inner, field.structType)));
|
||||
const own = typed === null;
|
||||
setters.set(
|
||||
field.key,
|
||||
structFieldRows(
|
||||
host,
|
||||
field.fields,
|
||||
inner,
|
||||
item,
|
||||
handlers,
|
||||
own ? edit : undefined,
|
||||
own ? typing : undefined,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
|
||||
@@ -163,6 +203,16 @@ function fieldRow(field: StructFieldDef, path: readonly string[], edit: Variable
|
||||
return gridRow(depth, markLabel(field.kind), name);
|
||||
}
|
||||
|
||||
/** Which Struct asset a struct follows, as a row of its own under the struct's head. */
|
||||
function typeRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack {
|
||||
const picker = new Dropdown(typeName ?? 'Own fields', { variant: 'ghost' });
|
||||
picker.onClick = () => new Menu(menu()).openBelow(picker);
|
||||
const name = new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding });
|
||||
const row = gridRow(depth, new Spacer(MARK_WIDTH), name);
|
||||
row.add(picker, 1);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
|
||||
function gridRow(depth: number, mark: Widget, name: Widget): HStack {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
|
||||
Reference in new issue
Block a user