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

+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),