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:
1 parent
14f542365e
commit
9f482573e5
9 files changed
+606
-207
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user