Show a placed object's struct the way its asset does

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.
This commit is contained in:
root committed 2026-09-10 23:05:02 +02:00
1 parent 14f542365e
commit 9f482573e5
9 files changed
+606 -207

No files matched your search

+5 -3
View File
@@ -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 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 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 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 stored value keeps a shape its declaration no longer promises. A placed instance draws the very
fields as plain value rows: it may override them, not declare them. 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 - **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 (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 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 # code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below 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 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 start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result ./e2e.sh status # live progress, or the last run's result
+26 -108
View File
@@ -13,6 +13,7 @@ import {
RenameVariableCommand, RenameVariableCommand,
SetListItemsCommand, SetListItemsCommand,
} from '../../core/commands/variableCommands'; } from '../../core/commands/variableCommands';
import type { Command } from '../../core/commands/Command';
import type { Asset, BehaviorAsset } from '../../core/model/Asset'; import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import type { PropertyValue } from '../../core/model/Properties'; import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project'; import { findAsset, type Project } from '../../core/model/Project';
@@ -22,7 +23,6 @@ import {
type ListItemKind, type ListItemKind,
type PropertyDef, type PropertyDef,
schemaForAsset, schemaForAsset,
STRUCT_FIELD_KINDS,
type StructDef, type StructDef,
type StructFieldDef, type StructFieldDef,
type StructFieldKind, type StructFieldKind,
@@ -33,16 +33,16 @@ import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve'; import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { aspectActions, sizeCommand } from './aspectAction'; import { aspectActions, sizeCommand } from './aspectAction';
import { import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
createField,
createFieldRow,
type FieldHandlers,
fieldDef,
labelledRow,
structFieldValue,
} from './fieldFactory';
import type { ListDef } from '../list/slot'; import type { ListDef } from '../list/slot';
import { addNote, addTitle, type InspectorHost } from './host'; import { addNote, addTitle, type InspectorHost } from './host';
import {
buildStructBlock,
FIELD_INDENT,
FIELD_KIND_LABELS,
FIELD_KIND_MARKS,
kindMark as glyphMark,
} from './structBlock';
import { import {
listOpener, listOpener,
type PreviewedValue, type PreviewedValue,
@@ -85,28 +85,10 @@ const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
struct: 'Structs', struct: 'Structs',
}; };
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
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. */ /** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */
const newField = (key: string, kind: StructFieldKind): StructFieldDef => const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
kind === 'list' ? { key, kind, items: 'number' } : { key, kind }; kind === 'list' ? { key, kind, items: 'number' } : { key, kind };
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
list: '≡',
};
/** /**
* Structure-defining facts of an asset selection: the schema keys and kinds, plus the fields of a * 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. * 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 { function kindMark(def: PropertyDef): Label {
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' }); return glyphMark(VARIABLE_KIND_MARKS[def.kind] ?? '?');
mark.minWidth = 18;
return mark;
} }
/** /**
@@ -304,9 +284,7 @@ function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef
const row = new HStack({ gap: Theme.gap, align: 'center' }); const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight; row.minHeight = Theme.rowHeight;
row.add(new Spacer(FIELD_INDENT)); row.add(new Spacer(FIELD_INDENT));
const mark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' }); row.add(glyphMark(FIELD_KIND_MARKS[field.kind]));
mark.minWidth = 18;
row.add(mark);
row.add( row.add(
new TextField(field.key, { new TextField(field.key, {
width: 90, width: 90,
@@ -344,91 +322,31 @@ function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef
host.body.add(foot); host.body.add(foot);
} }
/** /** A struct variable, with the shape editable: this is the asset that declares it. */
* 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( function buildStructVariable(
host: InspectorHost, host: InspectorHost,
asset: BehaviorAsset, asset: BehaviorAsset,
def: StructDef, def: StructDef,
value: PropertyValue, value: PropertyValue,
): void { ): 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<Asset>(host, def, assetSpec(host, asset.id, def.key), (field) => ({ const perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key), (field) => ({
kind: 'asset', kind: 'asset',
assetId: asset.id, assetId: asset.id,
key: def.key, key: def.key,
field, field,
})); }));
const fields = new Map<string, (item: PropertyValue) => void>(); const run = (command: Command): boolean => host.state.execute(command);
for (const field of def.fields) { host.fields.set(
const row = new HStack({ gap: Theme.gap, align: 'center' }); def.key,
row.minHeight = Theme.rowHeight; buildStructBlock(host, def, value, perField, {
row.add(new Spacer(FIELD_INDENT)); edit: {
const fieldMark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' }); renameStruct: (next) => run(new RenameVariableCommand(asset.id, def.key, next)),
fieldMark.minWidth = 18; removeStruct: () => run(new RemoveVariableCommand(asset.id, def.key)),
row.add(fieldMark); renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
row.add( removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
new TextField(field.key, { addField: (kind) =>
width: 90, run(new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(def.fields), kind))),
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: () => {},
});
} }
+28 -19
View File
@@ -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. * Adds the trailing ↺/↑/extra buttons to a row and returns the refresh that re-evaluates them.
* The inspector is entirely generated through this function: adding a new property * Separate from `createFieldRow` because a struct block wears the same buttons on its head row.
* 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( export function attachRowActions(row: HStack, rowActions: RowActions): () => void {
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 actions: { button: Button; glyph: string; available: FieldActions }[] = []; const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
const addActions = (glyph: string, available: FieldActions): void => { const addActions = (glyph: string, available: FieldActions): void => {
// A floor, not a width: the same button also carries the two-glyph menu form "↺ ▾". // 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(); if (changed) row.relayout();
}; };
refreshActions(); 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 { return {
row, row,
+97 -74
View File
@@ -41,10 +41,18 @@ import { Button, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions'; import { addCollider, createContainer, removeObjects } from '../actions';
import { aspectActions, sizeCommand } from './aspectAction'; import { aspectActions, sizeCommand } from './aspectAction';
import { defSignature } from './assetSection'; 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 { addGap, addNote, addTitle, type InspectorHost } from './host';
import type { ListSlot } from '../state';
import { parentRow } from './parentRow'; 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 { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
@@ -309,85 +317,100 @@ function propertyRow(
const overrideSpec: PreviewedValue<ObjectRef> = { const overrideSpec: PreviewedValue<ObjectRef> = {
target: () => currentRef(host.state, holderId, objectId), target: () => currentRef(host.state, holderId, objectId),
read: (ref) => ref.object.overrides[def.key], read: (ref) => ref.object.overrides[def.key],
current: () => currentProps(host.state, holderId, objectId)?.[def.key],
write: (ref, value) => ref.setOverride(def.key, value), write: (ref, value) => ref.setOverride(def.key, value),
remove: (ref) => ref.setOverride(def.key, undefined), remove: (ref) => ref.setOverride(def.key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value), commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
}; };
const row = createFieldRow( const fieldSlot = (field: string): ListSlot => ({
host, kind: 'object',
def, holderId,
values[def.key] ?? def.default, objectId,
{ key: def.key,
...previewingHandlers<ObjectRef>(host, overrideSpec), field,
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), });
...structFields<ObjectRef>(host, def, overrideSpec, (field) => ({ const rowActions: RowActions = {
kind: 'object', extra: aspectActions(
holderId, host,
objectId, owner,
key: def.key, def,
field, () => 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: () => {
extra: aspectActions( const { template, applyToAsset } = bases();
host, return [
owner, ...(template
def, ? [
() => currentProps(host.state, holderId, objectId), {
(values) => label: 'Apply to container',
host.state.execute( onSelect: () =>
sizeCommand(values, (axis, value) => new SetOverrideCommand(holderId, objectId, axis, value)), host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)),
), },
), ]
reset: () => { : []),
const { template, asset } = bases(); ...(applyToAsset
return [ ? [
...(template {
? [ label: 'Apply to asset',
{ onSelect: () =>
label: 'Reset to container', host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)),
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)),
},
]
: []),
];
},
}, },
); };
// 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<ObjectRef>(host, def, overrideSpec, fieldSlot),
{ actions: rowActions },
)
: createFieldRow(
host,
def,
values[def.key] ?? def.default,
{
...previewingHandlers<ObjectRef>(host, overrideSpec),
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
...structFields<ObjectRef>(host, def, overrideSpec, fieldSlot),
},
rowActions,
);
host.fields.set(def.key, row); host.fields.set(def.key, row);
host.body.add(row.row); if (def.kind !== 'struct') host.body.add(row.row);
return row; return row;
} }
+7 -1
View File
@@ -47,7 +47,7 @@ export function structFieldsOf<Target>(
...previewingHandlers<Target>(host, { ...previewingHandlers<Target>(host, {
...spec, ...spec,
toModel: (input, target) => ({ toModel: (input, target) => ({
...structOf(def.fields, spec.read(target)), ...structOf(def.fields, (spec.current ?? spec.read)(target)),
[field.key]: coerceFieldValue(input, field), [field.key]: coerceFieldValue(input, field),
}), }),
}), }),
@@ -63,6 +63,12 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
target: () => Target | undefined; target: () => Target | undefined;
/** The stored value; undefined means the key is not set on the target at all. */ /** The stored value; undefined means the key is not set on the target at all. */
read: (target: Target) => Model | undefined; 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; write: (target: Target, value: Model) => void;
/** Unsets the key again; needed wherever `read` can return undefined. */ /** Unsets the key again; needed wherever `read` can return undefined. */
remove?: (target: Target) => void; remove?: (target: Target) => void;
+145
View File
@@ -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<StructFieldKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
list: '≡',
};
export const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
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<string, (item: PropertyValue) => 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 });
}
+85
View File
@@ -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);
});
});
+119
View File
@@ -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<typeof setup>;
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<SceneObject>({ 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<string, PropertyValue> }>(
{ 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' });
});
});
+94 -2
View File
@@ -3,7 +3,7 @@
* same row, and writing one field keeps the others – the trap a shared snapshot would fall into. * same row, and writing one field keeps the others – the trap a shared snapshot would fall into.
*/ */
export async function structVariable(t) { 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?". */ /** `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 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 mutate('__gm.state.openList(null)');
await sleep(200); 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(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'structy'); 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.select(null);
__gm.state.preview(); __gm.state.preview();
`); `);
await sleep(200); 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)); check('the step leaves the fixture as it found it', left === 0, String(left));
} }