Give every behavior variable one row shape on asset and instance

buildStructBlock only covered structs, so a placed instance drew numbers,
text, bools and lists through createFieldRow on a 64 px name column while
its struct block sat on the asset's grid below it. variableRows.ts now
builds the row for every variable kind on both sides; the edit option
decides what the owner may change (asset: rename, x, item kind; instance:
revert and push). Label and Button take a fixed width so the mark column
no longer shifts names when a list shows its item-kind picker.

The two structFields calls that became dead with 9f48257 are gone. The
e2e steps locate the Enabled toggle through __gm.inspectorField instead
of clicking a fixed offset from its label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 07:27:29 +02:00
1 parent 9f482573e5
commit 631cf54c72
17 files changed
+515 -472

No files matched your search

+10 -6
View File
@@ -110,8 +110,9 @@ shown on the node and editable in the inspector.
because no tab may reach into the `Editor` to switch itself on. An **unconnected `list` input**
of a node is the same button and the same tab: its inline value lives in the very same params
bag a setting does, and the slot stops resolving the moment an edge feeds the port. What a list
variable holds is picked from its **kind mark** (`≡#`, `≡Aa`, `≡⊞`), which doubles as the
chooser; changing it runs the declared default, the behavior's own value and every override
variable holds is picked from its **kind mark** (`≡#`, `≡Aa`, `≡⊞`), which on the asset doubles
as the chooser – a click opens the menu, with no chevron, since the mark has to stay as narrow as
its column; changing it runs the declared default, the behavior's own value and every override
through the new declaration in one command. A list of structs then declares its item shape with
the same `+ Field` control a struct variable has – names and kinds only, since the values are
edited row by row in the tab. A **struct** variable is the other way round: few fields, so they fit under
@@ -120,10 +121,13 @@ 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
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
stored value keeps a shape its declaration no longer promises. A placed instance draws the very
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.
stored value keeps a shape its declaration no longer promises. A placed instance draws every
variable, whatever its kind, with the very same rows (`buildVariable`), minus the renaming, the
kind chooser and the `+ Field` / `×` buttons: it may override the values, not declare them. A
struct's ↺ / ↑ sit on its head row, because an override covers the whole bundle rather than a
single field. All of it sits on **one column grid** with the rest of the inspector: mark (28 px)
plus name (90 px) is exactly as wide as a plain row's label, a nested field's name gives up what
its indent takes, and names are cut with `…` rather than pushing their value out of line.
- **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
viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up
+3 -2
View File
@@ -56,7 +56,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
* *minimum* width, so a label wider than the column pushes its control right – which is why a
* fixed offset from the label's own text is the wrong way to click one.
*/
const fieldOf = (label: string): { x: number; y: number } | null => {
const fieldOf = (label: string): { x: number; y: number; left: number } | null => {
let found: Text | null = null;
const walk = (node: Container): void => {
if (found !== null || !node.visible) return;
@@ -73,7 +73,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const row = owner?.parent;
if (!row || !owner) return null;
const control = row.children[row.children.indexOf(owner) + 1];
return control instanceof Widget ? centreOf(control) : null;
if (!(control instanceof Widget)) return null;
return { ...centreOf(control), left: control.getGlobalPosition().x };
};
/**
* The visible text of the overlay layer, in drawing order – everything an open popup shows.
+25 -179
View File
@@ -15,15 +15,10 @@ import {
} 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';
import {
FIELD_KINDS,
LIST_ITEM_KINDS,
type ListItemKind,
type PropertyDef,
schemaForAsset,
type StructDef,
type StructFieldDef,
type StructFieldKind,
VARIABLE_KINDS,
@@ -31,25 +26,12 @@ import {
} from '../../core/properties/PropertySchema';
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 { Dropdown, HStack, Label, Menu, TextField, Theme } from '../../ui';
import { aspectActions, sizeCommand } from './aspectAction';
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import type { ListDef } from '../list/slot';
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
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,
previewingHandlers,
structFields,
structFieldsOf,
} from './previewHandlers';
import { buildVariable, type VariableEditing } from './variableRows';
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
number: 'Number',
@@ -58,33 +40,6 @@ const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
list: 'List',
struct: 'Struct',
};
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the
// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
list: '≡',
struct: '⊞',
};
/** The mark a list wears for what it holds – `≡` plus the item's own mark. */
const LIST_ITEM_MARKS: Record<ListItemKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
struct: '⊞',
};
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
number: 'Numbers',
boolean: 'Booleans',
text: 'Texts',
color: 'Colors',
struct: 'Structs',
};
/** 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 };
@@ -99,13 +54,19 @@ export function assetSignature(project: Project, assetId: string): string {
}
export function defSignature(def: PropertyDef): string {
const declared = def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : [];
const fields = declared.map((f) => `${f.key}:${f.kind}`).join('+');
const fields = declaredFields(def)
.map((f) => `${f.key}:${f.kind}`)
.join('+');
// A list's item kind decides whether it has field rows at all, so it is structure too.
const items = def.kind === 'list' ? `/${def.items}` : '';
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
}
/** The fields a struct declares, or the item shape of a list of structs. */
function declaredFields(def: PropertyDef): readonly StructFieldDef[] {
return def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : [];
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
export function buildAssetSection(host: InspectorHost, assetId: string): void {
const asset = findAsset(host.state.project, assetId);
@@ -199,7 +160,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
};
}
/** Variables of a behavior: kind mark, name, default value, remove – plus an "add" menu. */
/** Variables of a behavior, each with its declaration editable – plus an "add" menu. */
function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
@@ -220,133 +181,18 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
if (asset.variables.length === 0) addNote(host, 'No variables yet.');
const values = resolveAssetProps(asset);
const run = (command: Command): void => void host.state.execute(command);
for (const def of asset.variables) {
if (def.kind === 'struct') {
buildStructVariable(host, asset, def, values[def.key] ?? def.default);
continue;
}
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(def.kind === 'list' ? itemKindPicker(host, asset, def) : kindMark(def));
row.add(
new TextField(def.key, {
width: 90,
onChange: (next) => {
host.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim()));
},
}),
);
const field = createField(host, def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)),
}),
);
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
host.body.add(row);
if (def.kind === 'list' && def.items === 'struct') buildItemFields(host, asset, def);
const edit: VariableEditing = {
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)),
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
setItems: (items) => run(new SetListItemsCommand(asset.id, def.key, items)),
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(declaredFields(def)), kind))),
};
const value = values[def.key] ?? def.default;
host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit }));
}
}
function kindMark(def: PropertyDef): Label {
return glyphMark(VARIABLE_KIND_MARKS[def.kind] ?? '?');
}
/**
* The kind mark of a list doubles as the chooser for what it holds – there is no room for a
* column of its own, and the mark is the one place that already says it. Changing it runs every
* value through the new declaration, so a list of numbers becomes a list of texts and back.
*/
function itemKindPicker(host: InspectorHost, asset: BehaviorAsset, def: ListDef): Dropdown {
// Wide enough that the widest mark (`≡Aa`) is not truncated away behind the chevron.
const picker = new Dropdown(`≡${LIST_ITEM_MARKS[def.items]}`, { variant: 'ghost', width: 68 });
picker.onClick = () => {
new Menu(
LIST_ITEM_KINDS.filter((items) => items !== def.items).map((items) => ({
label: LIST_ITEM_LABELS[items],
onSelect: () => host.state.execute(new SetListItemsCommand(asset.id, def.key, items)),
})),
).openBelow(picker);
};
return picker;
}
/**
* The shape of the items of a list of structs: names and kinds only (primitives, see `ListFieldDef`).
* Their values are edited in the List tab, one row per item, which is why there is no value column.
*/
function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef): 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(glyphMark(FIELD_KIND_MARKS[field.kind]));
row.add(
new TextField(field.key, {
width: 90,
onChange: (next) =>
host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())),
}),
);
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)),
}),
);
host.body.add(row);
}
const foot = new HStack({ gap: Theme.gap, align: 'center' });
foot.minHeight = Theme.rowHeight;
foot.add(new Spacer(FIELD_INDENT));
foot.add(new Spacer(), 1);
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
new Menu(
FIELD_KINDS.map((kind) => ({
label: FIELD_KIND_LABELS[kind],
onSelect: () =>
host.state.execute(
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields ?? []), kind }),
),
})),
).openBelow(add);
};
foot.add(add);
host.body.add(foot);
}
/** 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 perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key), (field) => ({
kind: 'asset',
assetId: asset.id,
key: def.key,
field,
}));
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))),
},
}),
);
}
+8 -2
View File
@@ -66,8 +66,14 @@ export interface FieldRow {
setModified(modified: boolean): void;
}
/** Width of the label column every inspector row aligns to. */
const FIELD_LABEL_WIDTH = 64;
/**
* The inspector's one column grid. A variable row is mark + name, a plain row a label spanning
* both, so every value starts at the same x. The mark column holds `≡Aa` (24 px measured).
*/
export const MARK_WIDTH = 28;
export const NAME_WIDTH = 90;
export const FIELD_INDENT = 12;
const FIELD_LABEL_WIDTH = MARK_WIDTH + Theme.gap + NAME_WIDTH;
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
export interface FieldAction {
+15 -32
View File
@@ -45,14 +45,8 @@ import { createFieldRow, type FieldRow, labelledRow, type RowActions } from './f
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import type { ListSlot } from '../state';
import { parentRow } from './parentRow';
import {
listOpener,
type PreviewedValue,
previewingHandlers,
structFields,
structFieldsOf,
} from './previewHandlers';
import { buildStructBlock } from './structBlock';
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers';
import { buildVariable } from './variableRows';
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
@@ -231,7 +225,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values);
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior');
markObjectRows(host, object, asset, target);
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
@@ -312,6 +306,7 @@ function propertyRow(
owner: Asset,
def: PropertyDef,
values: Record<string, PropertyValue>,
variable = false,
): FieldRow {
const bases = () => deviations(host.state, holderId, objectId, def.key);
const overrideSpec: PreviewedValue<ObjectRef> = {
@@ -387,30 +382,18 @@ 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,
);
const value = values[def.key] ?? def.default;
const handlers = {
...previewingHandlers<ObjectRef>(host, overrideSpec),
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
...structFields<ObjectRef>(host, def, overrideSpec, fieldSlot),
};
// A variable reads exactly like it does on its asset, which adds its own rows to the body.
const row = variable
? buildVariable(host, def, value, handlers, { actions: rowActions })
: createFieldRow(host, def, value, handlers, rowActions);
host.fields.set(def.key, row);
if (def.kind !== 'struct') host.body.add(row.row);
if (!variable) host.body.add(row.row);
return row;
}
-145
View File
@@ -1,145 +0,0 @@
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 });
}
+255
View File
@@ -0,0 +1,255 @@
import type { PropertyValue } from '../../core/model/Properties';
import {
FIELD_KINDS,
LIST_ITEM_KINDS,
type ListItemKind,
type PropertyDef,
STRUCT_FIELD_KINDS,
type StructDef,
type StructFieldDef,
type StructFieldKind,
} from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui';
import type { ListDef } from '../list/slot';
import {
attachRowActions,
createField,
FIELD_INDENT,
type FieldHandlers,
fieldDef,
type FieldRow,
MARK_WIDTH,
NAME_WIDTH,
type RowActions,
structFieldValue,
} from './fieldFactory';
import { addNote, type InspectorHost } from './host';
// Keyed by the wide `PropertyDef['kind']` because variables are `PropertyDef`s, but only the
// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
struct: '⊞',
};
const LIST_ITEM_MARKS: Record<ListItemKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
struct: '⊞',
};
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
number: 'Numbers',
boolean: 'Booleans',
text: 'Texts',
color: 'Colors',
struct: 'Structs',
};
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
list: '≡',
};
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
list: 'List',
};
/** Changing a declaration – offered only on the asset that declares it. */
export interface VariableEditing {
rename: (next: string) => void;
remove: () => void;
setItems: (items: ListItemKind) => void;
renameField: (field: StructFieldDef, next: string) => void;
removeField: (field: StructFieldDef) => void;
addField: (kind: StructFieldKind) => void;
}
export interface VariableOptions {
edit?: VariableEditing;
/** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */
actions?: RowActions;
}
const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
/**
* One variable, drawn the same on its asset and on a placed object – minus `edit` there, since the
* shape belongs to the asset. Adds its rows to the body itself and returns the first.
*/
export function buildVariable(
host: InspectorHost,
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
options: VariableOptions = {},
): FieldRow {
const { edit, actions } = options;
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
const head = gridRow(0, markOf(def, edit), name);
let setValue: (next: PropertyValue) => void = () => {};
if (def.kind === 'struct') {
head.add(new Spacer(), 1);
if (edit) head.add(addFieldMenu(STRUCT_FIELD_KINDS, edit));
} else {
setValue = addValue(head, createField(host, def, value, handlers));
}
if (edit) head.add(removeButton(edit.remove));
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
host.body.add(head);
if (def.kind === 'struct') setValue = structFieldRows(host, def, value, handlers, edit);
if (def.kind === 'list' && def.items === 'struct' && edit) itemShapeRows(host, def, edit);
return {
row: head,
setValue: (next) => setValue(next),
setModified: (modified) => {
if (name instanceof Label) name.setStyle(modified ? 'override' : 'muted');
refreshActions();
},
};
}
/** One row per field, each with its own value; returns the setter that pushes a bundle into them. */
function structFieldRows(
host: InspectorHost,
def: StructDef,
value: PropertyValue,
handlers: FieldHandlers,
edit: VariableEditing | undefined,
): (next: PropertyValue) => void {
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 = fieldRow(field, edit);
const item = createField(
host,
fieldDef(field),
structFieldValue(value, field),
handlers.structFieldHandlers?.(field) ?? READ_ONLY,
);
setters.set(field.key, addValue(row, item));
if (edit) row.add(removeButton(() => edit.removeField(field)));
host.body.add(row);
}
return (next) => {
for (const field of def.fields) setters.get(field.key)?.(structFieldValue(next, field));
};
}
/**
* The item shape of a list of structs: names and kinds only (primitives, see `ListFieldDef`). The
* values are edited in the List tab, one row per item, which is why there is no value column.
*/
function itemShapeRows(host: InspectorHost, def: ListDef, edit: VariableEditing): void {
for (const field of def.fields ?? []) {
const row = fieldRow(field, edit);
row.add(new Spacer(), 1);
row.add(removeButton(() => edit.removeField(field)));
host.body.add(row);
}
const foot = new HStack({ gap: Theme.gap, align: 'center' });
foot.minHeight = Theme.rowHeight;
foot.add(new Spacer(), 1);
foot.add(addFieldMenu(FIELD_KINDS, edit));
host.body.add(foot);
}
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
return gridRow(1, markLabel(FIELD_KIND_MARKS[field.kind]), name);
}
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
function gridRow(depth: number, mark: Widget, name: Widget): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
if (depth > 0) row.add(new Spacer(FIELD_INDENT));
row.add(mark);
row.add(name);
return row;
}
function nameWidth(depth: number): number {
return NAME_WIDTH - depth * (FIELD_INDENT + Theme.gap);
}
/** A text field where the name may be changed, else a label whose text sits where the field's would. */
function nameCell(text: string, depth: number, rename: ((next: string) => void) | undefined): Widget {
const width = nameWidth(depth);
return rename
? new TextField(text, { width, onChange: (next) => rename(next.trim()) })
: new Label(text, { style: 'muted', width, padding: Theme.fieldPadding });
}
function addValue(
row: HStack,
field: { widget: Widget; grow: boolean; setValue: (v: PropertyValue) => void },
): (v: PropertyValue) => void {
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
return field.setValue;
}
function markOf(def: PropertyDef, edit: VariableEditing | undefined): Widget {
if (def.kind !== 'list') return markLabel(VARIABLE_KIND_MARKS[def.kind] ?? '?');
const glyph = `≡${LIST_ITEM_MARKS[def.items]}`;
return edit ? itemKindPicker(glyph, def.items, edit) : markLabel(glyph);
}
function markLabel(glyph: string): Label {
return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH });
}
/**
* A kind mark that can be clicked. The label may take the button's whole width: the column is
* sized for its widest glyph, and the usual inset would cut `≡Aa` to `≡A…`.
*/
class MarkButton extends Button {
protected override labelSlot(): number {
return this.w;
}
}
/**
* On the asset, the mark of a list doubles as the chooser for what it holds – it is the one place
* that already says it. Changing it runs every value through the new declaration.
*/
function itemKindPicker(glyph: string, current: ListItemKind, edit: VariableEditing): Button {
const picker = new MarkButton(glyph, { variant: 'ghost', width: MARK_WIDTH, style: 'small' });
picker.onClick = () => {
new Menu(
LIST_ITEM_KINDS.filter((items) => items !== current).map((items) => ({
label: LIST_ITEM_LABELS[items],
onSelect: () => edit.setItems(items),
})),
).openBelow(picker);
};
return picker;
}
function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing): Dropdown {
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
new Menu(
kinds.map((kind) => ({ label: FIELD_KIND_LABELS[kind], onSelect: () => edit.addField(kind) })),
).openBelow(add);
};
return add;
}
function removeButton(onClick: () => void): Button {
return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick });
}
+7 -3
View File
@@ -1,7 +1,7 @@
import { type FederatedPointerEvent, Graphics, Text, CanvasTextMetrics } from 'pixi.js';
import { drawControlBox } from '../draw';
import { centreY } from '../math';
import { Theme, textStyle } from '../Theme';
import { Theme, textStyle, type TextStyleToken } from '../Theme';
import { type Size, Widget } from '../Widget';
import { truncate } from './Label';
@@ -12,6 +12,8 @@ export interface ButtonOptions {
/** Floor only – for a button whose label changes between forms and must grow with them. */
minWidth?: number;
align?: 'center' | 'left';
/** Text style of the label when the button is not on an accent fill. */
style?: TextStyleToken;
onClick?: () => void;
}
@@ -26,6 +28,7 @@ export class Button extends Widget {
variant: NonNullable<ButtonOptions['variant']>;
protected readonly align: NonNullable<ButtonOptions['align']>;
protected readonly fixedWidth: number;
private readonly style: TextStyleToken;
onClick?: () => void;
constructor(text: string, options: ButtonOptions = {}) {
@@ -39,7 +42,8 @@ export class Button extends Widget {
this.fixedWidth = options.width ?? 0;
this.minWidth = this.fixedWidth || (options.minWidth ?? 0);
this.fullText = text;
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') });
this.style = options.style ?? 'body';
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : this.style) });
this.addChild(this.bg, this.labelText);
// Leaving while pressed cancels the click: a release elsewhere must not activate.
@@ -148,7 +152,7 @@ export class Button extends Widget {
this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke });
}
// Style first: it decides the glyph widths that the truncation then measures against.
this.labelText.style = textStyle(this.onAccent ? 'accent' : 'body');
this.labelText.style = textStyle(this.onAccent ? 'accent' : this.style);
const label = this.labelText;
label.text = truncate(this.fullText, label.style, this.labelSlot());
const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2;
+6
View File
@@ -7,6 +7,8 @@ export interface LabelOptions {
style?: TextStyleToken;
align?: 'left' | 'center' | 'right';
padding?: number;
/** Fixed width – a column that must not grow with a long text; the text is truncated instead. */
width?: number;
}
/** Single-line text that truncates with an ellipsis when narrower than its content. */
@@ -15,6 +17,7 @@ export class Label extends Widget {
private fullText: string;
private readonly align: NonNullable<LabelOptions['align']>;
private readonly padding: number;
private readonly fixedWidth: number;
constructor(text = '', options: LabelOptions = {}) {
super();
@@ -22,6 +25,8 @@ export class Label extends Widget {
this.fullText = text;
this.align = options.align ?? 'left';
this.padding = options.padding ?? 0;
this.fixedWidth = options.width ?? 0;
this.minWidth = this.fixedWidth;
this.textNode = new Text({ text, style: textStyle(options.style ?? 'body') });
this.addChild(this.textNode);
this.minHeight = Theme.rowHeight;
@@ -45,6 +50,7 @@ export class Label extends Widget {
}
override measure(): Size {
if (this.fixedWidth) return { w: this.fixedWidth, h: this.minHeight };
const metrics = CanvasTextMetrics.measureText(this.fullText, this.textNode.style);
return {
w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2),
-85
View File
@@ -1,85 +0,0 @@
/**
* 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);
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* A variable is drawn by one builder on the asset that declares it and on a placed object, and
* every row of the inspector shares one column grid. The asset may rename and reshape; 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 go by the name's widget.
*/
import { describe, expect, it } from 'vitest';
import { createEmptyProject } from '../../src/core/model/Project';
import type { PropertyDef, StructDef } from '../../src/core/properties/PropertySchema';
import { HStack, Label, TextField, Theme, VStack, type Widget } from '../../src/ui';
import { type FieldHandlers, labelledRow } from '../../src/editor/inspector/fieldFactory';
import type { InspectorHost } from '../../src/editor/inspector/host';
import { buildVariable, type VariableEditing } from '../../src/editor/inspector/variableRows';
import { EditorState } from '../../src/editor/state';
const STRUCT: 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' };
const PLAIN: PropertyDef[] = [
{ key: 'speed', label: 'speed', kind: 'number', default: 0 },
{ key: 'title', label: 'title', kind: 'text', default: '' },
{ key: 'alive', label: 'alive', kind: 'boolean', default: true },
{ key: 'cells', label: 'cells', kind: 'list', default: [], items: 'text' },
{ key: 'a_rather_long_variable_name', label: 'a_rather_long_variable_name', kind: 'number', default: 0 },
];
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 = (): FieldHandlers => ({ onInput: () => {}, onChange: () => {} });
const withFields: FieldHandlers = { ...noHandlers(), structFieldHandlers: noHandlers };
const EDIT: VariableEditing = {
rename: () => {},
remove: () => {},
setItems: () => {},
renameField: () => {},
removeField: () => {},
addField: () => {},
};
const rows = (host: InspectorHost): Widget[][] =>
host.body.children.map((row) => ('children' in row ? (row.children as Widget[]) : []));
/** Every widget of the body 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 isName = (w: Widget, name: string) =>
(w instanceof Label && w.text === name) || (w instanceof TextField && w.value === name);
/** Where the widget after the name starts – summed from the measured cells before it. */
function valueX(host: InspectorHost, name: string): number {
for (const row of host.body.children) {
if (!(row instanceof HStack)) continue;
const items = row.items;
const at = items.findIndex((w) => isName(w, name));
if (at < 0) continue;
return items.slice(0, at + 1).reduce((x, w) => x + w.measure().w + row.gap, row.padding);
}
throw new Error(`no row named ${name}`);
}
function buildAll(edit?: VariableEditing): InspectorHost {
const host = makeHost();
for (const def of PLAIN) buildVariable(host, def, def.default, noHandlers(), { edit });
buildVariable(host, STRUCT, VALUE, withFields, { edit });
return host;
}
const NAMES = [...PLAIN.map((d) => d.key), 'x', 'tag'];
describe('the column grid', () => {
it('starts every value at the same x on a placed object, whatever the kind or depth', () => {
const host = buildAll();
const xs = NAMES.map((name) => valueX(host, name));
expect(new Set(xs), JSON.stringify(xs)).toEqual(new Set([xs[0]]));
});
it('and at that same x on the asset, where names are fields and the list mark is a picker', () => {
const placed = valueX(buildAll(), 'speed');
const host = buildAll(EDIT);
const xs = NAMES.map((name) => valueX(host, name));
expect(xs, JSON.stringify(xs)).toEqual(NAMES.map(() => placed));
});
it('which is where a plain labelled row starts its value too', () => {
const { label } = labelledRow('Enabled');
expect(label.minWidth + Theme.gap).toBe(valueX(buildAll(), 'speed'));
});
});
describe('a struct on a placed object', () => {
it('is a head row plus one row per field', () => {
const host = makeHost();
buildVariable(host, STRUCT, VALUE, withFields);
expect(host.body.children.length).toBe(1 + STRUCT.fields.length);
});
it('names the bundle and its fields with labels, so they cannot be edited', () => {
const host = makeHost();
buildVariable(host, STRUCT, VALUE, withFields);
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();
buildVariable(host, STRUCT, VALUE, withFields);
expect(carrying(host, 'tee')[0]).toBeInstanceOf(TextField);
});
});
describe('the same struct on the asset that declares it', () => {
it('names the bundle and its fields with text fields, so they can be renamed', () => {
const host = makeHost();
buildVariable(host, STRUCT, VALUE, withFields, { edit: EDIT });
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();
buildVariable(host, STRUCT, VALUE, withFields, { edit: EDIT });
expect(host.body.children.length).toBe(1 + STRUCT.fields.length);
});
});
+7 -8
View File
@@ -16,7 +16,6 @@ export async function play(t) {
click,
clickText,
dragAsset,
locate,
press,
holdKey,
undo,
@@ -183,9 +182,9 @@ export async function play(t) {
// of those left the first in hierarchy order takes the view. The second camera is parked far
// from the origin, so "seen through it" is provable – both crates fall outside its frame.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
const enabledRow = await locate('Enabled');
// The checkbox sits right of the 64 px label column; locate() gives the label's text centre.
await click(enabledRow.x + 55, enabledRow.y);
// Found through its row, not an offset from the label: the label column's width is layout.
const enabledRow = await json("__gm.inspectorField('Enabled')");
await click(enabledRow.x, enabledRow.y);
await sleep(150);
check(
'the camera has the enabled switch like any other object',
@@ -221,8 +220,8 @@ export async function play(t) {
await stop();
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
const switchBackOn = await locate('Enabled');
await click(switchBackOn.x + 55, switchBackOn.y);
const switchBackOn = await json("__gm.inspectorField('Enabled')");
await click(switchBackOn.x, switchBackOn.y);
await sleep(150);
await play();
info = await gameInfo();
@@ -279,8 +278,8 @@ export async function play(t) {
// Switching the only camera off leaves nothing to preview – and says so, live on the tab.
await evaluate(`__gm.showTab('game')`);
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
const previewSwitch = await locate('Enabled');
await click(previewSwitch.x + 55, previewSwitch.y);
const previewSwitch = await json("__gm.inspectorField('Enabled')");
await click(previewSwitch.x, previewSwitch.y);
await sleep(200);
info = await gameInfo();
check(
+4 -4
View File
@@ -324,8 +324,8 @@ export async function containers(t) {
(await noticeAfter()).includes('container template'),
await noticeAfter(),
);
const parentRow = await locate('Parent');
await click(parentRow.x + 55, parentRow.y);
const parentRow = await json("__gm.inspectorField('Parent')");
await click(parentRow.x, parentRow.y);
await sleep(150);
check(
'the Parent picker does not even open for resolved content',
@@ -393,8 +393,8 @@ export async function containers(t) {
await selectObject(redC);
// Unpacking keeps the derived ids, so an id that merely *looks* like content of a template must
// not freeze the picker any more – the inspector has to read the holder, not the id.
const unpackedParentRow = await locate('Parent');
await click(unpackedParentRow.x + 55, unpackedParentRow.y);
const unpackedParentRow = await json("__gm.inspectorField('Parent')");
await click(unpackedParentRow.x, unpackedParentRow.y);
await sleep(150);
check(
'unpacked content offers the Parent picker like any ordinary object',
+2 -3
View File
@@ -37,14 +37,13 @@ export async function textAndEnabled(t) {
// 17a. The enabled switch: every object has it, switching it off is a normal override, and a
// switched-off object disappears from the scene view too (it stays in the hierarchy).
const enabledRow = await evaluate(`__gm.locate('Enabled')`);
const enabledRow = await json("__gm.inspectorField('Enabled')");
check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow));
const visibleBefore = (await json('__gm.viewportInfo()')).visibleItems;
const gizmoOf = async (id) => (await json('__gm.gizmos()')).find((g) => g.id === id) ?? null;
const gizmoOn = await gizmoOf('otext');
check('a selected object is framed at full strength', gizmoOn?.alpha === 1, JSON.stringify(gizmoOn));
// The checkbox sits right of the 64 px label column; locate() gives the label's text centre.
await click(enabledRow.x + 55, enabledRow.y);
await click(enabledRow.x, enabledRow.y);
await sleep(100);
const textObject = await object('otext');
check(
+2 -3
View File
@@ -26,9 +26,8 @@ export async function menus(t) {
// 18. A picker with 40+ entries is capped in height and stays fully on screen instead of
// running off the bottom edge.
// The dropdown sits right of the 64 px label column; locate() gives the label's text centre.
const parentRow = await locate('Parent');
await click(parentRow.x + 55, parentRow.y);
const parentRow = await json("__gm.inspectorField('Parent')");
await click(parentRow.x, parentRow.y);
const rect = await json('__gm.overlayRect()');
const screenHeight = await json('window.innerHeight');
check(
+9
View File
@@ -122,6 +122,15 @@ export async function listTab(t) {
listy.variables[0].items === 'number' && JSON.stringify(listy.props.nums) === '[1,2]',
JSON.stringify(listy.props.nums),
);
// The picker sits in the mark column, so it may not push the value off the inspector's grid.
const lefts = await json(
`[__gm.inspectorField('Name')?.left ?? null, __gm.inspectorField('nums')?.left ?? null]`,
);
check(
'the picking mark keeps the list on the column every row shares',
lefts[0] !== null && lefts[0] === lefts[1],
JSON.stringify(lefts),
);
// 7. A list whose target is gone empties the tab rather than showing a stale one.
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
+16
View File
@@ -8,6 +8,10 @@ export async function structVariable(t) {
/** `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 piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece");
/** Left edge of each row's control – one column grid means one number, in the browser's own font. */
const leftsOf = (labels) =>
json(`[${labels.map((l) => `__gm.inspectorField(${JSON.stringify(l)})?.left ?? null`).join(',')}]`);
const oneColumn = (xs) => xs.every((x) => x !== null && x === xs[0]);
// A throwaway behavior carrying one struct variable; removed again at the end of the step.
await mutate(`
@@ -39,6 +43,12 @@ export async function structVariable(t) {
check('a struct variable carries the add-a-field control', (await find('+ Field')) !== null);
check('and a row for each declared field', (await find('x')) !== null && (await find('tag')) !== null);
check('whose value is shown, not a summary of the bundle', (await find('30.000')) !== null);
const assetLefts = await leftsOf(['Name', 'Run in editor', 'x', 'tag']);
check(
'a field value starts on the same column as every other row',
oneColumn(assetLefts),
JSON.stringify(assetLefts),
);
await shot('01-struct-rows');
// 2. Writing one field keeps the others. Two edits in a row without a rebuild in between is
@@ -183,6 +193,12 @@ export async function structVariable(t) {
JSON.stringify({ fieldMark, fieldName, fieldValue }),
);
check('the shape itself cannot be changed from here', (await find('+ Field')) === null);
const placedLefts = await leftsOf(['Name', 'Run in editor', 'Offset X', 'column', 'tag']);
check(
'and its values share the column of position, offset and the switches',
oneColumn(placedLefts) && placedLefts[0] === assetLefts[0],
JSON.stringify({ placedLefts, asset: assetLefts[0] }),
);
// 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.