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
+545
-146
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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: () => {},
|
}),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,26 +317,19 @@ 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,
|
|
||||||
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',
|
kind: 'object',
|
||||||
holderId,
|
holderId,
|
||||||
objectId,
|
objectId,
|
||||||
key: def.key,
|
key: def.key,
|
||||||
field,
|
field,
|
||||||
})),
|
});
|
||||||
},
|
const rowActions: RowActions = {
|
||||||
{
|
|
||||||
extra: aspectActions(
|
extra: aspectActions(
|
||||||
host,
|
host,
|
||||||
owner,
|
owner,
|
||||||
@@ -384,10 +385,32 @@ function propertyRow(
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+94
-2
@@ -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));
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user