A kind like colour or texture used to be spelled out in five parallel lists, its port type, its empty value, its glyph, its label and its list tag - 22 literal places for texture alone. core/properties/kinds.ts now holds one row per kind and the rest is derived from it, so adding a kind is one row. The five literal nodes collapse into literals.ts, the six kind menus into one helper. Number, Text, Boolean is now the one order everywhere; the variable and port menus used to list Boolean before Text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
5.8 KiB
TypeScript
171 lines
5.8 KiB
TypeScript
import type { PropertyValue } from '../../core/model/Properties';
|
||
import { kindLabel, kindMark, STRUCT_FIELD_KINDS, type StructFieldKind } from '../../core/properties/kinds';
|
||
import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema';
|
||
import {
|
||
Button,
|
||
Dropdown,
|
||
HStack,
|
||
Label,
|
||
Menu,
|
||
type MenuItem,
|
||
Spacer,
|
||
TextField,
|
||
Theme,
|
||
type Widget,
|
||
} from '../../ui';
|
||
import {
|
||
addValue,
|
||
attachRowActions,
|
||
createField,
|
||
FIELD_INDENT,
|
||
type FieldHandlers,
|
||
fieldDef,
|
||
type FieldRow,
|
||
MARK_WIDTH,
|
||
NAME_WIDTH,
|
||
READ_ONLY,
|
||
removeButton,
|
||
type RowActions,
|
||
structFieldValue,
|
||
} from './fieldFactory';
|
||
import { addNote, type InspectorHost } from './host';
|
||
|
||
/** The menu every "add a kind" button opens: one entry per kind, named as the kind table names it. */
|
||
export function kindMenu<K extends string>(kinds: readonly K[], pick: (kind: K) => void): MenuItem[] {
|
||
return kinds.map((kind) => ({ label: kindLabel(kind), onSelect: () => pick(kind) }));
|
||
}
|
||
|
||
/** Changing a declaration – offered only on the asset that declares it. */
|
||
export interface VariableEditing {
|
||
rename: (next: string) => void;
|
||
remove: () => 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;
|
||
}
|
||
|
||
/**
|
||
* 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, markLabel(kindMark(def.kind)), 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);
|
||
|
||
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));
|
||
};
|
||
}
|
||
|
||
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
|
||
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
|
||
return gridRow(1, markLabel(kindMark(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 });
|
||
}
|
||
|
||
export function markLabel(glyph: string): Label {
|
||
return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH });
|
||
}
|
||
|
||
/**
|
||
* A kind mark that can be clicked – the List tab's way of saying what an item holds. 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;
|
||
}
|
||
}
|
||
|
||
/** The mark of a kind, as a button that offers the others. */
|
||
export function markPicker(glyph: string, items: MenuItem[]): Button {
|
||
const picker = new MarkButton(glyph, { variant: 'ghost', width: MARK_WIDTH, style: 'small' });
|
||
picker.onClick = () => new Menu(items).openBelow(picker);
|
||
return picker;
|
||
}
|
||
|
||
function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing): Dropdown {
|
||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||
add.onClick = () => new Menu(kindMenu(kinds, (kind) => edit.addField(kind))).openBelow(add);
|
||
return add;
|
||
}
|