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
@@ -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))),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user