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