From 9f482573e5b4981099b03cc47ec026c1ef6f196d Mon Sep 17 00:00:00 2001 From: root Date: Thu, 10 Sep 2026 23:05:02 +0200 Subject: [PATCH] Show a placed object's struct the way its asset does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instance inspector drew its own, flatter version of a struct: one row per field, no bundle head. It now draws the same block as the asset side, because there is only one implementation left - both call buildStructBlock. What the instance does not get is everything that touches the shape: renaming a field, '+ Field', and the per-field '×'. The shape belongs to the asset; a placed object overrides values. The revert and override marks sit on the head rather than on a field, since an override covers the whole bundle at once. Underneath this, editing a single field of an unoverridden struct used to blank its siblings: structFieldsOf filled the untouched fields from spec.read, which is object.overrides[key] and therefore undefined until something is overridden. structOf then handed back the empty shape. PreviewedValue carries 'current' now - the resolved value the row actually shows - and the fields come from that. Without it the old path stays, which is right for the asset side, whose prop bag is dense. --- README.md | 8 +- src/editor/inspector/assetSection.ts | 134 ++++--------------- src/editor/inspector/fieldFactory.ts | 47 ++++--- src/editor/inspector/objectSection.ts | 171 ++++++++++++++---------- src/editor/inspector/previewHandlers.ts | 8 +- src/editor/inspector/structBlock.ts | 145 ++++++++++++++++++++ tests/editor/struct-block.test.ts | 85 ++++++++++++ tests/editor/struct-field-edit.test.ts | 119 +++++++++++++++++ tools/e2e/22-struct.mjs | 96 ++++++++++++- 9 files changed, 606 insertions(+), 207 deletions(-) create mode 100644 src/editor/inspector/structBlock.ts create mode 100644 tests/editor/struct-block.test.ts create mode 100644 tests/editor/struct-field-edit.test.ts diff --git a/README.md b/README.md index 147c0b96..669b36b9 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,10 @@ shown on the node and editable in the inspector. has, one level down and with a value column – and a field may itself be a **list**, which is the one count button that opens the tab one level deeper. 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. + stored value keeps a shape its declaration no longer promises. A placed instance draws the very + same block (`buildStructBlock`), minus the renaming and the `+ Field` / `×` buttons: it may + override the values, not declare them. Its ↺ / ↑ sit on the head row, because an override covers + the whole bundle rather than a single field. - **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 @@ -607,7 +609,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru # code is lost (tail's is reported), so a red run reads as green 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 # 502 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 509 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 ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 9b51e56e..60835da3 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -13,6 +13,7 @@ import { RenameVariableCommand, SetListItemsCommand, } from '../../core/commands/variableCommands'; +import type { Command } from '../../core/commands/Command'; import type { Asset, BehaviorAsset } from '../../core/model/Asset'; import type { PropertyValue } from '../../core/model/Properties'; import { findAsset, type Project } from '../../core/model/Project'; @@ -22,7 +23,6 @@ import { type ListItemKind, type PropertyDef, schemaForAsset, - STRUCT_FIELD_KINDS, type StructDef, type StructFieldDef, type StructFieldKind, @@ -33,16 +33,16 @@ 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, - fieldDef, - labelledRow, - structFieldValue, -} from './fieldFactory'; +import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; import type { ListDef } from '../list/slot'; import { addNote, addTitle, type InspectorHost } from './host'; +import { + buildStructBlock, + FIELD_INDENT, + FIELD_KIND_LABELS, + FIELD_KIND_MARKS, + kindMark as glyphMark, +} from './structBlock'; import { listOpener, type PreviewedValue, @@ -85,28 +85,10 @@ const LIST_ITEM_LABELS: Record = { struct: 'Structs', }; -const FIELD_KIND_LABELS: Record = { - number: 'Number', - boolean: 'Boolean', - text: 'Text', - color: 'Color', - list: 'List', -}; - -const FIELD_INDENT = 12; - /** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */ const newField = (key: string, kind: StructFieldKind): StructFieldDef => kind === 'list' ? { key, kind, items: 'number' } : { key, kind }; -const FIELD_KIND_MARKS: Record = { - number: '#', - boolean: '✓', - text: 'Aa', - color: '◧', - list: '≡', -}; - /** * 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. @@ -271,9 +253,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { } function kindMark(def: PropertyDef): Label { - const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' }); - mark.minWidth = 18; - return mark; + return glyphMark(VARIABLE_KIND_MARKS[def.kind] ?? '?'); } /** @@ -304,9 +284,7 @@ function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; row.add(new Spacer(FIELD_INDENT)); - const mark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' }); - mark.minWidth = 18; - row.add(mark); + row.add(glyphMark(FIELD_KIND_MARKS[field.kind])); row.add( new TextField(field.key, { width: 90, @@ -344,91 +322,31 @@ function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef host.body.add(foot); } -/** - * 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. - */ +/** A struct variable, with the shape editable: this is the asset that declares it. */ 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 = STRUCT_FIELD_KINDS.map((kind) => ({ - label: FIELD_KIND_LABELS[kind], - onSelect: () => - host.state.execute( - new AddStructFieldCommand(asset.id, def.key, newField(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), (field) => ({ kind: 'asset', assetId: asset.id, key: def.key, field, })); - 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, fieldDef(field), 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: () => {}, - }); + const run = (command: Command): boolean => host.state.execute(command); + host.fields.set( + def.key, + buildStructBlock(host, def, value, perField, { + edit: { + renameStruct: (next) => run(new RenameVariableCommand(asset.id, def.key, next)), + removeStruct: () => run(new RemoveVariableCommand(asset.id, def.key)), + renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)), + removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)), + addField: (kind) => + run(new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(def.fields), kind))), + }, + }), + ); } diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index a94bb7e3..4f426168 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -105,26 +105,10 @@ export function labelledRow(text: string): { row: HStack; label: Label } { } /** - * Maps one PropertySchema entry to a labelled row containing the matching widget. - * The inspector is entirely generated through this function: adding a new property - * kind means adding a case here, adding a property means adding a schema line. - * - * A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to - * its source, `extra` adds row-specific ones. Each group is asked on every refresh: no entry - * means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾). + * Adds the trailing ↺/↑/extra buttons to a row and returns the refresh that re-evaluates them. + * Separate from `createFieldRow` because a struct block wears the same buttons on its head row. */ -export function createFieldRow( - ctx: FieldContext, - def: PropertyDef, - value: PropertyValue, - handlers: FieldHandlers, - rowActions: RowActions = {}, -): FieldRow { - const { row, label } = labelledRow(def.label); - const field = createField(ctx, def, value, handlers); - row.add(field.widget, field.grow ? 1 : 0); - if (!field.grow) row.add(new Spacer(), 1); - +export function attachRowActions(row: HStack, rowActions: RowActions): () => void { const actions: { button: Button; glyph: string; available: FieldActions }[] = []; const addActions = (glyph: string, available: FieldActions): void => { // A floor, not a width: the same button also carries the two-glyph menu form "↺ ▾". @@ -163,6 +147,31 @@ export function createFieldRow( if (changed) row.relayout(); }; refreshActions(); + return refreshActions; +} + +/** + * Maps one PropertySchema entry to a labelled row containing the matching widget. + * The inspector is entirely generated through this function: adding a new property + * kind means adding a case here, adding a property means adding a schema line. + * + * A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to + * its source, `extra` adds row-specific ones. Each group is asked on every refresh: no entry + * means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾). + */ +export function createFieldRow( + ctx: FieldContext, + def: PropertyDef, + value: PropertyValue, + handlers: FieldHandlers, + rowActions: RowActions = {}, +): FieldRow { + const { row, label } = labelledRow(def.label); + const field = createField(ctx, def, value, handlers); + row.add(field.widget, field.grow ? 1 : 0); + if (!field.grow) row.add(new Spacer(), 1); + + const refreshActions = attachRowActions(row, rowActions); return { row, diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 3240bc76..a28fd9f2 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -41,10 +41,18 @@ 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 { createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; +import type { ListSlot } from '../state'; import { parentRow } from './parentRow'; -import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers'; +import { + listOpener, + type PreviewedValue, + previewingHandlers, + structFields, + structFieldsOf, +} from './previewHandlers'; +import { buildStructBlock } from './structBlock'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; @@ -309,85 +317,100 @@ function propertyRow( const overrideSpec: PreviewedValue = { target: () => currentRef(host.state, holderId, objectId), read: (ref) => ref.object.overrides[def.key], + current: () => currentProps(host.state, holderId, objectId)?.[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, overrideSpec), - ...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), - ...structFields(host, def, overrideSpec, (field) => ({ - kind: 'object', - holderId, - objectId, - key: def.key, - field, - })), + const fieldSlot = (field: string): ListSlot => ({ + kind: 'object', + holderId, + objectId, + key: def.key, + field, + }); + const rowActions: RowActions = { + extra: aspectActions( + host, + owner, + def, + () => currentProps(host.state, holderId, objectId), + (values) => + host.state.execute( + sizeCommand(values, (axis, value) => new SetOverrideCommand(holderId, objectId, axis, value)), + ), + ), + reset: () => { + const { template, asset } = bases(); + return [ + ...(template + ? [ + { + label: 'Reset to container', + onSelect: () => + host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)), + }, + ] + : []), + ...(asset + ? [ + { + label: 'Reset to asset', + onSelect: () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)), + }, + ] + : []), + ]; }, - { - extra: aspectActions( - host, - owner, - def, - () => currentProps(host.state, holderId, objectId), - (values) => - host.state.execute( - sizeCommand(values, (axis, value) => new SetOverrideCommand(holderId, objectId, axis, value)), - ), - ), - reset: () => { - const { template, asset } = bases(); - return [ - ...(template - ? [ - { - label: 'Reset to container', - onSelect: () => - host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)), - }, - ] - : []), - ...(asset - ? [ - { - label: 'Reset to asset', - onSelect: () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)), - }, - ] - : []), - ]; - }, - apply: () => { - const { template, applyToAsset } = bases(); - return [ - ...(template - ? [ - { - label: 'Apply to container', - onSelect: () => - host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)), - }, - ] - : []), - ...(applyToAsset - ? [ - { - label: 'Apply to asset', - onSelect: () => - host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)), - }, - ] - : []), - ]; - }, + apply: () => { + const { template, applyToAsset } = bases(); + return [ + ...(template + ? [ + { + label: 'Apply to container', + onSelect: () => + host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)), + }, + ] + : []), + ...(applyToAsset + ? [ + { + label: 'Apply to asset', + onSelect: () => + host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)), + }, + ] + : []), + ]; }, - ); + }; + + // A struct is a block of rows, which only it can add to the body; the same one the asset shows, + // minus the renaming and the field buttons – a placed object overrides values, not the shape. + const row = + def.kind === 'struct' + ? buildStructBlock( + host, + def, + values[def.key] ?? def.default, + structFieldsOf(host, def, overrideSpec, fieldSlot), + { actions: rowActions }, + ) + : createFieldRow( + host, + def, + values[def.key] ?? def.default, + { + ...previewingHandlers(host, overrideSpec), + ...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), + ...structFields(host, def, overrideSpec, fieldSlot), + }, + rowActions, + ); host.fields.set(def.key, row); - host.body.add(row.row); + if (def.kind !== 'struct') host.body.add(row.row); return row; } diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts index fbf88044..0880919a 100644 --- a/src/editor/inspector/previewHandlers.ts +++ b/src/editor/inspector/previewHandlers.ts @@ -47,7 +47,7 @@ export function structFieldsOf( ...previewingHandlers(host, { ...spec, toModel: (input, target) => ({ - ...structOf(def.fields, spec.read(target)), + ...structOf(def.fields, (spec.current ?? spec.read)(target)), [field.key]: coerceFieldValue(input, field), }), }), @@ -63,6 +63,12 @@ export interface PreviewedValue { target: () => Target | undefined; /** The stored value; undefined means the key is not set on the target at all. */ read: (target: Target) => Model | undefined; + /** + * The value the row displays: the stored one resolved against what it inherits. Only a compound + * value needs it – writing one struct field rebuilds the whole bundle, and an override starts out + * unset, so seeding from `read` would drop every untouched field to empty. Defaults to `read`. + */ + current?: (target: Target) => Model | undefined; write: (target: Target, value: Model) => void; /** Unsets the key again; needed wherever `read` can return undefined. */ remove?: (target: Target) => void; diff --git a/src/editor/inspector/structBlock.ts b/src/editor/inspector/structBlock.ts new file mode 100644 index 00000000..2d058843 --- /dev/null +++ b/src/editor/inspector/structBlock.ts @@ -0,0 +1,145 @@ +import type { PropertyValue } from '../../core/model/Properties'; +import { + STRUCT_FIELD_KINDS, + type StructDef, + type StructFieldDef, + type StructFieldKind, +} from '../../core/properties/PropertySchema'; +import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; +import { + attachRowActions, + createField, + type FieldHandlers, + fieldDef, + type FieldRow, + type RowActions, + structFieldValue, +} from './fieldFactory'; +import { addNote, type InspectorHost } from './host'; + +export const FIELD_INDENT = 12; +const NAME_WIDTH = 90; +const MARK_WIDTH = 18; + +export const STRUCT_MARK = '⊞'; + +export const FIELD_KIND_MARKS: Record = { + number: '#', + boolean: '✓', + text: 'Aa', + color: '◧', + list: '≡', +}; + +export const FIELD_KIND_LABELS: Record = { + number: 'Number', + boolean: 'Boolean', + text: 'Text', + color: 'Color', + list: 'List', +}; + +export function kindMark(glyph: string): Label { + const mark = new Label(glyph, { style: 'small', align: 'center' }); + mark.minWidth = MARK_WIDTH; + return mark; +} + +/** Changing the shape itself – offered only where it is declared, which is the asset. */ +export interface StructShapeEditing { + renameStruct: (next: string) => void; + removeStruct: () => void; + renameField: (field: StructFieldDef, next: string) => void; + removeField: (field: StructFieldDef) => void; + addField: (kind: StructFieldKind) => void; +} + +export interface StructBlockOptions { + edit?: StructShapeEditing; + /** ↺/↑ on the head row: an override covers the whole bundle, so its fields share them. */ + actions?: RowActions; +} + +/** + * A struct is a block rather than a row: a head naming the bundle, then one row per field carrying + * its mark, its name and its value. Both places that show one build it here, so a placed object + * reads exactly like the asset that declares it – minus `edit`, since a shape belongs to the asset. + * + * Adds its rows to the body itself (a caller cannot add a block), and returns the head as the row. + */ +export function buildStructBlock( + host: InspectorHost, + def: StructDef, + value: PropertyValue, + handlers: (field: StructFieldDef) => FieldHandlers, + options: StructBlockOptions = {}, +): FieldRow { + const { edit, actions } = options; + const head = new HStack({ gap: Theme.gap, align: 'center' }); + head.minHeight = Theme.rowHeight; + head.add(kindMark(STRUCT_MARK)); + const nameLabel = edit ? null : name(def.key); + head.add( + nameLabel ?? + new TextField(def.key, { width: NAME_WIDTH, onChange: (next) => edit?.renameStruct(next.trim()) }), + ); + head.add(new Spacer(), 1); + if (edit) { + const add = new Dropdown('+ Field', { variant: 'ghost' }); + add.onClick = () => { + const items = STRUCT_FIELD_KINDS.map((kind) => ({ + label: FIELD_KIND_LABELS[kind], + onSelect: () => edit.addField(kind), + })); + new Menu(items).openBelow(add); + }; + head.add(add); + head.add(remove(() => edit.removeStruct())); + } + const refreshActions = actions ? attachRowActions(head, actions) : () => {}; + host.body.add(head); + + if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); + const setters = 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)); + row.add(kindMark(FIELD_KIND_MARKS[field.kind])); + row.add( + edit + ? new TextField(field.key, { + width: NAME_WIDTH, + onChange: (next) => edit.renameField(field, next.trim()), + }) + : name(field.key), + ); + const item = createField(host, fieldDef(field), structFieldValue(value, field), handlers(field)); + row.add(item.widget, item.grow ? 1 : 0); + if (!item.grow) row.add(new Spacer(), 1); + if (edit) row.add(remove(() => edit.removeField(field))); + setters.set(field.key, item.setValue); + host.body.add(row); + } + + return { + row: head, + setValue: (next) => { + for (const field of def.fields) setters.get(field.key)?.(structFieldValue(next, field)); + }, + setModified: (modified) => { + nameLabel?.setStyle(modified ? 'override' : 'muted'); + refreshActions(); + }, + }; +} + +function name(text: string): Label { + const label = new Label(text, { style: 'muted' }); + label.minWidth = NAME_WIDTH; + return label; +} + +function remove(onClick: () => void): Button { + return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick }); +} diff --git a/tests/editor/struct-block.test.ts b/tests/editor/struct-block.test.ts new file mode 100644 index 00000000..1b6a74e8 --- /dev/null +++ b/tests/editor/struct-block.test.ts @@ -0,0 +1,85 @@ +/** + * A struct is drawn as a block – a head naming the bundle, a row per field – in both places that + * show one. The asset that declares it may rename and add fields; a placed object may not, so + * there the names are labels. `NumberField extends TextField`, so counting editable widgets by + * type would count the value fields too: the tests below go by which widget carries the name. + */ +import { describe, expect, it } from 'vitest'; +import { createEmptyProject } from '../../src/core/model/Project'; +import type { StructDef } from '../../src/core/properties/PropertySchema'; +import { Label, TextField, VStack, type Widget } from '../../src/ui'; +import type { InspectorHost } from '../../src/editor/inspector/host'; +import { buildStructBlock } from '../../src/editor/inspector/structBlock'; +import { EditorState } from '../../src/editor/state'; + +const DEF: StructDef = { + key: 'piece', + label: 'piece', + kind: 'struct', + default: { x: 0, tag: '' }, + fields: [ + { key: 'x', kind: 'number' }, + { key: 'tag', kind: 'text' }, + ], +}; + +const VALUE = { x: 30, tag: 'tee' }; + +function makeHost(): InspectorHost { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + return { state, body: new VStack(), fields: new Map(), nameField: null, project: () => state.project }; +} + +const noHandlers = () => ({ onInput: () => {}, onChange: () => {} }); + +const rows = (host: InspectorHost): Widget[][] => + host.body.children.map((row) => ('children' in row ? (row.children as Widget[]) : [])); + +/** Every widget of the block that carries `text` as its own content, name or value. */ +const carrying = (host: InspectorHost, text: string): Widget[] => + rows(host) + .flat() + .filter((w) => (w instanceof Label && w.text === text) || (w instanceof TextField && w.value === text)); + +const noop = { renameStruct: () => {}, removeStruct: () => {}, renameField: () => {}, removeField: () => {} }; + +describe('a struct block on a placed object', () => { + it('is a head row plus one row per field', () => { + const host = makeHost(); + buildStructBlock(host, DEF, VALUE, noHandlers); + expect(host.body.children.length).toBe(1 + DEF.fields.length); + }); + + it('names the bundle and its fields with labels, so they cannot be edited', () => { + const host = makeHost(); + buildStructBlock(host, DEF, VALUE, noHandlers); + for (const name of ['piece', 'x', 'tag']) { + const [widget] = carrying(host, name); + expect(widget, name).toBeInstanceOf(Label); + } + }); + + it('still shows the values in editable fields', () => { + const host = makeHost(); + buildStructBlock(host, DEF, VALUE, noHandlers); + expect(carrying(host, 'tee')[0]).toBeInstanceOf(TextField); + }); +}); + +describe('the same block on the asset that declares it', () => { + it('names the bundle and its fields with text fields, so they can be renamed', () => { + const host = makeHost(); + buildStructBlock(host, DEF, VALUE, noHandlers, { edit: { ...noop, addField: () => {} } }); + for (const name of ['piece', 'x', 'tag']) { + const [widget] = carrying(host, name); + expect(widget, name).toBeInstanceOf(TextField); + } + }); + + it('keeps the same row count as the placed one', () => { + const host = makeHost(); + buildStructBlock(host, DEF, VALUE, noHandlers, { edit: { ...noop, addField: () => {} } }); + expect(host.body.children.length).toBe(1 + DEF.fields.length); + }); +}); diff --git a/tests/editor/struct-field-edit.test.ts b/tests/editor/struct-field-edit.test.ts new file mode 100644 index 00000000..02b8996c --- /dev/null +++ b/tests/editor/struct-field-edit.test.ts @@ -0,0 +1,119 @@ +/** + * Editing one field of a struct rebuilds the whole bundle – an override is one value per property + * key, not per field. The question here is what the untouched fields are filled from: an override + * is sparse and starts out unset, so only the *resolved* value carries the asset's own numbers. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands'; +import type { PropertyValue } from '../../src/core/model/Properties'; +import type { SceneObject } from '../../src/core/model/SceneObject'; +import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project'; +import { requirePropDef } from '../../src/core/commands/require'; +import { resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve'; +import { structFieldsOf } from '../../src/editor/inspector/previewHandlers'; +import { EditorState } from '../../src/editor/state'; + +const FIELDS = [ + { key: 'x', kind: 'number' }, + { key: 'y', kind: 'number' }, + { key: 'tag', kind: 'text' }, +] as const; + +function setup() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const scene = new CreateAssetCommand('scene', 'Level'); + const behavior = new CreateAssetCommand('behavior', 'B'); + // A rejected command only returns false, so an unasserted setup would let the tests below pass + // for the wrong reason. + expect(state.execute(scene)).toBe(true); + expect(state.execute(behavior)).toBe(true); + expect(state.execute(new AddVariableCommand(behavior.asset.id, 'piece', 'struct'))).toBe(true); + for (const field of FIELDS) + expect(state.execute(new AddStructFieldCommand(behavior.asset.id, 'piece', { ...field }))).toBe(true); + // The asset's own values: what the inspector shows for an object that overrides nothing. + expect( + state.execute(new SetAssetPropertyCommand(behavior.asset.id, 'piece', { x: 3, y: 4, tag: 'T' })), + ).toBe(true); + const place = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }); + expect(state.execute(place)).toBe(true); + return { state, sceneId: scene.asset.id, behaviorId: behavior.asset.id, objectId: place.objectId }; +} + +describe('editing one field of a struct', () => { + let f: ReturnType; + beforeEach(() => { + f = setup(); + }); + + const asset = () => findAsset(f.state.project, f.behaviorId)!; + const object = (): SceneObject => + findScene(f.state.project, f.sceneId)!.objects.find((o) => o.id === f.objectId)!; + const structDef = () => { + const def = requirePropDef(asset(), 'piece'); + if (def.kind !== 'struct') throw new Error('piece is not a struct'); + return def; + }; + + /** The spec `objectSection` builds for an override, resolved value included. */ + const objectHandlers = () => + structFieldsOf({ state: f.state }, structDef(), { + target: object, + read: (o) => o.overrides.piece, + current: (o) => resolveSceneObjectProps(asset(), o).piece, + write: (o, value) => (o.overrides.piece = value), + remove: (o) => delete o.overrides.piece, + commit: (value) => new SetOverrideCommand(f.sceneId, f.objectId, 'piece', value), + }); + + it('fills the untouched fields of a first override from the asset, not from an empty bundle', () => { + objectHandlers()({ key: 'x', kind: 'number' }).onChange(9); + expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'T' }); + }); + + it('leaves the others alone on a second edit, and undo goes back one field at a time', () => { + objectHandlers()({ key: 'x', kind: 'number' }).onChange(9); + objectHandlers()({ key: 'tag', kind: 'text' }).onChange('hi'); + expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'hi' }); + + f.state.commands.undo(); + expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'T' }); + f.state.commands.undo(); + expect(object().overrides.piece).toBeUndefined(); + }); + + it('builds on the preview while typing, and still undoes to before it', () => { + const handlers = objectHandlers()({ key: 'x', kind: 'number' }); + handlers.onInput(1); + handlers.onInput(12); + handlers.onChange(12); + expect(object().overrides.piece).toEqual({ x: 12, y: 4, tag: 'T' }); + + f.state.commands.undo(); + expect(object().overrides.piece).toBeUndefined(); + }); + + it('copies those values in rather than keeping them tied to the asset', () => { + objectHandlers()({ key: 'x', kind: 'number' }).onChange(9); + f.state.execute(new SetAssetPropertyCommand(f.behaviorId, 'piece', { x: 3, y: 99, tag: 'T' })); + // The whole bundle is overridden now, so the asset moving on no longer reaches y. + expect(resolveSceneObjectProps(asset(), object()).piece).toEqual({ x: 9, y: 4, tag: 'T' }); + }); + + it('takes the stored value where that already is the resolved one – the dense asset bag', () => { + const handlers = structFieldsOf<{ props: Record }>( + { state: f.state }, + structDef(), + { + target: () => asset(), + read: (a) => a.props.piece, + write: (a, value) => (a.props.piece = value), + commit: (value) => new SetAssetPropertyCommand(f.behaviorId, 'piece', value), + }, + ); + handlers({ key: 'y', kind: 'number' }).onChange(8); + expect(resolveAssetProps(asset()).piece).toEqual({ x: 3, y: 8, tag: 'T' }); + }); +}); diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs index 5b9d9982..ed200cfc 100644 --- a/tools/e2e/22-struct.mjs +++ b/tools/e2e/22-struct.mjs @@ -3,7 +3,7 @@ * 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; + const { check, sleep, json, shot, click, clickText, press, typeText, mutate, select, 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)`); @@ -135,13 +135,105 @@ export async function structVariable(t) { await mutate('__gm.state.openList(null)'); await sleep(200); + + // 7. The same bundle on a placed object. An override is sparse – it starts out unset – while a + // field edit rebuilds the whole bundle, so the untouched fields have to come from what the row + // shows (the asset's values) rather than from the empty form of the declaration. + const placed = () => json("__gm.state.activeScene.objects.find((o) => o.id === 'ostruct').overrides.piece"); + await mutate(` + __gm.state.activeScene.objects.push({ + id: 'ostruct', + assetId: 'structy', + name: 'Placed structy', + position: { x: 0, y: 0, z: 0 }, + editorOffset: { x: 0, y: 0 }, + overrides: {}, + parentId: '', + instanceEdits: {}, + }); + `); + await select({ kind: 'object', holderId: 'scene1', objectId: 'ostruct' }); + await sleep(200); + check('a placed behavior shows its bundle, filled from the asset', (await find('tee')) !== null); + + // 8. It reads exactly like the asset does: a head carrying the struct mark, then one row per + // field with its kind mark, its name and its value. What is missing is the shape editing – + // a placed object overrides values, the declaration stays with the asset that owns it. + const bundleMark = await find('⊞'); + const headName = await find('piece'); + const fieldMark = await find('#'); + const fieldName = await find('column'); + const fieldValue = await json("__gm.inspectorField('column')"); + check( + 'the bundle is a head above its fields, marked as a struct', + bundleMark !== null && + headName !== null && + fieldName !== null && + bundleMark.x < headName.x && + headName.y < fieldName.y, + JSON.stringify({ bundleMark, headName, fieldName }), + ); + check( + 'and a field row runs kind mark, name, value', + fieldMark !== null && + fieldName !== null && + fieldMark.x < fieldName.x && + fieldName.x < fieldValue.x && + Math.abs(fieldMark.y - fieldName.y) < 4, + JSON.stringify({ fieldMark, fieldName, fieldValue }), + ); + check('the shape itself cannot be changed from here', (await find('+ Field')) === null); + + // Typing over a name has to do nothing: a Label is not a TextField, and the declaration is + // the asset's. Plain letters are safe here – the only shortcuts are modified ones and Delete. + const declaration = () => + json( + "(() => { const v = __gm.state.project.assets.find((a) => a.id === 'structy').variables[0];" + + ' return `${v.key}:${v.fields.map((f) => f.key).join(",")}`; })()', + ); + const before = await declaration(); + // A missing label would throw here and kill the step; clicking nowhere keeps it a plain failure. + for (const cell of [headName, fieldName]) { + await click(cell?.x ?? 0, cell?.y ?? 0); + await typeText('renamed'); + await press('Enter'); + } + await sleep(200); + check('typing over the names of a placed bundle renames nothing', (await declaration()) === before, before); + + const placedField = await json("__gm.inspectorField('column')"); + await click(placedField.x, placedField.y); + await press('a', { ctrl: true }); + await typeText('7'); + await press('Enter'); + await sleep(250); + check( + 'the first override of one field keeps the asset values of the others', + JSON.stringify(await placed()) === '{"column":7,"tag":"tee"}', + JSON.stringify(await placed()), + ); + await shot('04-object-override'); + + await undo(); + await sleep(200); + check( + 'and undo takes the override away again, not just the one field', + (await json("__gm.state.activeScene.objects.find((o) => o.id === 'ostruct').overrides.piece")) === null, + JSON.stringify(await placed()), + ); + await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'structy'); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'ostruct'); __gm.state.select(null); __gm.state.preview(); `); await sleep(200); - const left = await json('__gm.state.project.assets.filter((a) => a.id === "structy").length'); + const left = await json( + '__gm.state.project.assets.filter((a) => a.id === "structy").length +' + + ' __gm.state.activeScene.objects.filter((o) => o.id === "ostruct").length', + ); check('the step leaves the fixture as it found it', left === 0, String(left)); }