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

+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' });
});
});