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

+26 -108
View File
@@ -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<ListItemKind, string> = {
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. */
const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
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
* 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<Asset>(host, def, assetSpec(host, asset.id, def.key), (field) => ({
kind: 'asset',
assetId: asset.id,
key: def.key,
field,
}));
const fields = 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));
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))),
},
}),
);
}
+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.
* 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,
+97 -74
View File
@@ -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<ObjectRef> = {
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<ObjectRef>(host, overrideSpec),
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
...structFields<ObjectRef>(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<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.body.add(row.row);
if (def.kind !== 'struct') host.body.add(row.row);
return row;
}
+7 -1
View File
@@ -47,7 +47,7 @@ export function structFieldsOf<Target>(
...previewingHandlers<Target>(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, Model = PropertyValue> {
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;
+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 });
}