Add Get/Set properties nodes and a breadcrumb for opened sub-behaviors
Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.
The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.
Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.
The "Add node..." header button is gone; nodes come from the context menu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9fa592078
commit
854ae2e756
35 files changed
+1044
-101
No files matched your search
@@ -251,7 +251,25 @@ export function createField(
|
||||
}
|
||||
case 'text': {
|
||||
const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||||
return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) };
|
||||
const setValue = (v: PropertyValue) => field.setValue(textOf(v));
|
||||
const suggestions = def.suggestions;
|
||||
if (!suggestions) return { widget: field, grow: true, setValue };
|
||||
const pick = new Dropdown('', { variant: 'ghost', width: MARK_WIDTH });
|
||||
pick.onClick = () => {
|
||||
const current = field.value.trim();
|
||||
const items = suggestions.map((s) => ({
|
||||
label: s.value === current ? `● ${s.label}` : s.label,
|
||||
onSelect: () => {
|
||||
setValue(s.value);
|
||||
handlers.onChange(s.value);
|
||||
},
|
||||
}));
|
||||
new Menu(items, { searchable: true }).openBelow(pick);
|
||||
};
|
||||
const combo = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
combo.add(field, 1);
|
||||
combo.add(pick);
|
||||
return { widget: combo, grow: true, setValue };
|
||||
}
|
||||
case 'color': {
|
||||
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
callProblem,
|
||||
declaresPropertyPorts,
|
||||
declaresVariablePorts,
|
||||
edgesInto,
|
||||
type GraphNode,
|
||||
@@ -15,7 +16,7 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Label } from '../../ui';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildPortSection, buildVariablePortSection } from './portSection';
|
||||
import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection';
|
||||
import { listOpener, previewingHandlers } from './previewHandlers';
|
||||
import { currentNode, nodeParamValue } from './valueSpecs';
|
||||
|
||||
@@ -52,6 +53,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
|
||||
if (problem) addNote(host, problem, 'warning');
|
||||
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
|
||||
else if (declaresPropertyPorts(node.type)) buildPropertyPortSection(host, assetId, node);
|
||||
else if (def.declaresPorts) buildPortSection(host, assetId, node, asset.variables);
|
||||
|
||||
if (def.params.length > 0) addTitle(host, 'SETTINGS');
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
nextPortName,
|
||||
RemovePortCommand,
|
||||
RenamePortCommand,
|
||||
SetVariablePortCommand,
|
||||
RepointPortCommand,
|
||||
} from '../../core/commands/portCommands';
|
||||
import {
|
||||
BEHAVIOR_START,
|
||||
@@ -13,10 +13,16 @@ import {
|
||||
type GraphNode,
|
||||
isTerminalNode,
|
||||
missingFieldPorts,
|
||||
objectPropertyDef,
|
||||
portKindOf,
|
||||
propertyPortOf,
|
||||
structShapeOf,
|
||||
variablePortKind,
|
||||
} from '../../core/graph';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
OBJECT_PROPERTY_CHOICES,
|
||||
type PropertyDef,
|
||||
VARIABLE_KINDS,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
Dropdown,
|
||||
HStack,
|
||||
@@ -42,44 +48,101 @@ export function buildVariablePortSection(
|
||||
node: GraphNode,
|
||||
variables: readonly PropertyDef[],
|
||||
): void {
|
||||
const choices = variables.map((def) => ({ label: def.key, def }));
|
||||
buildPickedPortSection(host, assetId, node, { noun: 'Variable', choices });
|
||||
}
|
||||
|
||||
/**
|
||||
* The ports of a Get/Set properties node: a built-in property is picked like a variable, and a name
|
||||
* the list does not know (a variable of another behavior's object) is typed, with a kind of its own.
|
||||
*/
|
||||
export function buildPropertyPortSection(host: InspectorHost, assetId: string, node: GraphNode): void {
|
||||
buildPickedPortSection(host, assetId, node, {
|
||||
noun: 'Property',
|
||||
choices: OBJECT_PROPERTY_CHOICES,
|
||||
custom: true,
|
||||
});
|
||||
}
|
||||
|
||||
interface PickedPorts {
|
||||
noun: 'Variable' | 'Property';
|
||||
choices: readonly { label: string; def: PropertyDef }[];
|
||||
custom?: boolean;
|
||||
}
|
||||
|
||||
function buildPickedPortSection(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
node: GraphNode,
|
||||
{ noun, choices, custom = false }: PickedPorts,
|
||||
): void {
|
||||
const plural = noun === 'Property' ? 'properties' : 'variables';
|
||||
const ports = node.ports ?? [];
|
||||
const taken = new Set(ports.map((p) => p.key));
|
||||
const portOf = (v: PropertyDef): DeclaredPort => ({ key: v.key, kind: variablePortKind(v) });
|
||||
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
|
||||
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
|
||||
variables.map((v) => ({
|
||||
label: v.key,
|
||||
disabled: taken.has(v.key) && v.key !== own,
|
||||
onSelect: () => onPick(portOf(v)),
|
||||
choices.map(({ label, def }) => ({
|
||||
label,
|
||||
disabled: taken.has(def.key) && def.key !== own,
|
||||
onSelect: () => onPick(portOf(def)),
|
||||
}));
|
||||
const typed = (): MenuItem[] =>
|
||||
VARIABLE_KINDS.map((kind) => ({
|
||||
label: `Custom ${KIND_LABELS[kind].toLowerCase()}`,
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
|
||||
),
|
||||
}));
|
||||
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(new Label('VARIABLES', { style: 'title' }), 1);
|
||||
const add = new Dropdown('+ Variable', { variant: 'ghost' });
|
||||
header.add(new Label(plural.toUpperCase(), { style: 'title' }), 1);
|
||||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
|
||||
new Menu(items).openBelow(add);
|
||||
new Menu(custom ? [...items, ...typed()] : items, { searchable: custom }).openBelow(add);
|
||||
};
|
||||
add.setEnabled(variables.length > taken.size);
|
||||
add.setEnabled(custom || choices.some(({ def }) => !taken.has(def.key)));
|
||||
header.add(add);
|
||||
host.body.add(header);
|
||||
|
||||
if (variables.length === 0) addNote(host, 'This behavior has no variables yet.');
|
||||
else if (ports.length === 0) addNote(host, 'No variables picked yet. Each one adds a port.');
|
||||
if (choices.length === 0) addNote(host, `This behavior has no ${plural} yet.`);
|
||||
else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`);
|
||||
|
||||
for (const port of ports) {
|
||||
const known = variables.some((v) => v.key === port.key);
|
||||
const known = choices.some(({ def }) => def.key === port.key);
|
||||
if (!known && custom) {
|
||||
portRow(host, assetId, node, port, customName(host, assetId, node, port));
|
||||
continue;
|
||||
}
|
||||
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
|
||||
choose.onClick = () => {
|
||||
const items = pick(port.key, (next) =>
|
||||
host.state.execute(new SetVariablePortCommand(assetId, node.id, port.key, next)),
|
||||
host.state.execute(new RepointPortCommand(assetId, node.id, port.key, next)),
|
||||
);
|
||||
new Menu(items).openBelow(choose);
|
||||
new Menu(items, { searchable: custom }).openBelow(choose);
|
||||
};
|
||||
portRow(host, assetId, node, port, choose);
|
||||
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
/** Typing a built-in property's name turns the port into that property, kind included. */
|
||||
function customName(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
|
||||
return new TextField(port.key, {
|
||||
width: NAME_WIDTH,
|
||||
onChange: (text) => {
|
||||
const next = text.trim();
|
||||
const builtIn = objectPropertyDef(next);
|
||||
host.state.execute(
|
||||
builtIn
|
||||
? new RepointPortCommand(assetId, node.id, port.key, propertyPortOf(builtIn))
|
||||
: new RenamePortCommand(assetId, node.id, port.key, next),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The ports a node declares for itself: the signature of a behavior seen as a node, or the fields
|
||||
* of a struct. A start node's are named from the caller's side – what it hands into the graph is
|
||||
|
||||
Reference in new issue
Block a user