diff --git a/README.md b/README.md index ef0bee5a..d45d740d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ shown on the node and editable in the inspector. - **Variables** are the behavior's own property schema (`BehaviorAsset.variables`): they appear in the inspector, can be overridden per placed instance, and each running instance gets its own mutable copy. `Get variable` / `Set variable` read and write them. A variable is a - **number, a yes/no, a text or a list**; a list holds numbers, texts, yes/nos or colours (its + **number, a yes/no, a text, a list or a struct**; a list holds numbers, texts, yes/nos or colours (its item kind decides which widget an item gets) and is compared by its items, never by identity – otherwise every reload would look like an override. There is no list *of objects*: a list is stored in the project file and an object is a live @@ -102,7 +102,13 @@ shown on the node and editable in the inspector. inspector would use for that kind. The inspector stays visible beside the tab, so what the list belongs to is still on screen. Which list the tab shows is a `ListSlot` (asset property, object override or node setting) on the editor state, because no tab may reach into the `Editor` to - switch itself on. + switch itself on. A **struct** variable is the other way round: few fields, so they fit under + each other right in the inspector. Its head row renames it and adds fields (`+ Field`), each + field row below carries its own name, its value and a remove – the port editor `Pack` already + has, one level down and with a value column. Adding, renaming or removing a field reaches the + declaration, the behavior's own value **and every instance override** in one command, so no + stored value keeps a shape its declaration no longer promises. A placed instance sees the same + fields as plain value rows: it may override them, not declare them. - **Owner** = parent: a placed behavior is usually parented to the object it controls (inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up @@ -142,9 +148,14 @@ shown on the node and editable in the inspector. undo: an edge on a port that no longer exists draws as nothing but survives every save, and would come back to life once the port returns. - **Structs**: several named values travelling as one, so a bundle needs one edge instead of one - per part. `Pack` declares its fields as inputs, `Expand` reads them back out as outputs, and a - behavior port may be declared `struct` – which is how a bundle crosses a call. Fields are - primitives or lists, never another struct, for the same reason a list is flat. A live entity is + per part. `Pack` declares its fields as inputs, `Expand` reads them back out as outputs, a + behavior port may be declared `struct` – which is how a bundle crosses a call – and a + **variable** may be one, which is how a bundle is stored. Writing a struct variable keeps the + fields it declares: what arrives fills the ones it names, the rest stay put (`mergeStruct`), so + one node can set part of a bundle without erasing it. Fields are + primitives or lists, never another struct, for the same reason a list is flat; a *declared* + field is a primitive – a field holding a list would need a nested editor, which is the same + step "a list of structs" waits for. A live entity is an object on a data edge too, so it carries a `Symbol` brand (`ENTITY_HANDLE`) that no struct field name can impersonate; nothing may hand a property value to `String()` (`textOf` instead), or a struct would read as `[object Object]`. A struct port has no inline value (its parts come @@ -569,7 +580,7 @@ npm run format # Prettier over src, tests and tools npm run check # typecheck + lint + format:check + tests, what CI would run npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # optional: run the browser elsewhere; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 438 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 450 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end @@ -625,7 +636,7 @@ to touch it. The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing, graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect, -container tab, run-in-editor, pause, nested behaviors, key repeat, list tab) that build on each other's +container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable) that build on each other's scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises. diff --git a/src/core/commands/naming.ts b/src/core/commands/naming.ts index b3102589..2c7cf1fa 100644 --- a/src/core/commands/naming.ts +++ b/src/core/commands/naming.ts @@ -18,3 +18,8 @@ export function nextAssetName(project: Project, base: string): string { export function nextVariableName(asset: BehaviorAsset, base = 'var'): string { return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1); } + +/** "field1", "field2", … – free within the one struct, which is the only place the name has to be unique. */ +export function nextFieldName(fields: readonly { key: string }[], base = 'field'): string { + return firstFree(new Set(fields.map((f) => f.key)), (i) => `${base}${i}`, 1); +} diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 0f2a1ddd..b99839cf 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -1,7 +1,21 @@ -import type { PropertyValue } from '../model/Properties'; +import { + type FieldValue, + isStruct, + type PropertyBag, + type PropertyValue, + type StructValue, +} from '../model/Properties'; import { overrideSites } from '../model/instance'; import type { Project } from '../model/Project'; -import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema'; +import { + emptyField, + isCommonProp, + type PropertyDef, + type StructDef, + type StructFieldDef, + type VariableKind, + variableDef, +} from '../properties/PropertySchema'; import { type Removed, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { requireBehavior } from './require'; @@ -123,8 +137,179 @@ export class RenameVariableCommand implements Command { } const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _'; +const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _'; /** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */ export function isValidVariableName(name: string): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !isCommonProp(name); + return isValidFieldName(name) && !isCommonProp(name); +} + +/** A field lives inside its struct, so the reserved property keys do not apply to it. */ +export function isValidFieldName(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name); +} + +function requireStruct(project: Project, assetId: string, key: string): StructDef { + const def = requireBehavior(project, assetId).variables.find((v) => v.key === key); + if (!def || def.kind !== 'struct') throw new CommandError(`Variable "${key}" is not a struct`); + return def; +} + +/** + * Every value of one struct variable: the declared default, the behavior's own, and each instance + * override. A field is added to or taken from all of them at once, so no value keeps a shape its + * declaration no longer promises. The `id` is what pairs a site up again when the change is undone. + */ +interface StructSite { + id: string; + read(): StructValue; + write(value: StructValue): void; +} + +function structSites(project: Project, assetId: string, key: string): StructSite[] { + const asset = requireBehavior(project, assetId); + const def = asset.variables.find((v) => v.key === key); + const sites: StructSite[] = []; + if (def?.kind === 'struct') + sites.push({ + id: 'declared', + read: () => def.default, + write: (value) => { + def.default = value; + }, + }); + sites.push(bagSite('asset', asset.props, key)); + for (const site of overrideSites(project, (id) => id === assetId)) + // An override the object does not have stays absent: writing one here would invent a deviation. + if (isStruct(site.bag[key])) sites.push(bagSite(`${site.object.id}/${site.path}`, site.bag, key)); + return sites; +} + +function bagSite(id: string, bag: PropertyBag, key: string): StructSite { + return { + id, + read: () => { + const value = bag[key]; + return isStruct(value) ? value : {}; + }, + write: (value) => { + bag[key] = value; + }, + }; +} + +function withoutField(value: StructValue, key: string): StructValue { + const next: Record = { ...value }; + delete next[key]; + return next; +} + +/** Rebuilt rather than reassigned, so the field keeps its place in the value as well. */ +function renameField(value: StructValue, from: string, to: string): StructValue { + const next: Record = {}; + for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item; + return next; +} + +export class AddStructFieldCommand implements Command { + readonly label: string; + + constructor( + private readonly assetId: string, + private readonly key: string, + private readonly field: StructFieldDef, + ) { + this.label = `Add field ${field.key}`; + } + + execute(project: Project): void { + const def = requireStruct(project, this.assetId, this.key); + if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD); + if (def.fields.some((f) => f.key === this.field.key)) + throw new CommandError(`Field "${this.field.key}" already exists`); + def.fields = [...def.fields, { ...this.field }]; + for (const site of structSites(project, this.assetId, this.key)) + site.write({ ...site.read(), [this.field.key]: emptyField(this.field.kind) }); + } + + undo(project: Project): void { + const def = requireStruct(project, this.assetId, this.key); + def.fields = def.fields.filter((f) => f.key !== this.field.key); + for (const site of structSites(project, this.assetId, this.key)) + site.write(withoutField(site.read(), this.field.key)); + } +} + +/** Takes the field's value out of every struct that carries it, and gives each one back on undo. */ +export class RemoveStructFieldCommand implements Command { + readonly label: string; + private removed?: { field: Removed; values: Map }; + + constructor( + private readonly assetId: string, + private readonly key: string, + private readonly field: string, + ) { + this.label = `Remove field ${field}`; + } + + execute(project: Project): void { + const def = requireStruct(project, this.assetId, this.key); + const fields = [...def.fields]; + const field = removeFirst(fields, (f) => f.key === this.field); + if (!field) throw new CommandError(`Field "${this.field}" not found`); + const values = new Map(); + for (const site of structSites(project, this.assetId, this.key)) { + const value = site.read(); + const item = value[this.field]; + if (item !== undefined) values.set(site.id, item); + site.write(withoutField(value, this.field)); + } + def.fields = fields; + this.removed = { field, values }; + } + + undo(project: Project): void { + if (!this.removed) return; + const def = requireStruct(project, this.assetId, this.key); + const fields = [...def.fields]; + restoreAt(fields, [this.removed.field]); + def.fields = fields; + for (const site of structSites(project, this.assetId, this.key)) { + const value = this.removed.values.get(site.id); + if (value !== undefined) site.write({ ...site.read(), [this.field]: value }); + } + } +} + +export class RenameStructFieldCommand implements Command { + readonly label: string; + + constructor( + private readonly assetId: string, + private readonly key: string, + private readonly from: string, + private readonly to: string, + ) { + this.label = `Rename field ${from}`; + } + + execute(project: Project): void { + this.rename(project, this.from, this.to); + } + + undo(project: Project): void { + this.rename(project, this.to, this.from); + } + + private rename(project: Project, from: string, to: string): void { + const def = requireStruct(project, this.assetId, this.key); + if (from === to) return; + if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD); + if (!def.fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`); + if (def.fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`); + def.fields = def.fields.map((f) => (f.key === from ? { ...f, key: to } : f)); + for (const site of structSites(project, this.assetId, this.key)) + site.write(renameField(site.read(), from, to)); + } } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 42f8f3e4..6666a1bf 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -3,9 +3,11 @@ import { type AssetType, isPlaceableType } from '../model/AssetTypes'; import { type FieldValue, isList, + isStruct, type ListValue, type PropertyBag, type PropertyValue, + type StructValue, textOf, } from '../model/Properties'; @@ -47,14 +49,27 @@ export type PropertyDef = * list, so one `list` port fits them all. There is no `object` item kind: a list is stored in the * project file, and an object is a live handle that no reload survives. */ - | { key: string; label: string; kind: 'list'; default: ListValue; items: ListItemKind }; + | { key: string; label: string; kind: 'list'; default: ListValue; items: ListItemKind } + /** + * `fields` is the declaration; the value carries exactly those keys, which is what lets a struct + * keep its shape while single fields are written (`mergeStruct`). + */ + | { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] }; export const LIST_ITEM_KINDS = ['number', 'text', 'boolean', 'color'] as const; export type ListItemKind = (typeof LIST_ITEM_KINDS)[number]; +/** A field holds what a list item holds: a field that were itself a struct is what `FieldValue` rules out. */ +export interface StructFieldDef { + key: string; + kind: ListItemKind; +} + +export type StructDef = Extract; + export type PropertySchema = readonly PropertyDef[]; -export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list'] as const; +export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list', 'struct'] as const; export type VariableKind = (typeof VARIABLE_KINDS)[number]; /** @@ -176,6 +191,7 @@ export function variableDef( kind: VariableKind, defaultValue?: PropertyValue, items?: ListItemKind, + fields?: readonly StructFieldDef[], ): PropertyDef { switch (kind) { case 'number': @@ -192,11 +208,40 @@ export function variableDef( default: isList(defaultValue) ? defaultValue : [], items: items ?? 'number', }; + case 'struct': { + const declared = fields ?? []; + return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) }; + } } } +/** The empty value of a kind, so a field that is not there yet still has one. */ +const EMPTY_FIELD: Record = { + number: 0, + text: '', + boolean: false, + color: 0, +}; + +export function emptyField(kind: ListItemKind): number | string | boolean { + return EMPTY_FIELD[kind]; +} + +/** + * A struct over exactly the declared fields: a declared field missing from `from` starts empty + * rather than absent, so a struct value always has the shape its declaration promises. + */ +export function structOf(fields: readonly StructFieldDef[], from: PropertyValue | undefined): StructValue { + const source = isStruct(from) ? from : {}; + const value: Record = {}; + for (const field of fields) + value[field.key] = + field.key in source ? coerceListItem(source[field.key]!, field.kind) : emptyField(field.kind); + return value; +} + /** Clamps / coerces a raw value so it satisfies the definition. */ -export function coerceValue(def: PropertyDef, value: PropertyValue): FieldValue { +export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue { switch (def.kind) { case 'number': { let n = typeof value === 'number' ? value : Number(value); @@ -222,6 +267,8 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): FieldValue } case 'list': return isList(value) ? value.map((item) => coerceListItem(item, def.items)) : def.default; + case 'struct': + return structOf(def.fields, value); } } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 6ed484d8..dc523259 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -27,6 +27,7 @@ import { type PropertyDef, type PropertySchema, schemaFor, + type StructFieldDef, VARIABLE_KINDS, variableDef, type VariableKind, @@ -176,13 +177,28 @@ function normalizeVariables(raw: unknown): PropertyDef[] { const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items)) ? (v.items as ListItemKind) : undefined; - const def = variableDef(key, kind, undefined, items); + const def = variableDef(key, kind, undefined, items, normalizeStructFields(v.fields)); def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue); defs.push(def); } return defs; } +/** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */ +function normalizeStructFields(raw: unknown): StructFieldDef[] { + const fields: StructFieldDef[] = []; + const seen = new Set(); + for (const item of Array.isArray(raw) ? raw : []) { + const f = asRecord(item); + const key = typeof f.key === 'string' ? f.key : ''; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue; + if (!(LIST_ITEM_KINDS as readonly string[]).includes(String(f.kind))) continue; + seen.add(key); + fields.push({ key, kind: f.kind as ListItemKind }); + } + return fields; +} + function normalizePorts(raw: unknown): DeclaredPort[] { const ports: DeclaredPort[] = []; const seen = new Set(); diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 7848d26f..19452e07 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -3,17 +3,24 @@ import { ResetAssetPropertyCommand, SetAssetPropertyCommand, } from '../../core/commands/assetCommands'; -import { nextVariableName } from '../../core/commands/naming'; +import { nextFieldName, nextVariableName } from '../../core/commands/naming'; import { + AddStructFieldCommand, AddVariableCommand, + RemoveStructFieldCommand, RemoveVariableCommand, + RenameStructFieldCommand, RenameVariableCommand, } from '../../core/commands/variableCommands'; import type { Asset, BehaviorAsset } from '../../core/model/Asset'; +import type { PropertyValue } from '../../core/model/Properties'; import { findAsset, type Project } from '../../core/model/Project'; import { + LIST_ITEM_KINDS, + type ListItemKind, type PropertyDef, schemaForAsset, + type StructDef, VARIABLE_KINDS, type VariableKind, } from '../../core/properties/PropertySchema'; @@ -21,15 +28,29 @@ import { markedAssetSchema } from '../../core/properties/deviation'; import { isModified, resolveAssetProps } from '../../core/properties/resolve'; import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; import { aspectActions, sizeCommand } from './aspectAction'; -import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; +import { + createField, + createFieldRow, + type FieldHandlers, + labelledRow, + primitiveDef, + structFieldValue, +} from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; -import { listOpener, previewingHandlers } from './previewHandlers'; +import { + listOpener, + type PreviewedValue, + previewingHandlers, + structFields, + structFieldsOf, +} from './previewHandlers'; const VARIABLE_KIND_LABELS: Record = { number: 'Number', boolean: 'Boolean', text: 'Text', list: 'List', + struct: 'Struct', }; // Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the // VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in. @@ -38,16 +59,37 @@ const VARIABLE_KIND_MARKS: Partial> = { boolean: '✓', text: 'Aa', list: '≡', + struct: '⊞', }; -/** Structure-defining facts of an asset selection: the schema keys and kinds. */ +const FIELD_KIND_LABELS: Record = { + number: 'Number', + boolean: 'Boolean', + text: 'Text', + color: 'Color', +}; + +const FIELD_INDENT = 12; + +const FIELD_KIND_MARKS: Record = { + number: '#', + boolean: '✓', + text: 'Aa', + color: '◧', +}; + +/** + * Structure-defining facts of an asset selection: the schema keys and kinds, plus the fields of a + * struct – those are rows of their own, so adding one has to rebuild the section. + */ export function assetSignature(project: Project, assetId: string): string { const asset = findAsset(project, assetId); - return asset - ? schemaForAsset(asset) - .map((d) => `${d.key}:${d.kind}`) - .join(',') - : 'missing'; + return asset ? schemaForAsset(asset).map(defSignature).join(',') : 'missing'; +} + +export function defSignature(def: PropertyDef): string { + const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : ''; + return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`; } /** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */ @@ -122,17 +164,24 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property } } +/** Where one property of an asset lives. Shared, so a whole row and a single struct field agree on it. */ +function assetSpec(host: InspectorHost, assetId: string, key: string): PreviewedValue { + return { + target: () => findAsset(host.state.project, assetId), + read: (asset) => asset.props[key], + write: (asset, value) => (asset.props[key] = value), + // No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own. + commit: (value) => new SetAssetPropertyCommand(assetId, key, value), + }; +} + /** Previews straight into the asset while editing; the command records the value from before the preview. */ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers { + const spec = assetSpec(host, assetId, def.key); return { - ...previewingHandlers(host, { - target: () => findAsset(host.state.project, assetId), - read: (asset) => asset.props[def.key], - write: (asset, value) => (asset.props[def.key] = value), - // No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own. - commit: (value) => new SetAssetPropertyCommand(assetId, def.key, value), - }), + ...previewingHandlers(host, spec), ...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })), + ...structFields(host, def, spec), }; } @@ -158,6 +207,10 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { if (asset.variables.length === 0) addNote(host, 'No variables yet.'); const values = resolveAssetProps(asset); for (const def of asset.variables) { + if (def.kind === 'struct') { + buildStructVariable(host, asset, def, values[def.key] ?? def.default); + continue; + } const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' }); @@ -185,3 +238,92 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { host.body.add(row); } } + +/** + * A struct variable is a block rather than a row: the head renames it and adds fields, each field + * below carries its own name, its value and a remove. That is the port editor `Pack` already has, + * one level down and with a value column – a field is declared and given a default in one place. + */ +function buildStructVariable( + host: InspectorHost, + asset: BehaviorAsset, + def: StructDef, + value: PropertyValue, +): void { + const head = new HStack({ gap: Theme.gap, align: 'center' }); + head.minHeight = Theme.rowHeight; + const mark = new Label(VARIABLE_KIND_MARKS.struct ?? '?', { style: 'small', align: 'center' }); + mark.minWidth = 18; + head.add(mark); + head.add( + new TextField(def.key, { + width: 90, + onChange: (next) => host.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim())), + }), + ); + head.add(new Spacer(), 1); + const add = new Dropdown('+ Field', { variant: 'ghost' }); + add.onClick = () => { + const items = LIST_ITEM_KINDS.map((kind) => ({ + label: FIELD_KIND_LABELS[kind], + onSelect: () => + host.state.execute( + new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields), kind }), + ), + })); + new Menu(items).openBelow(add); + }; + head.add(add); + head.add( + new Button('×', { + variant: 'ghost', + width: Theme.controlHeight, + onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)), + }), + ); + host.body.add(head); + + if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); + const perField = structFieldsOf(host, def, assetSpec(host, asset.id, def.key)); + const fields = new Map void>(); + for (const field of def.fields) { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + row.add(new Spacer(FIELD_INDENT)); + const fieldMark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' }); + fieldMark.minWidth = 18; + row.add(fieldMark); + row.add( + new TextField(field.key, { + width: 90, + onChange: (next) => + host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())), + }), + ); + const item = createField( + host, + primitiveDef(field.key, field.kind), + structFieldValue(value, field), + perField(field), + ); + row.add(item.widget, item.grow ? 1 : 0); + if (!item.grow) row.add(new Spacer(), 1); + row.add( + new Button('×', { + variant: 'ghost', + width: Theme.controlHeight, + onClick: () => host.state.execute(new RemoveStructFieldCommand(asset.id, def.key, field.key)), + }), + ); + fields.set(field.key, item.setValue); + host.body.add(row); + } + + host.fields.set(def.key, { + row: head, + setValue: (next) => { + for (const field of def.fields) fields.get(field.key)?.(structFieldValue(next, field)); + }, + setModified: () => {}, + }); +} diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index ed854754..98776b99 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -1,4 +1,4 @@ -import { isList, type PropertyValue, textOf } from '../../core/model/Properties'; +import { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; import { assetChoiceLabel, @@ -6,7 +6,12 @@ import { assetRefLabel, NO_ASSET_LABEL, } from '../../core/properties/assetRef'; -import type { PropertyDef } from '../../core/properties/PropertySchema'; +import { + emptyField, + type ListItemKind, + type PropertyDef, + type StructFieldDef, +} from '../../core/properties/PropertySchema'; import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef'; import { Button, @@ -22,6 +27,7 @@ import { TextField, Theme, ui, + VStack, type Widget, } from '../../ui'; @@ -41,6 +47,12 @@ export interface FieldHandlers { onChange: (value: PropertyValue) => void; /** Sends a list to the List tab; only the caller knows where the value it edits lives. */ openList?: () => void; + /** + * Handlers for one field of a struct. Writing a field means writing the whole bundle, so only + * the caller – which knows where that bundle lives – can build them. Without them a struct row + * can only show what it holds. + */ + structFieldHandlers?: (field: StructFieldDef) => FieldHandlers; } export interface FieldRow { @@ -169,6 +181,27 @@ interface Field { setValue(value: PropertyValue): void; } +/** A primitive as a property definition, so one widget set serves a list item and a struct field. */ +export function primitiveDef(key: string, kind: ListItemKind, label = key): PrimitiveDef { + switch (kind) { + case 'number': + return { key, label, kind: 'number', default: 0 }; + case 'text': + return { key, label, kind: 'text', default: '' }; + case 'boolean': + return { key, label, kind: 'boolean', default: false }; + case 'color': + return { key, label, kind: 'color', default: 0 }; + } +} + +export type PrimitiveDef = Extract; + +/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */ +export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue { + return (isStruct(value) ? value[field.key] : undefined) ?? emptyField(field.kind); +} + /** The bare widget for a property definition (no label, no reset). */ export function createField( ctx: FieldContext, @@ -203,6 +236,37 @@ export function createField( const button = new Button(count(value), { variant: 'ghost', onClick: handlers.openList }); return { widget: button, grow: true, setValue: (v) => button.setText(count(v)) }; } + case 'struct': { + // A bundle has few fields, so unlike a list they fit under each other right here. Editing + // one writes the whole bundle, which is why the handlers come per field from the caller. + const per = handlers.structFieldHandlers; + if (!per) { + const summary = new Label(textOf(value), { style: 'muted' }); + return { widget: summary, grow: true, setValue: (v) => summary.setText(textOf(v)) }; + } + const stack = new VStack({ gap: Theme.gap }); + const fields = new Map(); + for (const field of def.fields) { + const { row } = labelledRow(field.key); + const sub = createField( + ctx, + primitiveDef(field.key, field.kind), + structFieldValue(value, field), + per(field), + ); + row.add(sub.widget, sub.grow ? 1 : 0); + if (!sub.grow) row.add(new Spacer(), 1); + fields.set(field.key, sub); + stack.add(row); + } + return { + widget: stack, + grow: true, + setValue: (v) => { + for (const field of def.fields) fields.get(field.key)?.setValue(structFieldValue(v, field)); + }, + }; + } case 'boolean': { const field = new Checkbox(Boolean(value)); field.onChange = handlers.onChange; diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index a1564ce7..d543dda1 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -40,10 +40,11 @@ import { import { Button, TextField } from '../../ui'; import { addCollider, createContainer, removeObjects } from '../actions'; import { aspectActions, sizeCommand } from './aspectAction'; +import { defSignature } from './assetSection'; import { createFieldRow, type FieldRow, labelledRow } from './fieldFactory'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { parentRow } from './parentRow'; -import { listOpener, previewingHandlers } from './previewHandlers'; +import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; @@ -65,12 +66,7 @@ export function objectSignature( : isLiveInstance(project, holder, object) ? 'instance' : ''; - return ( - `${object.parentId}|${kind}|` + - schemaForAsset(asset) - .map((d) => `${d.key}:${d.kind}`) - .join(',') - ); + return `${object.parentId}|${kind}|` + schemaForAsset(asset).map(defSignature).join(','); } /** Several objects: what they are, and the actions that apply to all of them at once. */ @@ -310,19 +306,21 @@ function propertyRow( values: Record, ): FieldRow { const bases = () => deviations(host.state, holderId, objectId, def.key); + const overrideSpec: PreviewedValue = { + target: () => currentRef(host.state, holderId, objectId), + read: (ref) => ref.object.overrides[def.key], + write: (ref, value) => ref.setOverride(def.key, value), + remove: (ref) => ref.setOverride(def.key, undefined), + commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value), + }; const row = createFieldRow( host, def, values[def.key] ?? def.default, { - ...previewingHandlers(host, { - target: () => currentRef(host.state, holderId, objectId), - read: (ref) => ref.object.overrides[def.key], - write: (ref, value) => ref.setOverride(def.key, value), - remove: (ref) => ref.setOverride(def.key, undefined), - commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value), - }), + ...previewingHandlers(host, overrideSpec), ...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), + ...structFields(host, def, overrideSpec), }, { extra: aspectActions( diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts index 261be5a9..9d81ae88 100644 --- a/src/editor/inspector/previewHandlers.ts +++ b/src/editor/inspector/previewHandlers.ts @@ -1,4 +1,10 @@ -import type { PropertyDef } from '../../core/properties/PropertySchema'; +import { + coerceListItem, + type PropertyDef, + type StructDef, + type StructFieldDef, + structOf, +} from '../../core/properties/PropertySchema'; import type { Command } from '../../core/commands/Command'; import type { PropertyValue } from '../../core/model/Properties'; import type { EditorState, ListSlot } from '../state'; @@ -16,6 +22,34 @@ export function listOpener( return def.kind === 'list' ? { openList: () => state.openList(slot()) } : {}; } +/** + * The `structFieldHandlers` half of a row's handlers. Each field writes the whole bundle, so it + * reads the bundle at that moment rather than from a snapshot: sharing one would make the second + * field edited undo the first. + */ +export function structFields( + host: { state: EditorState }, + def: PropertyDef, + spec: PreviewedValue, +): Pick { + return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec) } : {}; +} + +export function structFieldsOf( + host: { state: EditorState }, + def: StructDef, + spec: PreviewedValue, +): (field: StructFieldDef) => FieldHandlers { + return (field) => + previewingHandlers(host, { + ...spec, + toModel: (input, target) => ({ + ...structOf(def.fields, spec.read(target)), + [field.key]: coerceListItem(input, field.kind), + }), + }); +} + /** Where an inspector row finds the value it edits, and what committing it means. */ export interface PreviewedValue { /** The model object holding the value, or undefined when the selection is gone – then the row does nothing. */ diff --git a/src/editor/list/ListEditor.ts b/src/editor/list/ListEditor.ts index 932880de..2976569c 100644 --- a/src/editor/list/ListEditor.ts +++ b/src/editor/list/ListEditor.ts @@ -3,16 +3,13 @@ * can be hundreds long has no room in an inspector row. The inspector stays visible beside this * tab, so what the list belongs to is still on screen while it is edited. */ -import type { ListItemKind, PropertyDef } from '../../core/properties/PropertySchema'; import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui'; -import { createField, type FieldContext } from '../inspector/fieldFactory'; +import { createField, type FieldContext, primitiveDef } from '../inspector/fieldFactory'; import type { EditorState } from '../state'; import { type ListAccess, resolveList } from './slot'; const INDEX_WIDTH = 40; -type ItemDef = Extract; - export class ListEditor extends VStack { private readonly ctx: FieldContext; private readonly title = new Label('', { style: 'bodyBold' }); @@ -118,7 +115,12 @@ export class ListEditor extends VStack { const label = new Label(String(index), { style: 'small', align: 'right' }); label.minWidth = INDEX_WIDTH; row.add(label); - const field = createField(this.ctx, itemDef(access.def.items, index), item, access.itemHandlers(index)); + const field = createField( + this.ctx, + primitiveDef(String(index), access.def.items), + item, + access.itemHandlers(index), + ); row.add(field.widget, field.grow ? 1 : 0); if (!field.grow) row.add(new Spacer(), 1); row.add( @@ -135,21 +137,6 @@ export class ListEditor extends VStack { private addItem(): void { const access = this.access(); - if (access) access.replace([...access.value, itemDef(access.def.items, 0).default]); - } -} - -/** One item as a property definition, so the inspector's own widget renders it. */ -function itemDef(items: ListItemKind, index: number): ItemDef { - const key = String(index); - switch (items) { - case 'number': - return { key, label: key, kind: 'number', default: 0 }; - case 'text': - return { key, label: key, kind: 'text', default: '' }; - case 'boolean': - return { key, label: key, kind: 'boolean', default: false }; - case 'color': - return { key, label: key, kind: 'color', default: 0 }; + if (access) access.replace([...access.value, primitiveDef('0', access.def.items).default]); } } diff --git a/tests/core/struct-variable.test.ts b/tests/core/struct-variable.test.ts new file mode 100644 index 00000000..3c693253 --- /dev/null +++ b/tests/core/struct-variable.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { + AddStructFieldCommand, + AddVariableCommand, + RemoveStructFieldCommand, + RenameStructFieldCommand, +} from '../../src/core/commands/variableCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { findAsset, objectHolders, type Project } from '../../src/core/model/Project'; +import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema'; +import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; + +const findObject = (project: Project, id: string) => + objectHolders(project) + .flatMap((holder) => holder.objects) + .find((o) => o.id === id); + +const structDef = (project: Project, assetId: string, key: string) => { + const asset = findAsset(project, assetId); + const def = asset?.type === 'behavior' ? asset.variables.find((v) => v.key === key) : undefined; + return def?.kind === 'struct' ? def : undefined; +}; + +/** A behavior with a struct variable `piece`, placed once with an override of its own. */ +function withStruct() { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B'); + f.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct')); + f.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key: 'x', kind: 'number' })); + const object = place(f, behavior.id, { x: 0, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'piece', { x: 7 })); + return { f, behaviorId: behavior.id, object }; +} + +describe('a variable of type struct', () => { + it('starts with no fields at all, so nothing is invented for it', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B'); + f.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct')); + expect(structDef(f.project, behavior.id, 'piece')?.fields).toEqual([]); + expect(findAsset(f.project, behavior.id)?.props.piece).toEqual({}); + }); + + it('grows a new field in the declaration, in its own value and in every override at once', () => { + const { f, behaviorId, object } = withStruct(); + f.stack.execute(new AddStructFieldCommand(behaviorId, 'piece', { key: 'name', kind: 'text' })); + + expect(structDef(f.project, behaviorId, 'piece')?.fields).toEqual([ + { key: 'x', kind: 'number' }, + { key: 'name', kind: 'text' }, + ]); + // The override deviates in `x` and must keep that while gaining the new field. + expect(findObject(f.project, object)?.overrides.piece).toEqual({ x: 7, name: '' }); + expect(findAsset(f.project, behaviorId)?.props.piece).toEqual({ x: 0, name: '' }); + }); + + it('takes the field back out on undo, so no value keeps a shape the declaration dropped', () => { + const { f, behaviorId, object } = withStruct(); + f.stack.execute(new AddStructFieldCommand(behaviorId, 'piece', { key: 'name', kind: 'text' })); + f.stack.undo(); + + expect(structDef(f.project, behaviorId, 'piece')?.fields).toEqual([{ key: 'x', kind: 'number' }]); + expect(findAsset(f.project, behaviorId)?.props.piece).toEqual({ x: 0 }); + expect(findObject(f.project, object)?.overrides.piece).toEqual({ x: 7 }); + }); + + it('gives an override its own removed value back, not the default', () => { + const { f, behaviorId, object } = withStruct(); + f.stack.execute(new RemoveStructFieldCommand(behaviorId, 'piece', 'x')); + expect(findObject(f.project, object)?.overrides.piece).toEqual({}); + + f.stack.undo(); + // 7 is the override's own value; the behavior's default for the same field is 0. + expect(findObject(f.project, object)?.overrides.piece).toEqual({ x: 7 }); + expect(findAsset(f.project, behaviorId)?.props.piece).toEqual({ x: 0 }); + }); + + it('carries the value along when a field is renamed, and back again on undo', () => { + const { f, behaviorId, object } = withStruct(); + f.stack.execute(new RenameStructFieldCommand(behaviorId, 'piece', 'x', 'column')); + + expect(structDef(f.project, behaviorId, 'piece')?.fields).toEqual([{ key: 'column', kind: 'number' }]); + expect(findObject(f.project, object)?.overrides.piece).toEqual({ column: 7 }); + + f.stack.undo(); + expect(findObject(f.project, object)?.overrides.piece).toEqual({ x: 7 }); + }); + + it('refuses a second field of the same name', () => { + const { f, behaviorId } = withStruct(); + expect(() => + f.stack.execute(new AddStructFieldCommand(behaviorId, 'piece', { key: 'x', kind: 'text' })), + ).toThrow(/already exists/); + }); + + it('survives saving and loading with its declaration, not just its value', () => { + const { f, behaviorId, object } = withStruct(); + f.stack.execute(new AddStructFieldCommand(behaviorId, 'piece', { key: 'name', kind: 'text' })); + + const loaded = roundTrip(f.project); + expect(structDef(loaded, behaviorId, 'piece')?.fields).toEqual([ + { key: 'x', kind: 'number' }, + { key: 'name', kind: 'text' }, + ]); + expect(findObject(loaded, object)?.overrides.piece).toEqual({ x: 7, name: '' }); + }); + + it('coerces a stored value to the shape its declaration promises', () => { + const def = variableDef('piece', 'struct', undefined, undefined, [ + { key: 'x', kind: 'number' }, + { key: 'on', kind: 'boolean' }, + ]); + // An unknown key is dropped, a missing one starts empty, and '3' is a number here. + expect(coerceValue(def, { x: '3', gone: 9 })).toEqual({ x: 3, on: false }); + }); +}); diff --git a/todo-notes/done/nodes-mit-dynamischer-port-zahl.md b/todo-notes/done/nodes-mit-dynamischer-port-zahl.md new file mode 100644 index 00000000..c8c995ae --- /dev/null +++ b/todo-notes/done/nodes-mit-dynamischer-port-zahl.md @@ -0,0 +1,361 @@ +## Dynamische Portzahl bei Nodes ermöglichen + +Wenn das möglich ist, umsetzen. Folgende Dinge wären dafür praktisch: + +### 1. sequence node + +diese hat momentan fixe 3 outputs, diese könnten somit ebenfalls dynamisch sein + +### 2. eine variable vom typ struct + +sprich eine variable die mehreren properties (key mit allen bisher möglichen variablen typen + value). +dazu passende nodes um von einer variable alle properties zu expanden und umgekehrt (alle werte eines structs setzen) + +## variablen vom typ struct und list im inspector + +bei typ list: momentan steht es einfach nur als string geparsed in einem textfeld. +für die liste bräuchten wir eine view, sprich liste mit variablen. + +für typ struct: hier wäre natürlich auch eine passende view gut, wo man bei der variable type + key + default value angeben kann. + +--- + +lass uns das mal ausarbeiten. wie weit das sinnvoll bzw. möglich ist. + +--- + +**Umgesetzt (2026-09-10), Teil 1 und der Werttyp. Was offen bleibt, steht unten.** + +**Dynamische Ports gab es schon** — nur nicht aus Node-Einstellungen. Verschachtelte Behaviors +leben seit jeher davon: `GraphNode.ports` speichert eine Deklaration **pro Node**, `resolveDef` +loest den Def pro Node auf, `portSignature` ist der Ausloeser, an dem View und Inspector merken, +dass sich eine abgeleitete Portliste geaendert hat. Die Regel dafuer stand als Dreier-Switch auf +Node-Typen in `nested.ts` — die einzige Stelle im Projekt, die Node-Typen namentlich kannte. + +Der Switch ist jetzt **aufgeloest** in zwei Vertraege am `NodeDef`: + +- `declaresPorts: 'inputs' | 'outputs'` — der Node traegt seine eigene Portliste, editierbar im + Inspector. Das haben `Start`, `End` und neu `Pack`/`Expand`. +- `derivePorts(source)` — der Node *rechnet* seine Ports aus sich und dem Graphen aus, in dem er + sitzt. Das haben `Sequence` (aus den eigenen Kanten) und `Run behavior` (Ports des benannten + Behaviors). + +**Sequence** hatte zuerst eine Einstellung *Outputs* (2…26). Die ist **wieder weg** — siehe den +Nachtrag unten: die Ports wachsen jetzt mit den Kanten. Die Keys bleiben `a`, `b`, `c`, … statt +`out1..N`, weil im Jump-and-Run zwei Nodes an einer Sequence haengen und deren Kanten heil bleiben +sollen. + +**Die einzige echte Falle war das Schrumpfen.** `SetNodeParamCommand` war ein reiner Bag-Schreiber: +verschwand ein Port, blieb seine Kante liegen — unsichtbar (`drawEdge` steigt bei unbekanntem Port +still aus), aber sie ueberlebte jedes Speichern und waere beim Hochzaehlen wieder angesprungen. +Der Befehl ist jetzt port-bewusst und nimmt solche Kanten mit, Undo gibt sie zurueck. Das schliesst +**einen bestehenden Fehler mit**: derselbe Verwaisungs-Effekt trat schon immer auf, wenn man an +einem `Run behavior`-Node das Behavior wechselte. + +**Struct als echter Werttyp (Weg C).** `PropertyValue` ist jetzt +`number | string | boolean | ListValue | StructValue`. Felder sind Primitive oder Listen, **nie** +wieder ein Struct — aus demselben Grund, aus dem eine Liste flach ist. + +Zwei Dinge waren dafuer noetig, und beide sind der eigentliche Preis von C: + +1. **Ein Objekt auf einer Datenkante hiess bisher zwangslaeufig Entity.** An drei Stellen in + `values.ts` bedeutete „Objekt, kein Array" ein Entity-Handle. `EntityHandle` traegt jetzt eine + **Symbol-Marke** (`ENTITY_HANDLE`) — ein Symbol, damit kein Struct-Feldname sie imitieren kann, + und es serialisiert nie, was fuer etwas richtig ist, das nur waehrend des Spiels existiert. + Der Typechecker hat jede betroffene Stelle selbst benannt. +2. **`String(value)` haette einen Struct als `[object Object]` geschrieben** — an **39** Stellen. + Die gehen jetzt alle ueber `textOf()`. Das war kein Stilfehler, sondern eine echte Luecke, die + erst der neue Typ sichtbar gemacht hat. + +Dazu ein **gemessener, lebender Fehler**, der dabei aufflog und mitrepariert ist: der `List`-Node +verlor seinen Wert beim Speichern. `normalizeGraph` liess bei Node-Params nur Primitive durch, +Arrays fielen weg (`[3,1,4]` → `undefined` nach Round-Trip). Listen-*Variablen* waren nicht +betroffen — die haben ein Schema. Params haben keins, weil sie Einstellungen und Inline-Werte +mischen. + +**Belegt** (`tests/core/dynamic-ports.test.ts`): ein Bündel faehrt ueber **eine** Kante durch ein +verschachteltes Behavior — `Pack{x:30,y:12}` → `Run behavior`-Port vom Typ `struct` → `Expand` → +`Add` → 42. Genau die Form, die Tetris heute von Hand parallel fuehrt (`Next move`: 8 rein, +7 raus; 49 deklarierte Ports ueber 9 Behaviors). + +**Offen, bewusst nicht gebaut:** +- **Variablen vom Typ struct** (Teil 2 der Notiz). Dafuer braucht eine Variable eine editierbare + Feldliste im Inspector — die Deklaration liegt bisher nur am Node (`Pack`/`Expand`) und am + Behavior-Port. `writeTyped`/`mergeStruct` sind schon darauf vorbereitet: ein Struct-Slot behaelt + seine Felder, was ankommt fuellt die benannten, der Rest bleibt stehen. +- **Die Views im Inspector** (Teil 3). *[Die Listen-Haelfte ist inzwischen gebaut — siehe den + Nachtrag „der List-Tab steht". Der Struct-Teil ist weiter offen.]* Das Textfeld war nicht nur + Kosmetik: `['red, green', 'blue']` wurde als `"red, green, blue"` geschrieben und las sich als + **drei** Items zurueck — ein Komma in einem Text-Item war nicht darstellbar. Fuer den Struct ist + Zeile-pro-Feld richtig, das sind wenige. +- Ein unverbundener `list`-Eingang zeigt weiterhin ein **Zahlenfeld** (`inlineInputDef` hat keinen + `list`-Zweig), und eine Listen-Variable kann nur Zahlen-Items: `AddVariableCommand` nimmt `items` + gar nicht entgegen. + +--- + +EDIT: + +> Eine Liste braucht eher einen **eigenen Editor als Overlay** + +ok, eigener tab "List" + +dann machen wir es so: es gibt dann einfach statt dem textfeld ein button wo drinnen steht "42 items". +klickt man darauf switch zu Tab "List" und dort wird die liste aller items dargestellt die man bearbeiten kann. + +dort soll auch der selbe "renderer" verwendet werden wie beim inspector. + +wenn wir dann struct (teil 3) umsetzen, kann es auch eine liste aus struct variablen geben (bitte das bedenken) und +im tab "List" sollen variablen (egal welcher type) im prinzip genauso dargestellt werden wie im inspector + +--- + +## Antwort auf den Edit (2026-09-10): geht, mit drei Auflagen + +**Der Tab ist hier besser als das Overlay**, aus einem Grund, der erst beim Nachsehen auffiel: der +Inspector haengt **nicht** im Tab-Bereich, sondern als eigene Spalte daneben (`Editor.ts:156`). Er +bleibt in jedem Tab sichtbar, der Sprung verliert also keinen Kontext. + +**„Derselbe Renderer" ist fast geschenkt.** `createField` (`fieldFactory.ts:171`) braucht als +Kontext nur `{ project(): Project }` — keine Inspector-Instanz, keinen Command-Stack. Die Zeile +baut selbst kein Kommando, sie ruft `onInput`/`onChange`. `previewingHandlers` benutzt vom +`InspectorHost` ausschliesslich `host.state`; den Parametertyp zu verengen macht auch die +Undo-Mechanik wiederverwendbar. Ein Struct kommt spaeter an *einer* Stelle dazu: dem +`switch (def.kind)` in `fieldFactory.ts:177`. + +**Der Weg in den Tab muss ueber den Zustand laufen, nicht ueber `showTab`.** Kein Tab kennt den +`Editor`; Tab-Wechsel von innen gibt es nur selektionsgetrieben, gebuendelt in `Editor.ts:169-179`. + +### Woran der Tab haengt + +Die anderen Tabs haengen an einer Asset-Id. Eine Liste ist kein Asset, sondern ein *Slot*: +Asset-Property (`assetId+key`), Objekt-Override (`holderId+objectId+key`) oder Node-Param +(`assetId+nodeId+key`). Also eine explizite Adresse **mit Pfad**, die der Tab behaelt wie die +anderen ihre Asset-Id und die auf „nichts" zurueckfaellt, wenn das Ziel verschwindet +(`AssetTabEditor.refresh()` macht genau das). Der Drill-down spaeter ist derselbe Mechanismus +eine Ebene tiefer. + +### Zeile-pro-Item traegt — gemessen + +Zeilen echt ueber `createFieldRow` gebaut, mit GPU im Browser, dabei **jeden Frame gescrollt**: + +| Zeilen | Aufbau | Display-Objekte | fps | +|---|---|---|---| +| 0 (Grundlinie) | – | 11 | 58 | +| 200 | 15 ms | 2 011 | 51 | +| 300 | 17 ms | 3 011 | 46 | +| 3 000 | 122 ms | 30 011 | 18 | + +Dagegen die laengsten Listen im echten Bestand: **tetris 224** (`shapes`), **224** (eine +Variablen-Vorgabe), **200** (`grid`); jumpRun und fixture haben keine. Der reale Hoechstfall liegt +also bei 224 — **kein Windowing noetig**. Bei 3 000 Eintraegen faellt es auseinander, aber nichts +kommt dem nahe. + +Der eigentliche Fund steckt in der Aufbau-Spalte: 15 ms pro Rebuild bei 200 Zeilen. Deshalb kam +der Fokus-Schutz zuerst (siehe Nachtrag oben) — ein Rebuild pro Tastendruck waere dort die Regel. + +### Was „Liste aus Structs" kostet + +Gibt es heute **nicht**: `ListValue = readonly (number | string | boolean)[]` (`Properties.ts:5`), +`FieldValue` schliesst Struct aus (`:8`), `sameValue` verlaesst sich auf „recurses exactly one level +deep" (`:50`), `LIST_ITEM_KINDS` kennt kein `struct`, `coerceListItem` gibt ein Primitiv zurueck. +Das macht `ListValue` und `StructValue` **gegenseitig rekursiv** (beliebige Tiefe, echter +Deep-Compare) und die Items brauchen eine deklarierte Feldform — wieder „eine geteilte Struct-Form +hat keine Heimat" (~29 Dateien fuer einen neuen Asset-Typ). Deshalb: Tab jetzt fuer flache Listen, +aber um einen Pfad herum; die Typerweiterung kommt mit den Struct-Variablen. + +Beibehalten: eine **Liste ist homogen** (ein `ListItemKind`), der **Struct** ist das heterogene +Ding. Das Textfeld sollte als Massenbearbeitung im Tab bleiben, sonst geht das Einfuegen einer +Komma-Liste verloren. Der unverbundene `list`-Eingang (heute faelschlich ein Zahlenfeld) loest +sich mit dem Knopf mit. + +--- + +## Nachtrag (2026-09-10): Sequence waechst mit den Kanten, statt gezaehlt zu werden + +Das Zahlenfeld *Outputs* war **unbedienbar**: tippen ging nicht, Pfeil rauf/runter ging nicht, nur +das Ziehen mit der Maus. Die Ursache ist allgemein und nicht `NumberField` zuzuschreiben: + +`onInput` (jeder Tastendruck, auch jede Pfeiltaste) → `previewingHandlers` schreibt in +`node.params` → `state.preview()` → Event `project` → `Inspector.refreshValues()` → die +`nodeSignature` enthaelt die Portliste, hat sich also geaendert → **`rebuild()` wirft die Zeile +weg, in der gerade getippt wird.** Ziehen ueberlebt das, weil ein laufender Drag seine +`globalpointermove`-Events weiter am alten (zerstoerten) Widget bekommt; Tippen braucht den Fokus, +und den gibt es nach dem Rebuild nicht mehr. + +**Diese Falle ist inzwischen zu** (siehe „List-Tab" unten): `refreshValues()` verschiebt einen +faelligen Rebuild, solange der Fokus im Inspector sitzt, und holt ihn nach, sobald das Feld +verlassen wird (`FocusManager.changeHandlers` + `isWithin`). Werte sind waehrenddessen ohnehin +sicher — `TextField.setValue` ignoriert eine laufende Eingabe (`TextField.ts:92`). Damit war der +Fehler auch nie `NumberField` zuzuschreiben. + +Statt das Feld zu reparieren, ist es nach Wunsch des Users **ersetzt**: die Sequence haelt immer +**einen freien Ausgang unter dem letzten verbundenen**. Nichts verbunden = 1 Ausgang; den +Ersatz-Ausgang verbinden = ein neuer erscheint; die unterste Kante loesen = schrumpft wieder. +Gezaehlt wird der letzte *verbundene* Port, nicht die Anzahl der Kanten — so kann keine Kante +verschwinden, wenn in der Mitte eine geloest wird (dann bleibt eine Luecke stehen). + +Preis dafuer: `derivePorts` bekommt jetzt den **Graphen** statt nur der Params +(`resolveDef(node, graph, lookup)`, ~15 Aufrufstellen, alle hatten den Graphen schon zur Hand), +und `run` bekommt als viertes Argument den **aufgeloesten Def** — anders kann ein Node mit +abgeleiteten Ports nicht wissen, wie viele er hat. `SetNodeParamCommand` bleibt port-bewusst, das +braucht `Run behavior` weiterhin. + +Nebeneffekt: die Sequence braucht keinen Param mehr, also auch kein Aufraeumen von Kanten beim +Verkleinern — eine Kante zu loesen *ist* das Verkleinern. + +--- + +## Nachtrag (2026-09-10): der List-Tab steht + +**Gebaut wie im Edit gewuenscht:** eine Listen-Property ist im Inspector ein Knopf `N items`; +er schaltet auf den neuen sechsten Tab **List**, wo jedes Item eine Zeile mit *demselben* +Widget bekommt, das der Inspector fuer diese Item-Art baut (`createField` — Zahl, Text, Ja/Nein +oder Farbe). Der Inspector bleibt daneben stehen, der Kontext geht also nicht verloren. + +**Der Weg dorthin laeuft ueber den Zustand**, nicht ueber `showTab`: `EditorState.listSlot` + +`openList(slot)` + Event `list`; der Editor hoert darauf und schaltet um. Ein Slot ist eine +**Asset-Property, ein Objekt-Override oder eine Node-Einstellung** — `ListSlot` steht in +`state.ts` neben `Selection`, weil `state.ts` sonst in einen UI-Ordner importieren muesste. +`resolveList(state, slot)` in `src/editor/list/slot.ts` liefert Wert, Item-Art, Titel und die +zwei Schreibwege; die drei Faelle benutzen **exakt die Spec der passenden Inspector-Sektion**, +also dasselbe Kommando, dieselbe Beschichtung, denselben Undo-Schritt. + +**Abweichung vom eigenen Vorschlag:** kein `path` in der Adresse. Ein immer leeres Feld, das +niemand liest, ist Spekulation; die Naht, an der ein Pfad spaeter ansetzt, ist die **eine** +Funktion `resolveList` — das ist dann eine Signaturaenderung, kein Umbau. Ehrlicher so. + +**Zwei Dinge, die erst die Gegenprobe gezeigt hat:** +- Die Zeilen-Handler duerfen **nicht** auf einer Momentaufnahme der Liste sitzen. Sie ueberleben + einen Commit (der Tab baut nur bei geaenderter *Laenge* neu), also wuerde die zweite bearbeitete + Zeile die erste zuruecksetzen. Gemessen: mit Momentaufnahme faellt genau dieser Test. +- Die Beschichtung in `replace` war **redundant** — alle drei Kommandos laufen selbst durch + `coerceValue`. Raus. In `toModel` ist sie noetig: dort entsteht der Wert, der als *Vorschau* + am Kommando vorbei direkt ins Modell geschrieben wird. Auch das ist jetzt bewacht. + +**Nebenbei mitgenommen:** `showTab` schaltete fuenf `visible`-Zeilen von Hand — nicht +typgeschuetzt, ein sechster Tab waere dort still vergessen worden. Jetzt ein +`Record`, das jede Auslassung zum Typfehler macht. + +Der unverbundene `list`-Eingang zeigt weiterhin ein **Zahlenfeld**: `inlineInputDef` hat keinen +`list`-Zweig, und der Inline-Pfad kann gar keine Liste erzeugen. Der Knopf loest das nicht mit. + +**Zahlen stehen mit drei Nachkommastellen** (`30.000`). Das ist Hausentscheidung, kein Versehen: +`formatNumber` zeigt immer alle Stellen, „so a value can never look whole when it is not". + +**Weiterhin offen:** Variablen vom Typ struct, die Struct-View im Inspector, und „Liste aus +Structs" (macht `ListValue`/`StructValue` gegenseitig rekursiv — siehe oben). + +--- + +## Nachtrag (2026-09-10): drei Korrekturen am List-Tab + +**Kurze Liste baute sich nicht auf** (User: „muss die Fenstergroesse kurz aendern"). Die Ursache +lag **nicht** im Tab, sondern im `ScrollContainer` — und traf jeden seiner Benutzer, auch `Menu`: + +`rebuild()` uebergibt die Zeilen-`VStack` an `setContent()`, solange sie noch **leer** ist. Der +leere Inhalt wird ueber `Math.max(size.h, this.h)` auf die **Viewporthoehe** gesetzt. Danach kommen +die Zeilen hinein, und `relayoutContent()` rechnet fuer eine kurze Liste wieder dieselbe Hoehe aus +— `Widget.resize()` sieht keine Aenderung und **ueberspringt `layout()`** (`Widget.ts:41`). Alle +Zeilen bleiben auf dem Ursprung gestapelt. Eine *lange* Liste aendert die Hoehe und faellt deshalb +nie auf; die erste Oeffnung ueberhaupt auch nicht, weil der Tab dann noch `w=h=0` hat. + +Gemessen an Tetris, bevor etwas geaendert wurde: `colors` (7) nach `shapes` (224) → alle vier +Zeilen auf `{245,116}`; `grid` (200) → sauber gestapelt; `test` (16) → wieder kaputt. + +Fix in `ScrollContainer.relayoutContent()`: wenn die Groesse gleich bleibt, `layout()` selbst +rufen. Die Methode wird genau dann gerufen, wenn der Inhalt sich geaendert hat — sich auf die +Aenderungserkennung von `resize` zu verlassen ist dort grundsaetzlich falsch. + +**Die Kommazeile im Fuss ist raus** (User-Wunsch). Damit ist auch der Komma-Fallback in +`fieldFactory` unerreichbar geworden — jede Sektion, die eine Liste tragen kann, liefert +`openList` (`assetSection:135`, `nodeSection:134`, `objectSection:325`). Fallback, `listToText` +und `listFromText` sind geloescht; der Test dafuer ist auf `coerceValue` umgeschrieben statt +gestrichen, sonst haette `coerceListItem` seine Abdeckung verloren (Boolean-Schreibweise, Farbe). + +**JSON-Speicherung war bereits da.** Gemessen an der Projektdatei: `props.shapes` ist ein +JSON-Array mit 224 Zahlen, kein Komma-String; die PHP-API ist reines JSON. Das Komma war nur die +*Anzeige* im geloeschten Textfeld. Es bleibt genau eine Stelle, an der ein Komma-Format wirklich +persistiert wird: eine Liste, die in ein **Text**-Property rutscht, wird ueber `textOf()` zu +`"1, 2, 3"` (`Properties.ts:33`, via `coerceValue` kind `text`) — betrifft auch die Ausgabe des +`Log`-Nodes. Der User hat entschieden, das **so zu lassen**; falls eine Liste je verlustfrei durch +ein Text-Feld muss, ist das die Stelle. + +--- + +## Nachtrag (2026-09-10): Variablen vom Typ struct — Teil 2 und 3 sind zu + +**Der Werttyp war fertig, die Deklaration fehlte.** `StructValue`, `textOf`, `sameValue`, +`writeTyped`/`mergeStruct` und der Port-Typ `struct` gab es seit dem ersten Teil; `Get`/`Set +variable` haben `any`-Ports, ein Bündel fliesst also ohne Änderung durch den Graphen. Neu ist +allein, dass eine Variable eine **Feldliste** deklarieren kann: +`PropertyDef` bekommt `kind: 'struct'` mit `fields: StructFieldDef[]`, `VARIABLE_KINDS` das +fünfte Kind, `variableDef` einen fünften Parameter. + +**Ein deklariertes Feld ist ein Primitiv** (`ListItemKind` — Zahl, Text, Ja/Nein, Farbe), obwohl +`FieldValue` auch eine Liste zulässt. Grund: ein Listen-Feld bräuchte einen verschachtelten +Editor und damit den Pfad in der Slot-Adresse — genau der Schritt, auf den auch „Liste aus +Structs" wartet. Der Werttyp bleibt unverändert, die Erweiterung ist additiv. + +**`coerceValue` gibt jetzt `PropertyValue` statt `FieldValue` zurück** — der erste Werttyp, der +kein Feld ist. Gemessen: das bricht **keine** der fünf Aufrufstellen. + +### Die UI: Block statt Zeile — und *nicht* der Tab + +Der List-Tab entstand, weil hunderte Zeilen in keine Inspector-Zeile passen. Ein Struct ist der +Gegenfall: wenige Felder, die untereinander passen. Also kein siebter Tab, keine zweite +Navigation. Eine Struct-Variable ist ein **Block**: + +``` +⊞ [piece ] [+ Field ▾] [×] + # [x ] [ 30.000 ] [×] + Aa [tag ] [ tee ] [×] +``` + +Das ist der Port-Editor, den `Pack` schon hat (`portSection.ts`), eine Ebene tiefer und **mit +Wertspalte**: Typ, Key und Default-Wert an einer Stelle — genau der Wortlaut aus Teil 3 der +Notiz. Ein **platziertes Objekt** sieht dieselben Felder als reine Wertzeilen (`createField` +liefert für `struct` einen Stapel beschrifteter Zeilen): überschreiben ja, deklarieren nein. + +### Drei Dinge, die den Ausschlag gaben + +**Eine Feldänderung muss jeden Wert erreichen.** `AddStructFieldCommand` &co. fassen die +Deklaration, den Default, den Wert des Behaviors **und jeden Instanz-Override** in *einem* +Kommando an (`structSites`); sonst behält ein gespeicherter Wert eine Form, die seine +Deklaration nicht mehr verspricht. Gegenprobe: ohne die Override-Sites fallen 3 Unit-Tests. + +**Kein Retype-Knopf** — der Port-Editor hat auch keinen. Feld löschen und neu anlegen. + +**Die Handler dürfen nicht auf einer Momentaufnahme sitzen**, dieselbe Falle wie beim List-Tab: +ein Feld schreibt das *ganze* Bündel, muss es also im Moment der Änderung lesen. Gegenprobe im +Browser: mit Momentaufnahme wird `{"x":42,"tag":"ell"}` zu `{"x":30,"tag":"ell"}` — die zweite +bearbeitete Zeile belebt den alten Wert der ersten wieder. + +**Die Signatur trägt jetzt die Felder** (`defSignature`): Felder sind eigene Zeilen, ein neues +Feld muss die Sektion also neu bauen. Ohne das erscheint die Zeile erst beim nächsten Rebuild — +gemessen, genau ein Browser-Check fällt. + +**Weiterhin offen:** „Liste aus Structs" (macht `ListValue`/`StructValue` gegenseitig rekursiv), +ein Struct-Feld vom Typ Liste, und der unverbundene `list`-Eingang, der immer noch ein Zahlenfeld +zeigt. + +### Der teuerste Fund lag nicht im Struct + +Der Schritt lief **solo grün und in der vollen Suite mit drei Fehlschlägen** — identische Klick- +koordinaten, das Menü ging auf, `Boolean` lag genau unter dem Zeiger, und trotzdem passierte +nichts. Kein Notice, also war es kein `CommandError`, sondern eine **echte Ausnahme**, die das +Menü (`closeThen`: erst schliessen, dann ausfuehren) als „Klick auf den Hintergrund" aussehen +liess. + +Halbiert (`GM_STEPS=1-9,22` gruen, `1-10,22` rot) und dann im Fenster abgefangen: +`Cannot convert undefined or null to object`. Die Ursache ist ein **Fixture-Fehler**: Schritt 10 +schiebt sein Text-Objekt per `mutate` von Hand in die Szene — **ohne `instanceEdits`**, obwohl +`SceneObject` das Feld verlangt. `overrideSites` laeuft mit `Object.entries` darueber. Mein +`AddStructFieldCommand` ist die erste Stelle, die `overrideSites` aus diesem Zustand heraus ruft. + +Das Produkt bleibt unangetastet: geladen wird das Feld von `normalizeObjects` gefuellt, angelegt +von den Kommandos — ein Objekt ohne `instanceEdits` kann echt nicht entstehen. Repariert ist das +Objektliteral im Schritt. + +**Lehre fuer den naechsten Schritt:** ein Schritt, der solo gruen ist, ist nicht belegt. Nur der +volle Lauf sieht, was die Vorgaenger hinterlassen. diff --git a/todo-notes/show-gizmo-of-disabled-objects.md b/todo-notes/show-gizmo-of-disabled-objects.md new file mode 100644 index 00000000..405248ff --- /dev/null +++ b/todo-notes/show-gizmo-of-disabled-objects.md @@ -0,0 +1,2 @@ +objects die nicht enabled sind, sind komplett unsichtbar, das passt +aber: wenn ich sie im panel linksunten selektiere, sollte zumindest das "gizmo" (also der selektions rahmen) im scene viewport sichtbar sein diff --git a/todo-notes/tetris-mit-structs-neu-bauen.md b/todo-notes/tetris-mit-structs-neu-bauen.md new file mode 100644 index 00000000..b46ebaee --- /dev/null +++ b/todo-notes/tetris-mit-structs-neu-bauen.md @@ -0,0 +1 @@ +Tetris Game mit Structs neu bauen diff --git a/tools/e2e/10-text-and-enabled.mjs b/tools/e2e/10-text-and-enabled.mjs index a4b807a7..615f6ee6 100644 --- a/tools/e2e/10-text-and-enabled.mjs +++ b/tools/e2e/10-text-and-enabled.mjs @@ -29,7 +29,7 @@ export async function textAndEnabled(t) { __gm.state.project.assets.find(a => a.id === id).props.text = 'Hello world'; // On the camera so the game view really renders it (outside the frame it would be culled). const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position; - __gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '' }); + __gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); __gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`); await sleep(100); check('a placed text object draws its content in the scene', await has('Hello world')); diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs new file mode 100644 index 00000000..f4aff69a --- /dev/null +++ b/tools/e2e/22-struct.mjs @@ -0,0 +1,106 @@ +/** + * A variable of type struct: the inspector declares its fields and gives each one a value in the + * same row, and writing one field keeps the others – the trap a shared snapshot would fall into. + */ +export async function structVariable(t) { + const { check, sleep, json, shot, click, clickText, press, typeText, mutate, undo } = t; + + /** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */ + const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`); + const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece"); + + // A throwaway behavior carrying one struct variable; removed again at the end of the step. + await mutate(` + const p = __gm.state.project; + p.assets.push({ + id: 'structy', + type: 'behavior', + name: 'Structy', + props: { enabled: true, runInEditor: false, piece: { x: 30, tag: 'tee' } }, + variables: [ + { + key: 'piece', + label: 'piece', + kind: 'struct', + default: { x: 0, tag: '' }, + fields: [ + { key: 'x', kind: 'number' }, + { key: 'tag', kind: 'text' }, + ], + }, + ], + graph: { nodes: [], edges: [] }, + }); + __gm.state.select({ kind: 'asset', assetId: 'structy' }); + `); + await sleep(150); + + // 1. The declaration is what the inspector draws: a head that can add fields, a row per field. + check('a struct variable carries the add-a-field control', (await find('+ Field')) !== null); + check('and a row for each declared field', (await find('x')) !== null && (await find('tag')) !== null); + check('whose value is shown, not a summary of the bundle', (await find('30.000')) !== null); + await shot('01-struct-rows'); + + // 2. Writing one field keeps the others. Two edits in a row without a rebuild in between is + // exactly where handlers sharing one snapshot of the bundle would revive the first value. + const xField = await json("__gm.inspectorField('x')"); + await click(xField.x, xField.y); + await typeText('42'); + await press('Enter'); + await sleep(200); + check('typing in a field writes that field', (await piece()).x === 42, JSON.stringify(await piece())); + + const tagField = await json("__gm.inspectorField('tag')"); + await click(tagField.x, tagField.y); + await press('a', { ctrl: true }); + await typeText('ell'); + await press('Enter'); + await sleep(200); + check( + 'and the next field edited keeps the one before it', + JSON.stringify(await piece()) === '{"x":42,"tag":"ell"}', + JSON.stringify(await piece()), + ); + + // 3. One undo step per field. + await undo(); + check('undo takes back just the last field', (await piece()).tag === 'tee' && (await piece()).x === 42); + await undo(); + check('and the one before it', (await piece()).x === 30, JSON.stringify(await piece())); + + // 4. Adding a field reaches the declaration and the value at once. + await clickText('+ Field'); + await sleep(150); + await clickText('Boolean'); + await sleep(250); + const grown = await piece(); + check('+ Field grows the bundle', 'field1' in grown && grown.field1 === false, JSON.stringify(grown)); + check('and the new field has a row', (await find('field1')) !== null); + await shot('02-field-added'); + await undo(); + await sleep(200); + check('undo takes it out of the value too', !('field1' in (await piece())), JSON.stringify(await piece())); + + // 5. Renaming a field carries its value along. + const nameField = await json("__gm.locate('x')"); + await click(nameField.x, nameField.y); + await press('a', { ctrl: true }); + await typeText('column'); + await press('Enter'); + await sleep(250); + check( + 'renaming a field keeps its value', + JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}', + JSON.stringify(await piece()), + ); + + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'structy'); + __gm.state.select(null); + __gm.state.preview(); + `); + await sleep(200); + const left = await json('__gm.state.project.assets.filter((a) => a.id === "structy").length'); + check('the step leaves the fixture as it found it', left === 0, String(left)); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 1a7901d3..a9a7ab52 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -24,6 +24,7 @@ import { pause } from './18-pause.mjs'; import { nestedBehavior } from './19-nested-behavior.mjs'; import { keyRepeat } from './20-key-repeat.mjs'; import { listTab } from './21-list.mjs'; +import { structVariable } from './22-struct.mjs'; export const STEPS = [ hierarchy, @@ -47,4 +48,5 @@ export const STEPS = [ nestedBehavior, keyRepeat, listTab, + structVariable, ];