The List tab could show one list at a time and was the only place to see its items. A list now shows its items right where it lives, and a new Variables tab shows all variables of an asset or placed object. - InlineList draws the items of a list under its row in the inspector, in struct fields and in the Variables tab. A list inside a list or a struct field is drawn the same way, one step indented. - The index column is the indent. Items sit under their list without an own margin, and the index column is 16 px wide instead of 28 px. - Above 20 items (INLINE_LIMIT) the rest collapses into "... N more - open in tab". During a run the rows show the values from the game. - ListHead puts "N items" and "+ Item" in one row. "N items" opens the list in a tab. A separate Open button squeezed the counter to zero width in the narrow inspector column. - VariablesEditor shows every variable of a behavior asset, a variables asset or a placed behavior. On an asset it edits the declarations. On a placed object an edit only overrides that object, with the usual reset and push buttons. - Opening a list inside a tab adds a new tab and keeps the open one. valuesKey gives each list its own document, so a list that is already open is shown instead of opened twice. - Titles read "Pickup · Variables" or "Listy · nums · [3]". The tab closes when its asset or object is deleted. - Lists on node inputs stay inline only, a node owns no variables. - The List tab, ListEditor and the __gm.list hook are gone. Tests: - ./check.sh: 181 files, 2645 tests, CHECK EXIT 0 - GM_STEPS=1,21,22,32: ALL PASSED, 123 checks - e2e 21 is rewritten for inline lists and the new tab, 22 and 32 are adapted. Sum in README: 1486 (+6 in 21, -1 in 22), counted by hand. - Counter-probes fail as expected: sub-lists not told apart (1 unit test), sub-list replacing the open tab, all items drawn inline (60 rows instead of 20), no live values (0 rows at 24 items in a run). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
/**
|
||
* Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so an
|
||
* inline list edit gets the same command, coercion and undo step. A slot with a `path` still writes
|
||
* the whole value.
|
||
*/
|
||
import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||
import { withoutObjects } from '../../core/graph/liveObjects';
|
||
import { resolveDef } from '../../core/graph/nested';
|
||
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
|
||
import type { Asset } from '../../core/model/Asset';
|
||
import { graphLookup } from '../../core/model/graphLookup';
|
||
import type { ObjectRef } from '../../core/model/instance';
|
||
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../../core/model/Properties';
|
||
import { findAsset } from '../../core/model/Project';
|
||
import { NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
|
||
import { itemLike } from '../../core/properties/listItems';
|
||
import { pathLabel, valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath';
|
||
import {
|
||
type PropertyDef,
|
||
schemaForAsset,
|
||
schemaForObject,
|
||
structOf,
|
||
} from '../../core/properties/PropertySchema';
|
||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||
import type { FieldHandlers } from '../inspector/fieldFactory';
|
||
import { currentProps, currentRef } from '../inspector/objectQueries';
|
||
import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers';
|
||
import { assetValue, currentNode, nodeParamValue, overrideValue } from '../inspector/valueSpecs';
|
||
import type { EditorState, ListSlot } from '../state';
|
||
import { deeperSlot } from '../variables/target';
|
||
|
||
export type ListDef = Extract<PropertyDef, { kind: 'list' }>;
|
||
|
||
/** What the tab needs of a slot: what is in it, and the two ways of writing it back. */
|
||
export interface ListAccess {
|
||
readonly def: ListDef;
|
||
/** What is in the list now – re-read on every access. */
|
||
readonly value: ListValue;
|
||
/** The kinds this list may hold: every one, at any depth (#239). */
|
||
readonly kinds: readonly NestedKind[];
|
||
/** What the tab header says the list belongs to. */
|
||
readonly title: string;
|
||
/** One item, previewed and committed exactly as the inspector does it. */
|
||
itemHandlers(index: number): FieldHandlers;
|
||
/**
|
||
* One value inside an item – `[3, 'hp']` is field `hp` of item 3. Each write rebuilds the whole
|
||
* list from what is in it at that moment, the same rule as an item, only deeper.
|
||
*/
|
||
handlersAt(path: ValuePath): FieldHandlers;
|
||
/**
|
||
* Opens a Variables tab on a list inside this one, `path` leading from this list to it – `[]` is
|
||
* this list. None for a node's list: that one is edited inline only.
|
||
*/
|
||
openList?: (path: ValuePath) => void;
|
||
/** A list inside this one, drawn inline one step further in. */
|
||
sub(path: ValuePath): ListAccess | null;
|
||
/** A whole new list in one command – adding, deleting, and the bulk text field. */
|
||
replace(next: ListValue): void;
|
||
}
|
||
|
||
/** The property a slot's `key` names, and how the section that owns it reads and writes it. */
|
||
interface Owner<Target> {
|
||
def: PropertyDef;
|
||
/** Its effective value, re-read on every call – a preview writes into the model. */
|
||
current: () => PropertyValue | undefined;
|
||
title: string;
|
||
spec: Omit<PreviewedValue<Target>, 'toModel'>;
|
||
}
|
||
|
||
/** A Variables asset's values in the running game, or null while none runs it. */
|
||
export type LiveVariables = (assetId: string) => PropertyBag | null;
|
||
|
||
export function resolveList(
|
||
state: EditorState,
|
||
slot: ListSlot,
|
||
live: LiveVariables = () => null,
|
||
): ListAccess | null {
|
||
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot);
|
||
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot, live));
|
||
if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
|
||
return inSlot(nodeOwner(state, slot));
|
||
}
|
||
|
||
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
|
||
|
||
/** The owner's own list, or – with a `path` – a list nested inside its value. */
|
||
function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot): ListAccess | null {
|
||
const path = slot.path ?? [];
|
||
const nested: Nested = {
|
||
open:
|
||
slot.kind === 'node'
|
||
? undefined
|
||
: (inner) => state.openValues({ kind: 'list', slot: deeperSlot(slot, inner) }),
|
||
sub: (inner) => listIn(state, owner, deeperSlot(slot, inner)),
|
||
};
|
||
if (path.length === 0)
|
||
return owner.def.kind === 'list'
|
||
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, nested)
|
||
: null;
|
||
// A struct is brought into its declared shape first, so a declared list not stored yet is there.
|
||
const def = owner.def;
|
||
const shaped = (value: PropertyValue | undefined): PropertyValue | undefined =>
|
||
def.kind === 'struct' ? structOf(def.fields, value) : value;
|
||
if (!isList(valueAt(shaped(owner.current()), path))) return null;
|
||
// Built on what the target stores, or – where it stores nothing, as an unset override – on what
|
||
// it shows: writing the list alone would drop its neighbours, and a snapshot would undo an edit
|
||
// made in between.
|
||
const whole = (target: Target | undefined) =>
|
||
shaped((target === undefined ? undefined : owner.spec.read(target)) ?? owner.current());
|
||
const wholeWith = (target: Target | undefined, list: PropertyValue): PropertyValue =>
|
||
withValueAt(whole(target), path, asList(list)) ?? asList(list);
|
||
const last = pathLabel(path).at(-1) ?? '';
|
||
return access<Target>(
|
||
state,
|
||
{ key: last, label: last, kind: 'list', default: [] },
|
||
[owner.title, ...pathLabel(path)].join(' · '),
|
||
() => asList(valueAt(shaped(owner.current()), path)),
|
||
{
|
||
target: owner.spec.target,
|
||
read: (target) => {
|
||
const stored = owner.spec.read(target);
|
||
return stored === undefined ? undefined : asList(valueAt(shaped(stored), path));
|
||
},
|
||
write: (target, list) => owner.spec.write(target, wholeWith(target, list)),
|
||
remove: owner.spec.remove,
|
||
commit: (list) => owner.spec.commit(wholeWith(owner.spec.target(), list)),
|
||
},
|
||
nested,
|
||
);
|
||
}
|
||
|
||
/** How a list reaches the lists inside it: in a tab of their own, and inline. */
|
||
interface Nested {
|
||
open: ((path: ValuePath) => void) | undefined;
|
||
sub: (path: ValuePath) => ListAccess | null;
|
||
}
|
||
|
||
/**
|
||
* `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so
|
||
* the next keystroke has to build on what is in there now, and the commit on what the restore
|
||
* put back. Everything else is the spec the matching inspector section already uses.
|
||
*/
|
||
function access<Target>(
|
||
state: EditorState,
|
||
def: ListDef,
|
||
title: string,
|
||
current: () => ListValue,
|
||
spec: Omit<PreviewedValue<Target>, 'toModel'>,
|
||
nested: Nested,
|
||
): ListAccess {
|
||
// The value keeps the kind it has: a color item written from its field stays a color.
|
||
const handlersAt = (path: ValuePath): FieldHandlers =>
|
||
previewingHandlers<Target>(
|
||
{ state },
|
||
{
|
||
...spec,
|
||
toModel: (input) =>
|
||
withValueAt(current(), path, itemLike(valueAt(current(), path), input)) ?? current(),
|
||
},
|
||
);
|
||
return {
|
||
def,
|
||
kinds: NESTED_KINDS,
|
||
title,
|
||
// Re-read, not a snapshot: an item edit does not rebuild the rows, so a delete or kind change
|
||
// clicked later has to build on the list with that edit in it, not on the one the rows showed.
|
||
get value() {
|
||
return current();
|
||
},
|
||
itemHandlers: (index) => handlersAt([index]),
|
||
handlersAt,
|
||
openList: nested.open,
|
||
sub: nested.sub,
|
||
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
|
||
// A preview does not, which is why `toModel` above has to.
|
||
replace: (next) => state.execute(spec.commit(next)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* While a run is on, a Variables asset shows what the run holds – the same rule as the inspector's
|
||
* `shownValues`, so a list a graph fills is not "2 items" in the row and empty in the tab.
|
||
*/
|
||
function assetOwner(
|
||
state: EditorState,
|
||
slot: Extract<ListSlot, { kind: 'asset' }>,
|
||
live: LiveVariables,
|
||
): Owner<Asset> | null {
|
||
const asset = findAsset(state.project, slot.assetId);
|
||
const def = asset && schemaForAsset(state.project, asset).find((d) => d.key === slot.key);
|
||
if (!asset || !def) return null;
|
||
return {
|
||
def,
|
||
title: `${asset.name} · ${def.label}`,
|
||
current: () => {
|
||
const now = findAsset(state.project, slot.assetId);
|
||
const bag = now?.type === 'variables' ? live(now.id) : null;
|
||
if (bag && slot.key in bag) return withoutObjects(bag[slot.key]);
|
||
return now && resolveAssetProps(state.project, now)[slot.key];
|
||
},
|
||
spec: assetValue(state, slot.assetId, slot.key),
|
||
};
|
||
}
|
||
|
||
function objectOwner(
|
||
state: EditorState,
|
||
slot: Extract<ListSlot, { kind: 'object' }>,
|
||
): Owner<ObjectRef> | null {
|
||
const ref = currentRef(state, slot.holderId, slot.objectId);
|
||
const owner = ref && findAsset(state.project, ref.object.assetId);
|
||
const def = owner && schemaForObject(state.project, owner, ref.object).find((d) => d.key === slot.key);
|
||
if (!ref || !def) return null;
|
||
return {
|
||
def,
|
||
title: `${ref.object.name} · ${def.label}`,
|
||
current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key],
|
||
spec: overrideValue(state, slot.holderId, slot.objectId, slot.key),
|
||
};
|
||
}
|
||
|
||
function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null {
|
||
const found = currentNode(state.project, slot.assetId, slot.nodeId);
|
||
if (!found) return null;
|
||
const nodeDef = resolveDef(found.node, found.graph, graphLookup(state.project));
|
||
const def =
|
||
nodeDef && (nodeDef.params.find((p) => p.key === slot.key) ?? inlineListDef(nodeDef, found, slot.key));
|
||
if (!nodeDef || !def) return null;
|
||
return {
|
||
def,
|
||
title: `${nodeDef.label} · ${def.label}`,
|
||
current: () =>
|
||
currentNode(state.project, slot.assetId, slot.nodeId)?.node.params[slot.key] ?? def.default,
|
||
spec: nodeParamValue(state, slot.assetId, slot.nodeId, slot.key),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A `list` input, which carries its inline value in the very same params bag a setting does. Once
|
||
* an edge feeds the port that value is dead, so the slot stops resolving rather than editing
|
||
* something the node no longer reads.
|
||
*/
|
||
function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, key: string): ListDef | null {
|
||
const port = inlineInputs(def).find((p) => p.key === key);
|
||
if (!port || port.type !== 'list' || edgesInto(found.graph, found.node.id, key).length > 0) return null;
|
||
const inline = inlineInputDef(port);
|
||
return inline.kind === 'list' ? inline : null;
|
||
}
|