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
+515
-472
No files matched your search
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user