A behavior often needs working state such as a timer or a counter. Until now every variable showed up on each placed object and in the Properties menu, so the inspector filled up with values nobody should touch. A private variable works like any other inside its graph but is not offered anywhere outside. - Each behavior variable gets a lock switch next to Keep. It starts off, so existing projects stay as they are (`privateVariables`). - A placed object shows no private variable, neither in the inspector nor while the game runs. If all are private, it says so. - Switching a variable to private drops its overrides on every placed object in one undo step. Switching back does not restore them. - A running game does not write a private value back as an override, and a project change during the run does not reset it. - `+ Property` no longer offers private variables. A port that names one by hand still reads and writes it without a warning. - The default value of a private variable is read-only in the asset inspector. Its shape (name, fields, type, delete) stays editable. - Keep is now a pin icon instead of a word. With the lock added, the header of a list variable had no room left and the lock covered `+ Item`. Not done: the animation track picker still offers private variables. Tests: ./check.sh -> 186 files, 2711 tests, CHECK EXIT 0. e2e 1,19,20,21,56 -> ALL PASSED, 119 checks. New module 56 has 7 checks with a real mouse. Counter-checks fail as expected: unit without the write-back filter, the sync guard, dropping overrides or loading the flag. Browser with all variables on the object, an unfiltered menu, or no value lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import type { PropertyValue } from '../../core/model/Properties';
|
||
import { type Kind, kindLabel, kindMark, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
|
||
import type { PropertyDef, StructFieldDef } from '../../core/properties/PropertySchema';
|
||
import {
|
||
Button,
|
||
Dropdown,
|
||
HStack,
|
||
Icon,
|
||
Label,
|
||
Menu,
|
||
type MenuEntry,
|
||
type MenuItem,
|
||
Spacer,
|
||
TextField,
|
||
Theme,
|
||
type Widget,
|
||
} from '../../ui';
|
||
import {
|
||
addValue,
|
||
attachRowActions,
|
||
createField,
|
||
FIELD_INDENT,
|
||
type Field,
|
||
type FieldHandlers,
|
||
fieldDef,
|
||
type FieldRow,
|
||
indentedBy,
|
||
MARK_WIDTH,
|
||
nameWidth,
|
||
READ_ONLY,
|
||
removeButton,
|
||
type RowActions,
|
||
structFieldValue,
|
||
} from './fieldFactory';
|
||
import { KIND_ICON_SVG, VARIABLE_SWITCH_ICON_SVG } from '../panels/markIcons';
|
||
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. `path` names the struct a
|
||
* field belongs to, from the variable down (#239): none for the variable's own fields.
|
||
*/
|
||
export interface VariableEditing {
|
||
rename: (next: string) => void;
|
||
remove: () => void;
|
||
renameField: (path: readonly string[], field: StructFieldDef, next: string) => void;
|
||
removeField: (path: readonly string[], field: StructFieldDef) => void;
|
||
addField: (path: readonly string[], kind: NestedKind) => void;
|
||
}
|
||
|
||
/**
|
||
* A behavior's switch for one variable: carried on from one `Run behavior` call to the next (#229),
|
||
* or hidden from everything outside its graph (#257).
|
||
*/
|
||
export interface VariableSwitch {
|
||
on: boolean;
|
||
set: (on: boolean) => void;
|
||
}
|
||
|
||
/**
|
||
* The type of a struct (#253), offered where its declaration is edited. `path` names the struct
|
||
* from the variable down, as for `VariableEditing`.
|
||
*/
|
||
export interface StructTyping {
|
||
/** The Struct asset the struct follows, or null while it holds its own fields – a deleted type too. */
|
||
typeName: (structType: string | undefined) => string | null;
|
||
menu: (path: readonly string[], structType: string | undefined) => MenuEntry[];
|
||
/** The item type of a list (#254); `path` leads to the list field, none for a list variable. */
|
||
listMenu: (path: readonly string[], itemType: string | undefined) => MenuEntry[];
|
||
}
|
||
|
||
export interface VariableOptions {
|
||
edit?: VariableEditing;
|
||
typing?: StructTyping;
|
||
keep?: VariableSwitch;
|
||
hidden?: VariableSwitch;
|
||
/** ↺/↑ 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, typing, keep, hidden, actions } = options;
|
||
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
|
||
const head = gridRow(0, markLabel(def.kind), name);
|
||
let setValue: (next: PropertyValue) => void = () => {};
|
||
let below: Widget | undefined;
|
||
// A struct with a type has the type's fields: they are changed on the Struct asset, not here.
|
||
const typed = def.kind === 'struct' ? (typing?.typeName(def.structType) ?? null) : null;
|
||
// A private value is set in its own graph only (#257): shown, but not edited here.
|
||
const locked = hidden?.on ?? false;
|
||
if (def.kind === 'struct') {
|
||
head.add(new Spacer(), 1);
|
||
if (edit && typed === null) head.add(addFieldMenu(edit, []));
|
||
} else {
|
||
const field = createField(host, def, value, handlers);
|
||
setValue = addValue(head, field);
|
||
below = field.below;
|
||
lockValue(field, locked);
|
||
}
|
||
if (hidden) head.add(switchButton('private', hidden));
|
||
if (keep) head.add(switchButton('keep', keep));
|
||
if (edit) head.add(removeButton(edit.remove));
|
||
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
|
||
host.body.add(head);
|
||
if (def.kind === 'list' && typing)
|
||
host.body.add(itemsRow(1, typing.typeName(def.itemType), () => typing.listMenu([], def.itemType)));
|
||
if (below) host.body.add(indentedBy(below, 1));
|
||
|
||
if (def.kind === 'struct') {
|
||
if (typing) host.body.add(typeRow(1, typed, () => typing.menu([], def.structType)));
|
||
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
|
||
const own = typed === null;
|
||
setValue = structFieldRows(
|
||
host,
|
||
def.fields,
|
||
[],
|
||
value,
|
||
handlers,
|
||
own ? edit : undefined,
|
||
own ? typing : undefined,
|
||
locked,
|
||
);
|
||
}
|
||
|
||
return {
|
||
row: head,
|
||
setValue: (next) => setValue(next),
|
||
setModified: (modified) => {
|
||
if (name instanceof Label) name.setStyle(modified ? 'override' : 'muted');
|
||
refreshActions();
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Lit while the switch is on – the lock while private, the pin while kept; a click flips it. */
|
||
function switchButton(icon: keyof typeof VARIABLE_SWITCH_ICON_SVG, of: VariableSwitch): Button {
|
||
const button = new Button('', {
|
||
variant: 'ghost',
|
||
toggle: true,
|
||
icon: VARIABLE_SWITCH_ICON_SVG[icon],
|
||
padding: 4,
|
||
onClick: () => of.set(!of.on),
|
||
});
|
||
button.setToggled(of.on);
|
||
return button;
|
||
}
|
||
|
||
/** A value – and the items below it – greyed out and deaf to the pointer while `locked`. */
|
||
function lockValue(field: Field, locked: boolean): void {
|
||
if (!locked) return;
|
||
field.widget.setEnabled(false);
|
||
field.below?.setEnabled(false);
|
||
}
|
||
|
||
/**
|
||
* One row per field, each with its own value; a field that is a struct again is a head row with its
|
||
* fields indented below it (#239). `path` names the struct these fields belong to. Returns the
|
||
* setter that pushes a bundle into them.
|
||
*/
|
||
function structFieldRows(
|
||
host: InspectorHost,
|
||
fields: readonly StructFieldDef[],
|
||
path: readonly string[],
|
||
value: PropertyValue,
|
||
handlers: FieldHandlers,
|
||
edit: VariableEditing | undefined,
|
||
typing: StructTyping | undefined,
|
||
locked: boolean,
|
||
): (next: PropertyValue) => void {
|
||
const setters = new Map<string, (item: PropertyValue) => void>();
|
||
for (const field of fields) {
|
||
const row = fieldRow(field, path, edit);
|
||
const item = structFieldValue(value, field);
|
||
if (field.kind === 'struct') {
|
||
const inner = [...path, field.key];
|
||
const typed = typing?.typeName(field.structType) ?? null;
|
||
row.add(new Spacer(), 1);
|
||
if (edit && typed === null) row.add(addFieldMenu(edit, inner));
|
||
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
||
host.body.add(row);
|
||
if (typing) host.body.add(typeRow(inner.length + 1, typed, () => typing.menu(inner, field.structType)));
|
||
const own = typed === null;
|
||
setters.set(
|
||
field.key,
|
||
structFieldRows(
|
||
host,
|
||
field.fields,
|
||
inner,
|
||
item,
|
||
handlers,
|
||
own ? edit : undefined,
|
||
own ? typing : undefined,
|
||
locked,
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
|
||
const sub = createField(host, fieldDef(field), item, own);
|
||
setters.set(field.key, addValue(row, sub));
|
||
lockValue(sub, locked);
|
||
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
||
host.body.add(row);
|
||
if (field.kind === 'list' && typing) {
|
||
const inner = [...path, field.key];
|
||
host.body.add(
|
||
itemsRow(path.length + 2, typing.typeName(field.itemType), () =>
|
||
typing.listMenu(inner, field.itemType),
|
||
),
|
||
);
|
||
}
|
||
if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2));
|
||
}
|
||
return (next) => {
|
||
for (const field of fields) setters.get(field.key)?.(structFieldValue(next, field));
|
||
};
|
||
}
|
||
|
||
function fieldRow(field: StructFieldDef, path: readonly string[], edit: VariableEditing | undefined): HStack {
|
||
const depth = path.length + 1;
|
||
const name = nameCell(field.key, depth, edit && ((next) => edit.renameField(path, field, next)));
|
||
return gridRow(depth, markLabel(field.kind), name);
|
||
}
|
||
|
||
/** Which Struct asset a struct follows, as a row of its own under the struct's head. */
|
||
function typeRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack {
|
||
return pickerRow(depth, 'Type', typeName ?? 'Own fields', menu);
|
||
}
|
||
|
||
/** Which Struct asset every item of a list is (#254), as a row of its own under the list's head. */
|
||
function itemsRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack {
|
||
return pickerRow(depth, 'Items', typeName ?? 'Mixed', menu);
|
||
}
|
||
|
||
function pickerRow(depth: number, label: string, text: string, menu: () => MenuEntry[]): HStack {
|
||
const picker = new Dropdown(text, { variant: 'ghost' });
|
||
picker.onClick = () => new Menu(menu()).openBelow(picker);
|
||
const name = new Label(label, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding });
|
||
const row = gridRow(depth, new Spacer(MARK_WIDTH), name);
|
||
row.add(picker, 1);
|
||
return row;
|
||
}
|
||
|
||
/** 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(depth * (FIELD_INDENT + Theme.gap) - Theme.gap));
|
||
row.add(mark);
|
||
row.add(name);
|
||
return row;
|
||
}
|
||
|
||
/** 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 });
|
||
}
|
||
|
||
/** A kind's mark: its picture, or its letters for the kinds that have no picture (`#`, `Aa`). */
|
||
export function markLabel(kind: string): Widget {
|
||
const icon = KIND_ICON_SVG[kind as Kind];
|
||
return icon
|
||
? new Icon(icon, MARK_WIDTH)
|
||
: new Label(kindMark(kind), { style: 'small', align: 'center', width: MARK_WIDTH });
|
||
}
|
||
|
||
/**
|
||
* A kind mark that can be clicked – a list's way of saying what an item holds. The label may
|
||
* take the button's whole width: the column is sized for its widest mark, 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(kind: string, items: MenuItem[]): Button {
|
||
const icon = KIND_ICON_SVG[kind as Kind];
|
||
const picker = new MarkButton(icon ? '' : kindMark(kind), {
|
||
variant: 'ghost',
|
||
width: MARK_WIDTH,
|
||
style: 'small',
|
||
icon,
|
||
});
|
||
picker.onClick = () => new Menu(items).openBelow(picker);
|
||
return picker;
|
||
}
|
||
|
||
/** `path` names the struct the field goes into: none for the variable's own fields. */
|
||
function addFieldMenu(edit: VariableEditing, path: readonly string[]): Dropdown {
|
||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||
add.onClick = () => new Menu(kindMenu(NESTED_KINDS, (kind) => edit.addField(path, kind))).openBelow(add);
|
||
return add;
|
||
}
|