Files
feuerwerk/src/editor/inspector/variableRows.ts
T
adminandClaude Opus 5.5 e8b3ac0a3b
game-maker / gate (push) Successful in 3m40s
game-maker / suite (push) Successful in 10m56s
Keep behavior variables private to their own graph (#257)
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>
2026-09-28 13:54:57 +02:00

314 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}