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:
1 parent
9f482573e5
commit
631cf54c72
17 files changed
+510
-467
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
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(def.fields), 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 }));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
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),
|
||||
},
|
||||
rowActions,
|
||||
);
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' })");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user