Let structs and lists nest to any depth (#239)
Structs and lists were capped at two fixed levels (list of structs, struct with a list field). Anything deeper was dropped silently on load, in node settings and when written to a variable. The cap came from an early design choice, not from a technical need, so it is lifted completely instead of adding a third fixed level. - One kind list NESTED_KINDS replaces ListItemKind and StructFieldKind. The flat special cases in listItems.ts are gone, every value goes through one recursive path. - A struct inside a struct is declared, like a top-level struct. Values coming in over an edge are brought into that shape. Struct items in lists still take their fields from the value. - New valuePath.ts addresses any nested spot by keys and indices. ListSlot.field is now ListSlot.path. - Field commands (add, rename, remove) take the path to the struct. They change declaration, own value and all overrides in one undo step, as before on the top level. - Inspector: "+ Field" offers Struct and List on every level. A nested struct is a header row with its own "+ Field", its fields are indented one step further. - List tab: a list item shows "n items" and opens one level deeper. The title shows the path, e.g. "Listy · nums · [3]". - Pack in Pack now writes a struct into a struct field instead of text. Objects are kept at any depth and stripped at every exit. - A list edit in a struct field without an existing object override now starts from the shown value instead of an empty struct. - Old projects load unchanged. Save, load and save again gives the same result. Tests: - ./check.sh: 172 files, 2498 tests, CHECK EXIT 0 - e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii, 21d and 22f updated) - 20 new unit tests in nested-values.test.ts and nested-list-slot.test.ts, three tests that pinned the old cap turned around - 6 counter-probes, each fails 1 to 8 tests, originals restored and checked with cmp - Not covered: a nested struct inside a struct item in the list tab has no e2e click Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
483c34bb5c
commit
0c5ac32b59
29 files changed
+1046
-437
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1407 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1416 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type StructFieldDef,
|
type StructFieldDef,
|
||||||
variableDef,
|
variableDef,
|
||||||
} from '../properties/PropertySchema';
|
} from '../properties/PropertySchema';
|
||||||
|
import { valueAt, withValueAt } from '../properties/valuePath';
|
||||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||||
import { type Command, CommandError, RenameCommand } from './Command';
|
import { type Command, CommandError, RenameCommand } from './Command';
|
||||||
import { renameVariablePorts } from './portCommands';
|
import { renameVariablePorts } from './portCommands';
|
||||||
@@ -189,7 +190,10 @@ export class SetVariableKeptCommand implements Command {
|
|||||||
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
|
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
|
||||||
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
|
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
|
||||||
|
|
||||||
/** Where declared fields live: on a struct variable. A list item carries its own shape instead. */
|
/**
|
||||||
|
* Where declared fields live: on a struct variable, or on a struct field of one, however deep
|
||||||
|
* (#239). A list item carries its own shape instead.
|
||||||
|
*/
|
||||||
interface FieldOwner {
|
interface FieldOwner {
|
||||||
fields(): readonly StructFieldDef[];
|
fields(): readonly StructFieldDef[];
|
||||||
setFields(next: readonly StructFieldDef[]): void;
|
setFields(next: readonly StructFieldDef[]): void;
|
||||||
@@ -199,15 +203,50 @@ function variableOf(project: Project, assetId: string, key: string): PropertyDef
|
|||||||
return requireVariableOwner(project, assetId).variables.find((v) => v.key === key);
|
return requireVariableOwner(project, assetId).variables.find((v) => v.key === key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
|
/** `path` names the struct fields from the variable down to the struct whose fields are meant. */
|
||||||
|
function requireFieldOwner(
|
||||||
|
project: Project,
|
||||||
|
assetId: string,
|
||||||
|
key: string,
|
||||||
|
path: readonly string[],
|
||||||
|
): FieldOwner {
|
||||||
const def = variableOf(project, assetId, key);
|
const def = variableOf(project, assetId, key);
|
||||||
if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`);
|
if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`);
|
||||||
return { fields: () => def.fields, setFields: (next) => (def.fields = next) };
|
if (fieldsAt(def.fields, path) === undefined)
|
||||||
|
throw new CommandError(`Field "${path.join('.')}" has no fields`);
|
||||||
|
return {
|
||||||
|
fields: () => fieldsAt(def.fields, path) ?? [],
|
||||||
|
setFields: (next) => (def.fields = withFieldsAt(def.fields, path, next)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldsAt(
|
||||||
|
fields: readonly StructFieldDef[],
|
||||||
|
path: readonly string[],
|
||||||
|
): readonly StructFieldDef[] | undefined {
|
||||||
|
if (path.length === 0) return fields;
|
||||||
|
const field = fields.find((f) => f.key === path[0]);
|
||||||
|
return field?.kind === 'struct' ? fieldsAt(field.fields, path.slice(1)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The declaration with the fields at `path` replaced, rebuilt on the way down. */
|
||||||
|
function withFieldsAt(
|
||||||
|
fields: readonly StructFieldDef[],
|
||||||
|
path: readonly string[],
|
||||||
|
next: readonly StructFieldDef[],
|
||||||
|
): readonly StructFieldDef[] {
|
||||||
|
if (path.length === 0) return next;
|
||||||
|
return fields.map((f) =>
|
||||||
|
f.key === path[0] && f.kind === 'struct'
|
||||||
|
? { ...f, fields: withFieldsAt(f.fields, path.slice(1), next) }
|
||||||
|
: f,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One struct-carrying value of a variable (its declared default, the behavior's own, an override); a
|
* One struct-carrying value of a variable (its declared default, the behavior's own, an override); a
|
||||||
* field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo.
|
* field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo.
|
||||||
|
* With a `path` the site is the struct that deep inside the value.
|
||||||
*/
|
*/
|
||||||
interface StructSite {
|
interface StructSite {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -215,15 +254,19 @@ interface StructSite {
|
|||||||
write(value: StructValue): void;
|
write(value: StructValue): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function structSites(project: Project, assetId: string, key: string): StructSite[] {
|
function structSites(project: Project, assetId: string, key: string, path: readonly string[]): StructSite[] {
|
||||||
|
const structIn = (value: PropertyValue | undefined): StructValue => {
|
||||||
|
const at = valueAt(value, path);
|
||||||
|
return isStruct(at) ? at : {};
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
valueHolders(project, assetId, key)
|
valueHolders(project, assetId, key)
|
||||||
// An override the object does not have stays absent: writing one here would invent a deviation.
|
// An override the object does not have stays absent: writing one here would invent a deviation.
|
||||||
.filter((h) => !h.optional || isStruct(h.read()))
|
.filter((h) => !h.optional || isStruct(h.read()))
|
||||||
.map((holder) => ({
|
.map((holder) => ({
|
||||||
id: holder.id,
|
id: holder.id,
|
||||||
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
|
read: () => structIn(holder.read()),
|
||||||
write: (value: StructValue) => holder.write(value),
|
write: (value: StructValue) => holder.write(withValueAt(holder.read(), path, value) ?? value),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -276,28 +319,30 @@ function renameField(value: StructValue, from: string, to: string): StructValue
|
|||||||
export class AddStructFieldCommand implements Command {
|
export class AddStructFieldCommand implements Command {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
|
||||||
|
/** `path` names the struct the field is added to: none for the variable itself. */
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
private readonly key: string,
|
private readonly key: string,
|
||||||
private readonly field: StructFieldDef,
|
private readonly field: StructFieldDef,
|
||||||
|
private readonly path: readonly string[] = [],
|
||||||
) {
|
) {
|
||||||
this.label = `Add field ${field.key}`;
|
this.label = `Add field ${field.key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||||||
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
|
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
|
||||||
if (owner.fields().some((f) => f.key === this.field.key))
|
if (owner.fields().some((f) => f.key === this.field.key))
|
||||||
throw new CommandError(`Field "${this.field.key}" already exists`);
|
throw new CommandError(`Field "${this.field.key}" already exists`);
|
||||||
owner.setFields([...owner.fields(), { ...this.field }]);
|
owner.setFields([...owner.fields(), { ...this.field }]);
|
||||||
for (const site of structSites(project, this.assetId, this.key))
|
for (const site of structSites(project, this.assetId, this.key, this.path))
|
||||||
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
|
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||||||
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
|
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
|
||||||
for (const site of structSites(project, this.assetId, this.key))
|
for (const site of structSites(project, this.assetId, this.key, this.path))
|
||||||
site.write(withoutField(site.read(), this.field.key));
|
site.write(withoutField(site.read(), this.field.key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,21 +352,23 @@ export class RemoveStructFieldCommand implements Command {
|
|||||||
readonly label: string;
|
readonly label: string;
|
||||||
private removed?: { field: Removed<StructFieldDef>; values: Map<string, FieldValue> };
|
private removed?: { field: Removed<StructFieldDef>; values: Map<string, FieldValue> };
|
||||||
|
|
||||||
|
/** `path` names the struct the field is taken from: none for the variable itself. */
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
private readonly key: string,
|
private readonly key: string,
|
||||||
private readonly field: string,
|
private readonly field: string,
|
||||||
|
private readonly path: readonly string[] = [],
|
||||||
) {
|
) {
|
||||||
this.label = `Remove field ${field}`;
|
this.label = `Remove field ${field}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||||||
const fields = [...owner.fields()];
|
const fields = [...owner.fields()];
|
||||||
const field = removeFirst(fields, (f) => f.key === this.field);
|
const field = removeFirst(fields, (f) => f.key === this.field);
|
||||||
if (!field) throw new CommandError(`Field "${this.field}" not found`);
|
if (!field) throw new CommandError(`Field "${this.field}" not found`);
|
||||||
const values = new Map<string, FieldValue>();
|
const values = new Map<string, FieldValue>();
|
||||||
for (const site of structSites(project, this.assetId, this.key)) {
|
for (const site of structSites(project, this.assetId, this.key, this.path)) {
|
||||||
const value = site.read();
|
const value = site.read();
|
||||||
const item = value[this.field];
|
const item = value[this.field];
|
||||||
if (item !== undefined) values.set(site.id, item);
|
if (item !== undefined) values.set(site.id, item);
|
||||||
@@ -333,11 +380,11 @@ export class RemoveStructFieldCommand implements Command {
|
|||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
if (!this.removed) return;
|
if (!this.removed) return;
|
||||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||||||
const fields = [...owner.fields()];
|
const fields = [...owner.fields()];
|
||||||
restoreAt(fields, [this.removed.field]);
|
restoreAt(fields, [this.removed.field]);
|
||||||
owner.setFields(fields);
|
owner.setFields(fields);
|
||||||
for (const site of structSites(project, this.assetId, this.key)) {
|
for (const site of structSites(project, this.assetId, this.key, this.path)) {
|
||||||
const value = this.removed.values.get(site.id);
|
const value = this.removed.values.get(site.id);
|
||||||
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
|
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
|
||||||
}
|
}
|
||||||
@@ -345,24 +392,26 @@ export class RemoveStructFieldCommand implements Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class RenameStructFieldCommand extends RenameCommand {
|
export class RenameStructFieldCommand extends RenameCommand {
|
||||||
|
/** `path` names the struct the field belongs to: none for the variable itself. */
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
private readonly key: string,
|
private readonly key: string,
|
||||||
from: string,
|
from: string,
|
||||||
to: string,
|
to: string,
|
||||||
|
private readonly path: readonly string[] = [],
|
||||||
) {
|
) {
|
||||||
super(`Rename field ${from}`, from, to);
|
super(`Rename field ${from}`, from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected rename(project: Project, from: string, to: string): void {
|
protected rename(project: Project, from: string, to: string): void {
|
||||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||||||
if (from === to) return;
|
if (from === to) return;
|
||||||
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
|
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
|
||||||
const fields = owner.fields();
|
const fields = owner.fields();
|
||||||
if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
|
if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
|
||||||
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
|
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
|
||||||
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
|
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
|
||||||
for (const site of structSites(project, this.assetId, this.key))
|
for (const site of structSites(project, this.assetId, this.key, this.path))
|
||||||
site.write(renameField(site.read(), from, to));
|
site.write(renameField(site.read(), from, to));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { itemLike } from '../../../properties/listItems';
|
import type { PropertyValue } from '../../../model/Properties';
|
||||||
|
import { itemKindOf, itemLike, makeItem } from '../../../properties/listItems';
|
||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { asList, asNumber, asValueItem, isEntity } from '../../values';
|
import { asList, asNumber, asValueItem, isEntity, type ValueList } from '../../values';
|
||||||
import { any, int, list } from '../shared';
|
import { any, int, list } from '../shared';
|
||||||
|
|
||||||
export const SetItem: NodeDef = {
|
export const SetItem: NodeDef = {
|
||||||
@@ -20,8 +21,21 @@ export const SetItem: NodeDef = {
|
|||||||
const value = asValueItem(inputs.value);
|
const value = asValueItem(inputs.value);
|
||||||
const slot = items[i];
|
const slot = items[i];
|
||||||
// An object has no kind to keep and none to take on, so it is written as it is (#157).
|
// An object has no kind to keep and none to take on, so it is written as it is (#157).
|
||||||
next[i] = isEntity(value) || isEntity(slot) ? value : itemLike(slot, value);
|
if (isEntity(value) || isEntity(slot)) next[i] = value;
|
||||||
|
else next[i] = nestedKind(slot) ? sameNested(slot, value) : itemLike(slot, value);
|
||||||
return { outputs: { result: next } };
|
return { outputs: { result: next } };
|
||||||
},
|
},
|
||||||
params: [],
|
params: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Whether a slot holds a list or a struct, which may carry objects `itemLike` would not keep. */
|
||||||
|
const nestedKind = (slot: ValueList[number] | undefined): boolean => {
|
||||||
|
const kind = itemKindOf(slot as PropertyValue);
|
||||||
|
return kind === 'list' || kind === 'struct';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A list or struct slot takes a value of its own kind as it is, anything else as its empty one. */
|
||||||
|
function sameNested(slot: ValueList[number] | undefined, value: ValueList[number]): ValueList[number] {
|
||||||
|
const kind = itemKindOf(slot as PropertyValue);
|
||||||
|
return itemKindOf(value as PropertyValue) === kind ? value : makeItem(kind, undefined);
|
||||||
|
}
|
||||||
+21
-16
@@ -16,9 +16,10 @@ import { ENTITY_HANDLE, type EntityHandle } from './NodeContext';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A list travelling along a data edge. Unlike a stored one it may hold live object handles, which is
|
* A list travelling along a data edge. Unlike a stored one it may hold live object handles, which is
|
||||||
* what lets `Raycast all` hand out the objects it met (#157).
|
* what lets `Raycast all` hand out the objects it met (#157). Items nest like stored ones (#239); an
|
||||||
|
* object deeper down is held the way a bag holds one (`heldObject`).
|
||||||
*/
|
*/
|
||||||
export type ValueList = readonly (number | string | boolean | StructValue | EntityHandle)[];
|
export type ValueList = readonly (PropertyValue | EntityHandle)[];
|
||||||
|
|
||||||
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
|
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
|
||||||
export type Value = PropertyValue | EntityHandle | ValueList | null;
|
export type Value = PropertyValue | EntityHandle | ValueList | null;
|
||||||
@@ -87,9 +88,9 @@ export function unplaced(value: Value | undefined): Value | undefined {
|
|||||||
return tagOf(value) === 'object' ? null : value;
|
return tagOf(value) === 'object' ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A list as a run's bag holds it: an object stays the object. */
|
/** A list as a run's bag holds it: an object stays the object, however deep it sits. */
|
||||||
function heldList(items: ValueList): ListValue {
|
function heldList(items: ValueList): ListValue {
|
||||||
return items.map((item) => (isEntity(item) ? heldObject(item) : asListItem(item)));
|
return items.map(asField);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `value` with every object in it named instead – how a value reads as text, however deep it sits. */
|
/** `value` with every object in it named instead – how a value reads as text, however deep it sits. */
|
||||||
@@ -105,24 +106,28 @@ export function namedValue(value: Value | undefined): PropertyValue | undefined
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A value narrowed to what a *travelling* list may hold: unlike `asListItem`, a handle stays one. */
|
/** A value narrowed to what a *travelling* list may hold: unlike `asField`, a handle stays one. */
|
||||||
export function asValueItem(value: Value | undefined): ValueList[number] {
|
export function asValueItem(value: Value | undefined): ValueList[number] {
|
||||||
return isEntity(value) ? value : asListItem(value);
|
return isEntity(value) ? value : asField(value);
|
||||||
}
|
|
||||||
|
|
||||||
/** A value narrowed to what a list may hold: a struct stays one, handles and lists flatten. */
|
|
||||||
function asListItem(value: Value | undefined): number | string | boolean | StructValue {
|
|
||||||
if (isStructValue(value)) return value;
|
|
||||||
return asPrimitive(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A value narrowed to what a struct field may hold: a list stays a list and an object the object,
|
* A value as a struct field or list item holds it in a run's bag: lists and structs stay what they
|
||||||
* anything deeper flattens.
|
* are at any depth (#239), an object stays the object, a missing value becomes 0.
|
||||||
*/
|
*/
|
||||||
export function asField(value: Value | undefined): FieldValue {
|
export function asField(value: Value | undefined): FieldValue {
|
||||||
if (isEntity(value)) return heldObject(value);
|
if (isEntity(value)) return heldObject(value);
|
||||||
return isValueList(value) ? heldList(value) : asPrimitive(value);
|
if (isValueList(value)) return heldList(value);
|
||||||
|
if (isStructValue(value)) return heldStruct(value);
|
||||||
|
return asPrimitive(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A struct as a run's bag holds it: each field as `asField` says, a tagged item as itself. */
|
||||||
|
function heldStruct(value: StructValue): StructValue {
|
||||||
|
if (tagOf(value) !== undefined) return value;
|
||||||
|
const fields: Record<string, FieldValue> = {};
|
||||||
|
for (const [key, field] of Object.entries(value)) fields[key] = asField(field);
|
||||||
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
function asPrimitive(value: Value | undefined): number | string | boolean {
|
function asPrimitive(value: Value | undefined): number | string | boolean {
|
||||||
@@ -185,7 +190,7 @@ function mergeStruct(current: StructValue, value: Value | undefined): StructValu
|
|||||||
const merged: Record<string, PropertyValue> = {};
|
const merged: Record<string, PropertyValue> = {};
|
||||||
for (const [field, own] of Object.entries(current))
|
for (const [field, own] of Object.entries(current))
|
||||||
merged[field] = field in incoming ? asTypeOf(own, incoming[field]) : own;
|
merged[field] = field in incoming ? asTypeOf(own, incoming[field]) : own;
|
||||||
return merged as StructValue;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `value` converted to the type the slot already holds, so a number stays a number. */
|
/** `value` converted to the type the slot already holds, so a number stays a number. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type DeclaredPortKind, isKindWith, KIND, plainKindOf } from '../properties/kinds';
|
import { type DeclaredPortKind, isKindWith, KIND, plainKindOf } from '../properties/kinds';
|
||||||
import { itemKindOf, structItemFields } from '../properties/listItems';
|
import { itemKindOf, structItemFields } from '../properties/listItems';
|
||||||
import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema';
|
import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema';
|
||||||
|
import { isStruct, type PropertyValue } from '../model/Properties';
|
||||||
import type { DeclaredPort, GraphNode } from './Graph';
|
import type { DeclaredPort, GraphNode } from './Graph';
|
||||||
|
|
||||||
/** The port a variable or property travels on. A kind no port declares (an enum, a key) is what it travels as. */
|
/** The port a variable or property travels on. A kind no port declares (an enum, a key) is what it travels as. */
|
||||||
@@ -19,7 +20,16 @@ export function structShapeOf(def: PropertyDef): readonly StructFieldDef[] | und
|
|||||||
if (def.kind === 'struct') return def.fields;
|
if (def.kind === 'struct') return def.fields;
|
||||||
if (def.kind !== 'list') return undefined;
|
if (def.kind !== 'list') return undefined;
|
||||||
const item = def.default.find((entry) => itemKindOf(entry) === 'struct');
|
const item = def.default.find((entry) => itemKindOf(entry) === 'struct');
|
||||||
return item === undefined ? undefined : structItemFields(item);
|
return item === undefined ? undefined : itemShape(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fields a struct item carries as a declaration would name them, nested ones included. */
|
||||||
|
function itemShape(item: PropertyValue | undefined): StructFieldDef[] {
|
||||||
|
return structItemFields(item).map(({ key, kind }) =>
|
||||||
|
kind === 'struct'
|
||||||
|
? { key, kind, fields: itemShape(isStruct(item) ? item[key] : undefined) }
|
||||||
|
: { key, kind },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { TaggedKind, ValueOf } from '../properties/kinds';
|
import type { TaggedKind, ValueOf } from '../properties/kinds';
|
||||||
|
|
||||||
/** Depth is capped: `list → struct → primitive` is the deepest a stored value goes. */
|
/** Lists and structs nest to any depth (#239): an item may be a list or a struct itself. */
|
||||||
export type ListValue = readonly (number | string | boolean | StructValue)[];
|
export type ListValue = readonly PropertyValue[];
|
||||||
|
|
||||||
/** What a struct field may hold. A field is never itself a struct – except a tagged item. */
|
/** What a struct field or a list item holds – any value, since both nest to any depth. */
|
||||||
export type FieldValue = number | string | boolean | ListValue | TaggedItem;
|
export type FieldValue = PropertyValue;
|
||||||
|
|
||||||
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
|
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
|
||||||
export type StructValue = { readonly [field: string]: FieldValue };
|
export type StructValue = { readonly [field: string]: PropertyValue };
|
||||||
|
|
||||||
/** Colors are stored as 0xRRGGBB numbers. */
|
/** Colors are stored as 0xRRGGBB numbers. */
|
||||||
export type PropertyValue = FieldValue | StructValue;
|
export type PropertyValue = number | string | boolean | ListValue | StructValue;
|
||||||
|
|
||||||
export type PropertyBag = Record<string, PropertyValue>;
|
export type PropertyBag = Record<string, PropertyValue>;
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
VECTOR_WIDTH_KEY,
|
VECTOR_WIDTH_KEY,
|
||||||
} from '../model/vectorart';
|
} from '../model/vectorart';
|
||||||
import type { SceneObject } from '../model/SceneObject';
|
import type { SceneObject } from '../model/SceneObject';
|
||||||
import { flatListOf, listItemOf } from './listItems';
|
import { listItemOf, listOf } from './listItems';
|
||||||
import { FONT_KEY } from './fonts';
|
import { FONT_KEY } from './fonts';
|
||||||
import {
|
import {
|
||||||
BURST_COUNT_KEY,
|
BURST_COUNT_KEY,
|
||||||
@@ -194,13 +194,20 @@ export interface PrimitiveFieldDef {
|
|||||||
kind: FieldKind;
|
kind: FieldKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A list held by a struct field. Its items declare nothing either, but they stay flat. */
|
/** A list held by a struct field. Its items declare nothing either, and may nest like any list. */
|
||||||
export interface ListFieldDef {
|
export interface ListFieldDef {
|
||||||
key: string;
|
key: string;
|
||||||
kind: 'list';
|
kind: 'list';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef;
|
/** A struct held by a struct field declares its fields like a struct variable does (#239). */
|
||||||
|
export interface StructFieldStructDef {
|
||||||
|
key: string;
|
||||||
|
kind: 'struct';
|
||||||
|
fields: readonly StructFieldDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef;
|
||||||
|
|
||||||
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
||||||
|
|
||||||
@@ -766,6 +773,7 @@ export function variableDef(
|
|||||||
/** The empty value of a kind, so a field that is not there yet still has one. */
|
/** The empty value of a kind, so a field that is not there yet still has one. */
|
||||||
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
||||||
if (field.kind === 'object') return NO_OBJECT;
|
if (field.kind === 'object') return NO_OBJECT;
|
||||||
|
if (field.kind === 'struct') return structOf(field.fields, undefined);
|
||||||
return field.kind === 'list' ? [] : emptyOf(field.kind);
|
return field.kind === 'list' ? [] : emptyOf(field.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,8 +814,12 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
|
/**
|
||||||
|
* A struct field as its declaration promises: a list field takes only a list (see `ListFieldDef`),
|
||||||
|
* a struct field is brought into its declared shape, however deep it sits.
|
||||||
|
*/
|
||||||
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
|
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
|
||||||
if (field.kind === 'object') return NO_OBJECT;
|
if (field.kind === 'object') return NO_OBJECT;
|
||||||
return field.kind === 'list' ? flatListOf(value) : coerceField(value, field.kind);
|
if (field.kind === 'struct') return structOf(field.fields, value);
|
||||||
|
return field.kind === 'list' ? listOf(value) : coerceField(value, field.kind);
|
||||||
}
|
}
|
||||||
@@ -199,10 +199,11 @@ export type TaggedKind = With<'tagged'>;
|
|||||||
export type ReferenceKind = With<'reference'>;
|
export type ReferenceKind = With<'reference'>;
|
||||||
/** The kinds whose value is one primitive – the ones with an empty value. */
|
/** The kinds whose value is one primitive – the ones with an empty value. */
|
||||||
export type FlatKind = Extract<Row, { empty: unknown }>['kind'];
|
export type FlatKind = Extract<Row, { empty: unknown }>['kind'];
|
||||||
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
|
/**
|
||||||
export type ListItemKind = FieldKind | 'struct';
|
* What a list item or a struct field may be: a primitive, or a list or struct again. Nothing bounds
|
||||||
/** What a field of a struct holds – a primitive plus `list`. No `struct`: that bounds the depth. */
|
* the depth (#239), so the two places offer the same kinds.
|
||||||
export type StructFieldKind = FieldKind | 'list';
|
*/
|
||||||
|
export type NestedKind = FieldKind | 'list' | 'struct';
|
||||||
|
|
||||||
type Widen<T> = T extends number ? number : T extends string ? string : T extends boolean ? boolean : never;
|
type Widen<T> = T extends number ? number : T extends string ? string : T extends boolean ? boolean : never;
|
||||||
export type ValueOf<K extends FlatKind> = Widen<RowOf<K>['empty']>;
|
export type ValueOf<K extends FlatKind> = Widen<RowOf<K>['empty']>;
|
||||||
@@ -221,8 +222,7 @@ const kindsWith = <Role extends string>(role: Role): readonly With<Role>[] =>
|
|||||||
export const VARIABLE_KINDS = kindsWith('variable');
|
export const VARIABLE_KINDS = kindsWith('variable');
|
||||||
export const FIELD_KINDS = kindsWith('field');
|
export const FIELD_KINDS = kindsWith('field');
|
||||||
export const DECLARED_PORT_KINDS = kindsWith('port');
|
export const DECLARED_PORT_KINDS = kindsWith('port');
|
||||||
export const LIST_ITEM_KINDS: readonly ListItemKind[] = [...FIELD_KINDS, 'struct'];
|
export const NESTED_KINDS: readonly NestedKind[] = [...FIELD_KINDS, 'list', 'struct'];
|
||||||
export const STRUCT_FIELD_KINDS: readonly StructFieldKind[] = [...FIELD_KINDS, 'list'];
|
|
||||||
|
|
||||||
export const isTaggedKind = (kind: string): kind is TaggedKind => isKindWith('tagged', kind);
|
export const isTaggedKind = (kind: string): kind is TaggedKind => isKindWith('tagged', kind);
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* A list declares nothing about what it holds: every item carries its own kind, read back off the
|
* A list declares nothing about what it holds: every item carries its own kind, read back off the
|
||||||
* value. JSON says what a number, a text, a boolean and a struct are; a tagged kind says so itself
|
* value. JSON says what a number, a text, a boolean, a list and a struct are; a tagged kind says so
|
||||||
* (`tagOf`), which keeps every list ever stored readable.
|
* itself (`tagOf`), which keeps every list ever stored readable.
|
||||||
*
|
*
|
||||||
* Depth is capped: a struct item holds flat items, and so does a list inside a struct field.
|
* Items nest to any depth (#239): the items of a list item and the fields of a struct item go
|
||||||
|
* through the same coercion again, so nothing deeper is dropped.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
type FieldValue,
|
|
||||||
isList,
|
isList,
|
||||||
isStruct,
|
isStruct,
|
||||||
|
type ListValue,
|
||||||
type PropertyValue,
|
type PropertyValue,
|
||||||
type StructValue,
|
type StructValue,
|
||||||
type TaggedItem,
|
|
||||||
tagged,
|
tagged,
|
||||||
tagOf,
|
tagOf,
|
||||||
untag,
|
untag,
|
||||||
} from '../model/Properties';
|
} from '../model/Properties';
|
||||||
import {
|
import { coerceField, isKindWith, isTaggedKind, type NestedKind, plainKindOf } from './kinds';
|
||||||
coerceField,
|
|
||||||
type FieldKind,
|
|
||||||
isKindWith,
|
|
||||||
isTaggedKind,
|
|
||||||
type ListItemKind,
|
|
||||||
plainKindOf,
|
|
||||||
} from './kinds';
|
|
||||||
|
|
||||||
/** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */
|
/** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */
|
||||||
export type ListItem = number | string | boolean | StructValue;
|
export type ListItem = PropertyValue;
|
||||||
|
|
||||||
/** What a flat position holds: no struct except a tagged item, so the depth stays capped. */
|
|
||||||
export type FlatItem = number | string | boolean | TaggedItem;
|
|
||||||
|
|
||||||
/** The kind an item is, taken from the item itself – there is nowhere else it could come from. */
|
/** The kind an item is, taken from the item itself – there is nowhere else it could come from. */
|
||||||
export function itemKindOf(item: PropertyValue | undefined): ListItemKind {
|
export function itemKindOf(item: PropertyValue | undefined): NestedKind {
|
||||||
const tag = tagOf(item);
|
const tag = tagOf(item);
|
||||||
if (tag !== undefined && isTaggedKind(tag)) return tag;
|
if (tag !== undefined && isTaggedKind(tag)) return tag;
|
||||||
if (isStruct(item) || isList(item)) return 'struct';
|
if (isList(item)) return 'list';
|
||||||
|
if (isStruct(item)) return 'struct';
|
||||||
const plain = plainKindOf(typeof item);
|
const plain = plainKindOf(typeof item);
|
||||||
return plain !== undefined && isKindWith('field', plain) ? plain : 'number';
|
return plain !== undefined && isKindWith('field', plain) ? plain : 'number';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flat positions hold no struct, so one reads as its text. */
|
|
||||||
export function fieldKindOf(value: PropertyValue | undefined): FieldKind {
|
|
||||||
const kind = itemKindOf(value);
|
|
||||||
return kind === 'struct' ? 'text' : kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `value` as an item of `kind`, which is how a row changes what it holds. */
|
/** `value` as an item of `kind`, which is how a row changes what it holds. */
|
||||||
export function makeItem(kind: ListItemKind, value: PropertyValue | undefined): ListItem {
|
export function makeItem(kind: NestedKind, value: PropertyValue | undefined): ListItem {
|
||||||
return kind === 'struct' ? structItemOf(value) : makeFlatItem(kind, value);
|
if (kind === 'list') return listOf(value);
|
||||||
|
if (kind === 'struct') return structItemOf(value);
|
||||||
|
if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind));
|
||||||
|
return coerceField(untag(value), kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */
|
/** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */
|
||||||
@@ -56,50 +44,26 @@ export function itemLike(current: PropertyValue | undefined, value: PropertyValu
|
|||||||
return makeItem(itemKindOf(current), value);
|
return makeItem(itemKindOf(current), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `value` as a flat item of `kind` – a struct item's field, where a struct itself cannot go. */
|
|
||||||
export function makeFlatItem(kind: FieldKind, value: PropertyValue | undefined): FlatItem {
|
|
||||||
if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind));
|
|
||||||
return coerceField(untag(value), kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A flat slot (a struct item's field, an item of a list in a struct field) keeps its kind too. */
|
|
||||||
export function fieldLike(current: PropertyValue | undefined, value: PropertyValue | undefined): FlatItem {
|
|
||||||
return makeFlatItem(fieldKindOf(current), value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Any value as a valid item; the one coercion a list value goes through. */
|
/** Any value as a valid item; the one coercion a list value goes through. */
|
||||||
export function listItemOf(value: PropertyValue | undefined): ListItem {
|
export function listItemOf(value: PropertyValue | undefined): ListItem {
|
||||||
return makeItem(itemKindOf(value), value);
|
return makeItem(itemKindOf(value), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The items of a list, each as the kind its own value says; anything else holds none. */
|
||||||
* A flat position holds a primitive or a tagged item. Anything deeper is dropped rather than
|
export function listOf(value: PropertyValue | undefined): ListValue {
|
||||||
* flattened – the bound the store writes.
|
return isList(value) ? value.map(listItemOf) : [];
|
||||||
*/
|
|
||||||
export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined {
|
|
||||||
if (isList(value) || (isStruct(value) && !isTaggedKind(tagOf(value) ?? ''))) return undefined;
|
|
||||||
return makeFlatItem(fieldKindOf(value), value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A struct item keeps the fields it carries, each as the kind its own value says. */
|
/** A struct item keeps the fields it carries, each as the kind its own value says. */
|
||||||
export function structItemOf(value: PropertyValue | undefined): StructValue {
|
export function structItemOf(value: PropertyValue | undefined): StructValue {
|
||||||
if (!isStruct(value) || tagOf(value) !== undefined) return {};
|
if (!isStruct(value) || tagOf(value) !== undefined) return {};
|
||||||
const fields: Record<string, FieldValue> = {};
|
const fields: Record<string, PropertyValue> = {};
|
||||||
for (const [key, item] of Object.entries(value)) {
|
for (const [key, item] of Object.entries(value)) fields[key] = listItemOf(item);
|
||||||
const flat = flatItemOf(item);
|
|
||||||
if (flat !== undefined) fields[key] = flat;
|
|
||||||
}
|
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The items of a list sitting in a struct field: flat ones only. */
|
|
||||||
export function flatListOf(value: PropertyValue | undefined): readonly FlatItem[] {
|
|
||||||
if (!isList(value)) return [];
|
|
||||||
return value.map(flatItemOf).filter((item): item is FlatItem => item !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
|
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
|
||||||
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: FieldKind }[] {
|
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: NestedKind }[] {
|
||||||
if (!isStruct(item) || tagOf(item) !== undefined) return [];
|
if (!isStruct(item) || tagOf(item) !== undefined) return [];
|
||||||
return Object.entries(item).map(([key, value]) => ({ key, kind: fieldKindOf(value) }));
|
return Object.entries(item).map(([key, value]) => ({ key, kind: itemKindOf(value) }));
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Where a value sits inside a nested one (#239): a key per struct step, an index per list step. A
|
||||||
|
* list in a struct field in a list item is `[3, 'drops']` – the address every nested row edits by.
|
||||||
|
*/
|
||||||
|
import { isList, isStruct, type PropertyValue, tagOf } from '../model/Properties';
|
||||||
|
|
||||||
|
export type ValueStep = string | number;
|
||||||
|
export type ValuePath = readonly ValueStep[];
|
||||||
|
|
||||||
|
/** The value at `path`, or undefined where a step finds nothing to step into. */
|
||||||
|
export function valueAt(value: PropertyValue | undefined, path: ValuePath): PropertyValue | undefined {
|
||||||
|
let at = value;
|
||||||
|
for (const step of path) {
|
||||||
|
if (typeof step === 'number') at = isList(at) ? at[step] : undefined;
|
||||||
|
else at = isStruct(at) && tagOf(at) === undefined ? at[step] : undefined;
|
||||||
|
if (at === undefined) return undefined;
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `value` with the part at `path` replaced by `next`, rebuilt along the way so nothing shared is
|
||||||
|
* changed. A struct step that finds no struct starts an empty one; a list index out of range, or a
|
||||||
|
* list step that finds no list, leaves `value` as it is – there is no item to replace.
|
||||||
|
*/
|
||||||
|
export function withValueAt(
|
||||||
|
value: PropertyValue | undefined,
|
||||||
|
path: ValuePath,
|
||||||
|
next: PropertyValue,
|
||||||
|
): PropertyValue | undefined {
|
||||||
|
if (path.length === 0) return next;
|
||||||
|
const [step, ...rest] = path as [ValueStep, ...ValueStep[]];
|
||||||
|
if (typeof step === 'number') {
|
||||||
|
if (!isList(value) || step < 0 || step >= value.length) return value;
|
||||||
|
const inner = withValueAt(value[step], rest, next);
|
||||||
|
if (inner === undefined || (rest.length > 0 && inner === value[step])) return value;
|
||||||
|
return value.map((item, i) => (i === step ? inner : item));
|
||||||
|
}
|
||||||
|
const struct = isStruct(value) && tagOf(value) === undefined ? value : {};
|
||||||
|
const inner = withValueAt(struct[step], rest, next);
|
||||||
|
// Nothing replaced further down (an index out of range): the value stays the very same.
|
||||||
|
if (inner === undefined || (rest.length > 0 && inner === struct[step])) return value;
|
||||||
|
return { ...struct, [step]: inner };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a path reads in a title: `drops`, `[3]`. */
|
||||||
|
export function pathLabel(path: ValuePath): string[] {
|
||||||
|
return path.map((step) => (typeof step === 'number' ? `[${step}]` : step));
|
||||||
|
}
|
||||||
+14
-39
@@ -47,14 +47,7 @@ import { isAssetType } from '../model/AssetTypes';
|
|||||||
import { isFontFile } from '../model/font';
|
import { isFontFile } from '../model/font';
|
||||||
import { isSoundFile } from '../model/sound';
|
import { isSoundFile } from '../model/sound';
|
||||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||||
import {
|
import { type PropertyBag, type PropertyValue, tagOf, type Vec2 } from '../model/Properties';
|
||||||
type FieldValue,
|
|
||||||
type PropertyBag,
|
|
||||||
type PropertyValue,
|
|
||||||
type StructValue,
|
|
||||||
tagOf,
|
|
||||||
type Vec2,
|
|
||||||
} from '../model/Properties';
|
|
||||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||||
import { readScope, readSharedScope } from '../model/Scope';
|
import { readScope, readSharedScope } from '../model/Scope';
|
||||||
import type { SceneObject } from '../model/SceneObject';
|
import type { SceneObject } from '../model/SceneObject';
|
||||||
@@ -81,7 +74,7 @@ import {
|
|||||||
VARIABLE_KINDS,
|
VARIABLE_KINDS,
|
||||||
type VariableKind,
|
type VariableKind,
|
||||||
} from '../properties/kinds';
|
} from '../properties/kinds';
|
||||||
import { type FlatItem, makeFlatItem } from '../properties/listItems';
|
import { makeItem } from '../properties/listItems';
|
||||||
import {
|
import {
|
||||||
coerceValue,
|
coerceValue,
|
||||||
initialOf,
|
initialOf,
|
||||||
@@ -380,6 +373,7 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
|||||||
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
|
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
|
||||||
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
|
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
|
||||||
if (f.kind === 'list') return { key, kind: 'list' };
|
if (f.kind === 'list') return { key, kind: 'list' };
|
||||||
|
if (f.kind === 'struct') return { key, kind: 'struct', fields: normalizeStructFields(f.fields) };
|
||||||
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,46 +781,27 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A value from an untyped slot (node params, instance edits). Lists and structs are kept but
|
* A value from an untyped slot (node params, instance edits): lists and structs at any depth, each
|
||||||
* flattened one level, so nothing deeper than the model allows can enter behind the type's back.
|
* item as the kind it says it is. Only what is no value at all (`null`, a bad tag) is dropped.
|
||||||
*/
|
*/
|
||||||
function asStoredValue(value: unknown): PropertyValue | undefined {
|
function asStoredValue(value: unknown): PropertyValue | undefined {
|
||||||
if (isPrimitive(value)) return value;
|
if (isPrimitive(value)) return value;
|
||||||
if (Array.isArray(value)) return value.map(asStoredItem).filter((item) => item !== undefined);
|
if (Array.isArray(value))
|
||||||
return asStoredStruct(value, asStoredField);
|
return value.map(asStoredValue).filter((item): item is PropertyValue => item !== undefined);
|
||||||
}
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
|
||||||
const isPrimitive = (value: unknown): value is number | string | boolean =>
|
|
||||||
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
|
|
||||||
|
|
||||||
/** A primitive, or the one object a flat position may hold: a tagged item. */
|
|
||||||
const asFlatItem = (value: unknown): FlatItem | undefined => {
|
|
||||||
if (isPrimitive(value)) return value;
|
|
||||||
const tag = tagOf(value);
|
const tag = tagOf(value);
|
||||||
return tag !== undefined && isTaggedKind(tag) ? makeFlatItem(tag, value as PropertyValue) : undefined;
|
if (tag !== undefined) return isTaggedKind(tag) ? makeItem(tag, value as PropertyValue) : undefined;
|
||||||
};
|
const fields: Record<string, PropertyValue> = {};
|
||||||
|
|
||||||
/** A list item: a flat one, or a struct of flat ones. */
|
|
||||||
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
|
|
||||||
asFlatItem(value) ?? asStoredStruct(value, asFlatItem);
|
|
||||||
|
|
||||||
/** A struct field: a flat item or a flat list. A field that is itself a struct is dropped. */
|
|
||||||
const asStoredField = (value: unknown): FieldValue | undefined =>
|
|
||||||
Array.isArray(value) ? value.map(asFlatItem).filter((item) => item !== undefined) : asFlatItem(value);
|
|
||||||
|
|
||||||
function asStoredStruct(
|
|
||||||
value: unknown,
|
|
||||||
ofField: (item: unknown) => FieldValue | undefined,
|
|
||||||
): StructValue | undefined {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
||||||
const fields: Record<string, FieldValue> = {};
|
|
||||||
for (const [key, item] of Object.entries(value)) {
|
for (const [key, item] of Object.entries(value)) {
|
||||||
const stored = ofField(item);
|
const stored = asStoredValue(item);
|
||||||
if (stored !== undefined) fields[key] = stored;
|
if (stored !== undefined) fields[key] = stored;
|
||||||
}
|
}
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPrimitive = (value: unknown): value is number | string | boolean =>
|
||||||
|
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
|
||||||
|
|
||||||
function toNumber(value: unknown): number {
|
function toNumber(value: unknown): number {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
import { findAsset, type Project } from '../../core/model/Project';
|
import { findAsset, type Project } from '../../core/model/Project';
|
||||||
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
|
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
|
||||||
import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope';
|
import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope';
|
||||||
import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds';
|
import { type NestedKind, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||||||
import {
|
import {
|
||||||
isRowShown,
|
isRowShown,
|
||||||
type PropertyDef,
|
type PropertyDef,
|
||||||
@@ -50,7 +50,9 @@ import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
|
|||||||
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
|
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
|
||||||
import { assetValue } from './valueSpecs';
|
import { assetValue } from './valueSpecs';
|
||||||
|
|
||||||
const newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key, kind });
|
/** A struct field declares its own fields, starting with none. */
|
||||||
|
const newField = (key: string, kind: NestedKind): StructFieldDef =>
|
||||||
|
kind === 'struct' ? { key, kind, fields: [] } : { key, kind };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structure-defining facts of an asset selection: the schema keys and kinds of the rows shown, plus
|
* Structure-defining facts of an asset selection: the schema keys and kinds of the rows shown, plus
|
||||||
@@ -73,9 +75,9 @@ export function assetSignature(project: Project, assetId: string): string {
|
|||||||
return rows.map(defSignature).join(',') + machine + kept + keeps + shapes;
|
return rows.map(defSignature).join(',') + machine + kept + keeps + shapes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defSignature(def: PropertyDef): string {
|
export function defSignature(def: PropertyDef | StructFieldDef): string {
|
||||||
// Only a struct has rows of its own; a list is one row whatever its items are.
|
// Only a struct has rows of its own, at any depth; a list is one row whatever its items are.
|
||||||
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
|
const fields = def.kind === 'struct' ? def.fields.map(defSignature).join('+') : '';
|
||||||
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
|
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +171,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
|
|||||||
return {
|
return {
|
||||||
...previewingHandlers<Asset>(host, spec),
|
...previewingHandlers<Asset>(host, spec),
|
||||||
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
|
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
|
||||||
...structFields<Asset>(host, def, spec, (field) => ({ kind: 'asset', assetId, key: def.key, field })),
|
...structFields<Asset>(host, def, spec, (path) => ({ kind: 'asset', assetId, key: def.key, path })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +185,14 @@ export function shownValues(host: InspectorHost, asset: Asset): PropertyBag {
|
|||||||
return live ? { ...values, ...bagWithoutObjects(live) } : values;
|
return live ? { ...values, ...bagWithoutObjects(live) } : values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The fields declared at `path` inside a struct declaration; none where the path leads nowhere. */
|
||||||
|
function fieldsAt(fields: readonly StructFieldDef[], path: readonly string[]): readonly StructFieldDef[] {
|
||||||
|
const [step, ...rest] = path;
|
||||||
|
if (step === undefined) return fields;
|
||||||
|
const field = fields.find((f) => f.key === step);
|
||||||
|
return field?.kind === 'struct' ? fieldsAt(field.fields, rest) : [];
|
||||||
|
}
|
||||||
|
|
||||||
/** Declared variables, each with its declaration editable – plus an "add" menu. */
|
/** Declared variables, each with its declaration editable – plus an "add" menu. */
|
||||||
function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAsset): void {
|
function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAsset): void {
|
||||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
@@ -206,14 +216,16 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAss
|
|||||||
const edit: VariableEditing = {
|
const edit: VariableEditing = {
|
||||||
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)),
|
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)),
|
||||||
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
|
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
|
||||||
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
|
renameField: (path, field, next) =>
|
||||||
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
|
run(new RenameStructFieldCommand(asset.id, def.key, field.key, next, path)),
|
||||||
addField: (kind) =>
|
removeField: (path, field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key, path)),
|
||||||
|
addField: (path, kind) =>
|
||||||
run(
|
run(
|
||||||
new AddStructFieldCommand(
|
new AddStructFieldCommand(
|
||||||
asset.id,
|
asset.id,
|
||||||
def.key,
|
def.key,
|
||||||
newField(nextFieldName(def.kind === 'struct' ? def.fields : []), kind),
|
newField(nextFieldName(def.kind === 'struct' ? fieldsAt(def.fields, path) : []), kind),
|
||||||
|
path,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { tilemapLayerNames } from '../../core/model/tilemap';
|
|||||||
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
|
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
|
||||||
import {
|
import {
|
||||||
emptyFieldValue,
|
emptyFieldValue,
|
||||||
|
structOf,
|
||||||
flatDef,
|
flatDef,
|
||||||
type PropertyDef,
|
type PropertyDef,
|
||||||
type StructFieldDef,
|
type StructFieldDef,
|
||||||
@@ -71,11 +72,12 @@ export interface FieldHandlers {
|
|||||||
/** Sends a list to the List tab; only the caller knows where the value it edits lives. */
|
/** Sends a list to the List tab; only the caller knows where the value it edits lives. */
|
||||||
openList?: () => void;
|
openList?: () => void;
|
||||||
/**
|
/**
|
||||||
* Handlers for one field of a struct. Writing a field means writing the whole bundle, so only
|
* Handlers for one field of a struct, `path` being the keys from the bundle down to it (a struct
|
||||||
* the caller – which knows where that bundle lives – can build them. Without them a struct row
|
* field nests further, #239). Writing a field means writing the whole bundle, so only the caller –
|
||||||
* can only show what it holds.
|
* which knows where that bundle lives – can build them. Without them a struct row can only show
|
||||||
|
* what it holds.
|
||||||
*/
|
*/
|
||||||
structFieldHandlers?: (field: StructFieldDef) => FieldHandlers;
|
structFieldHandlers?: (path: readonly string[], field: StructFieldDef) => FieldHandlers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldRow {
|
export interface FieldRow {
|
||||||
@@ -98,6 +100,15 @@ export const NAME_WIDTH = 90;
|
|||||||
export const FIELD_INDENT = 12;
|
export const FIELD_INDENT = 12;
|
||||||
const FIELD_LABEL_WIDTH = MARK_WIDTH + Theme.gap + NAME_WIDTH;
|
const FIELD_LABEL_WIDTH = MARK_WIDTH + Theme.gap + NAME_WIDTH;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A name column `depth` indent steps in, narrowed by what the indent takes so the value after it
|
||||||
|
* starts on the same column at any depth. Deep enough, it stops shrinking and the values move right.
|
||||||
|
*/
|
||||||
|
export function nameWidth(depth: number): number {
|
||||||
|
return Math.max(MIN_NAME_WIDTH, NAME_WIDTH - depth * (FIELD_INDENT + Theme.gap));
|
||||||
|
}
|
||||||
|
const MIN_NAME_WIDTH = 40;
|
||||||
|
|
||||||
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
|
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
|
||||||
export interface FieldAction {
|
export interface FieldAction {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -276,7 +287,7 @@ export function linkLabel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Field {
|
export interface Field {
|
||||||
widget: Widget;
|
widget: Widget;
|
||||||
/** Whether the widget should take the remaining row width. */
|
/** Whether the widget should take the remaining row width. */
|
||||||
grow: boolean;
|
grow: boolean;
|
||||||
@@ -298,11 +309,27 @@ export function removeButton(onClick: () => void): Button {
|
|||||||
|
|
||||||
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
|
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
|
||||||
|
|
||||||
/** A struct field as a property definition – a list field becomes the same row a list property has. */
|
/**
|
||||||
|
* A struct field as a property definition – a list field becomes the same row a list property has,
|
||||||
|
* a struct field the same block a struct property has.
|
||||||
|
*/
|
||||||
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
|
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
|
||||||
return field.kind === 'list'
|
if (field.kind === 'list') return { key: field.key, label, kind: 'list', default: [] };
|
||||||
? { key: field.key, label, kind: 'list', default: [] }
|
if (field.kind === 'struct')
|
||||||
: flatDef(field.key, field.kind, { label });
|
return {
|
||||||
|
key: field.key,
|
||||||
|
label,
|
||||||
|
kind: 'struct',
|
||||||
|
fields: field.fields,
|
||||||
|
default: structOf(field.fields, undefined),
|
||||||
|
};
|
||||||
|
return flatDef(field.key, field.kind, { label });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The handlers of a struct field that is a struct again: its own fields answer one step deeper. */
|
||||||
|
export function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers {
|
||||||
|
const per = handlers.structFieldHandlers;
|
||||||
|
return per ? { ...READ_ONLY, structFieldHandlers: (path, field) => per([key, ...path], field) } : READ_ONLY;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a reference kind names in the project: how an id reads, and what its picker may offer. */
|
/** What a reference kind names in the project: how an id reads, and what its picker may offer. */
|
||||||
@@ -439,7 +466,9 @@ export function createField(
|
|||||||
const fields = new Map<string, Field>();
|
const fields = new Map<string, Field>();
|
||||||
for (const field of def.fields) {
|
for (const field of def.fields) {
|
||||||
const { row } = labelledRow(field.key);
|
const { row } = labelledRow(field.key);
|
||||||
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
|
const own =
|
||||||
|
field.kind === 'struct' ? nestedStructHandlers(handlers, field.key) : per([field.key], field);
|
||||||
|
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), own);
|
||||||
addValue(row, sub);
|
addValue(row, sub);
|
||||||
fields.set(field.key, sub);
|
fields.set(field.key, sub);
|
||||||
stack.add(row);
|
stack.add(row);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
RevertOverrideToTemplateCommand,
|
RevertOverrideToTemplateCommand,
|
||||||
RevertPositionToTemplateCommand,
|
RevertPositionToTemplateCommand,
|
||||||
} from '../../core/commands/applyCommands';
|
} from '../../core/commands/applyCommands';
|
||||||
|
import type { ValuePath } from '../../core/properties/valuePath';
|
||||||
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
|
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
|
||||||
import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
|
import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
|
||||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap';
|
import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap';
|
||||||
@@ -342,12 +343,12 @@ function propertyRow(
|
|||||||
owner.type === 'tilemap' && (def.key === TILEMAP_COLUMNS_KEY || def.key === TILEMAP_ROWS_KEY)
|
owner.type === 'tilemap' && (def.key === TILEMAP_COLUMNS_KEY || def.key === TILEMAP_ROWS_KEY)
|
||||||
? tilemapSizeValue(host.state, holderId, objectId, def.key)
|
? tilemapSizeValue(host.state, holderId, objectId, def.key)
|
||||||
: overrideValue(host.state, holderId, objectId, def.key);
|
: overrideValue(host.state, holderId, objectId, def.key);
|
||||||
const fieldSlot = (field: string): ListSlot => ({
|
const fieldSlot = (path: ValuePath): ListSlot => ({
|
||||||
kind: 'object',
|
kind: 'object',
|
||||||
holderId,
|
holderId,
|
||||||
objectId,
|
objectId,
|
||||||
key: def.key,
|
key: def.key,
|
||||||
field,
|
path,
|
||||||
});
|
});
|
||||||
const rowActions: RowActions = {
|
const rowActions: RowActions = {
|
||||||
extra: aspectActions(
|
extra: aspectActions(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '../../core/properties/PropertySchema';
|
} from '../../core/properties/PropertySchema';
|
||||||
import type { Command } from '../../core/commands/Command';
|
import type { Command } from '../../core/commands/Command';
|
||||||
import type { PropertyValue } from '../../core/model/Properties';
|
import type { PropertyValue } from '../../core/model/Properties';
|
||||||
|
import { type ValuePath, withValueAt } from '../../core/properties/valuePath';
|
||||||
import type { EditorState, ListSlot } from '../state';
|
import type { EditorState, ListSlot } from '../state';
|
||||||
import type { FieldHandlers } from './fieldFactory';
|
import type { FieldHandlers } from './fieldFactory';
|
||||||
|
|
||||||
@@ -31,29 +32,30 @@ export function structFields<Target>(
|
|||||||
host: { state: EditorState },
|
host: { state: EditorState },
|
||||||
def: PropertyDef,
|
def: PropertyDef,
|
||||||
spec: PreviewedValue<Target>,
|
spec: PreviewedValue<Target>,
|
||||||
fieldSlot?: (field: string) => ListSlot,
|
fieldSlot?: (path: ValuePath) => ListSlot,
|
||||||
): Pick<FieldHandlers, 'structFieldHandlers'> {
|
): Pick<FieldHandlers, 'structFieldHandlers'> {
|
||||||
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec, fieldSlot) } : {};
|
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec, fieldSlot) } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `fieldSlot` is what a `list` field needs: its own address, so the List tab can be opened on it. */
|
/**
|
||||||
|
* `fieldSlot` is what a `list` field needs: its own address, so the List tab can be opened on it.
|
||||||
|
* `path` leads from the bundle to the field, through struct fields as deep as they nest (#239).
|
||||||
|
*/
|
||||||
export function structFieldsOf<Target>(
|
export function structFieldsOf<Target>(
|
||||||
host: { state: EditorState },
|
host: { state: EditorState },
|
||||||
def: StructDef,
|
def: StructDef,
|
||||||
spec: PreviewedValue<Target>,
|
spec: PreviewedValue<Target>,
|
||||||
fieldSlot?: (field: string) => ListSlot,
|
fieldSlot?: (path: ValuePath) => ListSlot,
|
||||||
): (field: StructFieldDef) => FieldHandlers {
|
): (path: readonly string[], field: StructFieldDef) => FieldHandlers {
|
||||||
return (field) => ({
|
return (path, field) => ({
|
||||||
...previewingHandlers<Target>(host, {
|
...previewingHandlers<Target>(host, {
|
||||||
...spec,
|
...spec,
|
||||||
toModel: (input, target) => ({
|
toModel: (input, target) => {
|
||||||
...structOf(def.fields, (spec.current ?? spec.read)(target)),
|
const bundle = structOf(def.fields, (spec.current ?? spec.read)(target));
|
||||||
[field.key]: coerceFieldValue(input, field),
|
return withValueAt(bundle, path, coerceFieldValue(input, field)) ?? bundle;
|
||||||
}),
|
},
|
||||||
}),
|
}),
|
||||||
...(field.kind === 'list' && fieldSlot
|
...(field.kind === 'list' && fieldSlot ? { openList: () => host.state.openList(fieldSlot(path)) } : {}),
|
||||||
? { openList: () => host.state.openList(fieldSlot(field.key)) }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { PropertyValue } from '../../core/model/Properties';
|
import type { PropertyValue } from '../../core/model/Properties';
|
||||||
import {
|
import { type Kind, kindLabel, kindMark, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
|
||||||
type Kind,
|
import type { PropertyDef, StructFieldDef } from '../../core/properties/PropertySchema';
|
||||||
kindLabel,
|
|
||||||
kindMark,
|
|
||||||
STRUCT_FIELD_KINDS,
|
|
||||||
type StructFieldKind,
|
|
||||||
} from '../../core/properties/kinds';
|
|
||||||
import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
@@ -29,7 +23,7 @@ import {
|
|||||||
fieldDef,
|
fieldDef,
|
||||||
type FieldRow,
|
type FieldRow,
|
||||||
MARK_WIDTH,
|
MARK_WIDTH,
|
||||||
NAME_WIDTH,
|
nameWidth,
|
||||||
READ_ONLY,
|
READ_ONLY,
|
||||||
removeButton,
|
removeButton,
|
||||||
type RowActions,
|
type RowActions,
|
||||||
@@ -43,13 +37,16 @@ export function kindMenu<K extends string>(kinds: readonly K[], pick: (kind: K)
|
|||||||
return kinds.map((kind) => ({ label: kindLabel(kind), onSelect: () => pick(kind) }));
|
return kinds.map((kind) => ({ label: kindLabel(kind), onSelect: () => pick(kind) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Changing a declaration – offered only on the asset that declares it. */
|
/**
|
||||||
|
* 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 {
|
export interface VariableEditing {
|
||||||
rename: (next: string) => void;
|
rename: (next: string) => void;
|
||||||
remove: () => void;
|
remove: () => void;
|
||||||
renameField: (field: StructFieldDef, next: string) => void;
|
renameField: (path: readonly string[], field: StructFieldDef, next: string) => void;
|
||||||
removeField: (field: StructFieldDef) => void;
|
removeField: (path: readonly string[], field: StructFieldDef) => void;
|
||||||
addField: (kind: StructFieldKind) => 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). */
|
/** A behavior's switch for one variable: carried on from one `Run behavior` call to the next (#229). */
|
||||||
@@ -82,7 +79,7 @@ export function buildVariable(
|
|||||||
let setValue: (next: PropertyValue) => void = () => {};
|
let setValue: (next: PropertyValue) => void = () => {};
|
||||||
if (def.kind === 'struct') {
|
if (def.kind === 'struct') {
|
||||||
head.add(new Spacer(), 1);
|
head.add(new Spacer(), 1);
|
||||||
if (edit) head.add(addFieldMenu(STRUCT_FIELD_KINDS, edit));
|
if (edit) head.add(addFieldMenu(edit, []));
|
||||||
} else {
|
} else {
|
||||||
setValue = addValue(head, createField(host, def, value, handlers));
|
setValue = addValue(head, createField(host, def, value, handlers));
|
||||||
}
|
}
|
||||||
@@ -91,7 +88,10 @@ export function buildVariable(
|
|||||||
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
|
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
|
||||||
host.body.add(head);
|
host.body.add(head);
|
||||||
|
|
||||||
if (def.kind === 'struct') setValue = structFieldRows(host, def, value, handlers, edit);
|
if (def.kind === 'struct') {
|
||||||
|
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
|
||||||
|
setValue = structFieldRows(host, def.fields, [], value, handlers, edit);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
row: head,
|
row: head,
|
||||||
@@ -115,52 +115,57 @@ function keepButton(keep: VariableKeep): Button {
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row per field, each with its own value; returns the setter that pushes a bundle into them. */
|
/**
|
||||||
|
* 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(
|
function structFieldRows(
|
||||||
host: InspectorHost,
|
host: InspectorHost,
|
||||||
def: StructDef,
|
fields: readonly StructFieldDef[],
|
||||||
|
path: readonly string[],
|
||||||
value: PropertyValue,
|
value: PropertyValue,
|
||||||
handlers: FieldHandlers,
|
handlers: FieldHandlers,
|
||||||
edit: VariableEditing | undefined,
|
edit: VariableEditing | undefined,
|
||||||
): (next: PropertyValue) => void {
|
): (next: PropertyValue) => void {
|
||||||
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
|
|
||||||
const setters = new Map<string, (item: PropertyValue) => void>();
|
const setters = new Map<string, (item: PropertyValue) => void>();
|
||||||
for (const field of def.fields) {
|
for (const field of fields) {
|
||||||
const row = fieldRow(field, edit);
|
const row = fieldRow(field, path, edit);
|
||||||
const item = createField(
|
const item = structFieldValue(value, field);
|
||||||
host,
|
if (field.kind === 'struct') {
|
||||||
fieldDef(field),
|
row.add(new Spacer(), 1);
|
||||||
structFieldValue(value, field),
|
if (edit) row.add(addFieldMenu(edit, [...path, field.key]));
|
||||||
handlers.structFieldHandlers?.(field) ?? READ_ONLY,
|
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
||||||
);
|
host.body.add(row);
|
||||||
setters.set(field.key, addValue(row, item));
|
setters.set(field.key, structFieldRows(host, field.fields, [...path, field.key], item, handlers, edit));
|
||||||
if (edit) row.add(removeButton(() => edit.removeField(field)));
|
continue;
|
||||||
|
}
|
||||||
|
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
|
||||||
|
setters.set(field.key, addValue(row, createField(host, fieldDef(field), item, own)));
|
||||||
|
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
||||||
host.body.add(row);
|
host.body.add(row);
|
||||||
}
|
}
|
||||||
return (next) => {
|
return (next) => {
|
||||||
for (const field of def.fields) setters.get(field.key)?.(structFieldValue(next, field));
|
for (const field of fields) setters.get(field.key)?.(structFieldValue(next, field));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
|
function fieldRow(field: StructFieldDef, path: readonly string[], edit: VariableEditing | undefined): HStack {
|
||||||
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
|
const depth = path.length + 1;
|
||||||
return gridRow(1, markLabel(field.kind), name);
|
const name = nameCell(field.key, depth, edit && ((next) => edit.renameField(path, field, next)));
|
||||||
|
return gridRow(depth, markLabel(field.kind), name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
|
/** 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 {
|
function gridRow(depth: number, mark: Widget, name: Widget): HStack {
|
||||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
row.minHeight = Theme.rowHeight;
|
row.minHeight = Theme.rowHeight;
|
||||||
if (depth > 0) row.add(new Spacer(FIELD_INDENT));
|
if (depth > 0) row.add(new Spacer(depth * (FIELD_INDENT + Theme.gap) - Theme.gap));
|
||||||
row.add(mark);
|
row.add(mark);
|
||||||
row.add(name);
|
row.add(name);
|
||||||
return row;
|
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. */
|
/** 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 {
|
function nameCell(text: string, depth: number, rename: ((next: string) => void) | undefined): Widget {
|
||||||
const width = nameWidth(depth);
|
const width = nameWidth(depth);
|
||||||
@@ -201,8 +206,9 @@ export function markPicker(kind: string, items: MenuItem[]): Button {
|
|||||||
return picker;
|
return picker;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing): Dropdown {
|
/** `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' });
|
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||||
add.onClick = () => new Menu(kindMenu(kinds, (kind) => edit.addField(kind))).openBelow(add);
|
add.onClick = () => new Menu(kindMenu(NESTED_KINDS, (kind) => edit.addField(path, kind))).openBelow(add);
|
||||||
return add;
|
return add;
|
||||||
}
|
}
|
||||||
+133
-93
@@ -20,27 +20,31 @@ import {
|
|||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import {
|
import {
|
||||||
type FieldValue,
|
type FieldValue,
|
||||||
|
isList,
|
||||||
|
isStruct,
|
||||||
type ListValue,
|
type ListValue,
|
||||||
type PropertyValue,
|
type PropertyValue,
|
||||||
textOf,
|
textOf,
|
||||||
untag,
|
untag,
|
||||||
} from '../../core/model/Properties';
|
} from '../../core/model/Properties';
|
||||||
import { nextFieldName } from '../../core/commands/naming';
|
import { nextFieldName } from '../../core/commands/naming';
|
||||||
import { FIELD_KINDS, type FieldKind, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds';
|
import { type FieldKind, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
|
||||||
import {
|
import {
|
||||||
type ListItem,
|
type ListItem,
|
||||||
itemKindOf,
|
itemKindOf,
|
||||||
makeFlatItem,
|
|
||||||
makeItem,
|
makeItem,
|
||||||
structItemFields,
|
structItemFields,
|
||||||
structItemOf,
|
structItemOf,
|
||||||
} from '../../core/properties/listItems';
|
} from '../../core/properties/listItems';
|
||||||
import { flatDef } from '../../core/properties/PropertySchema';
|
import { flatDef } from '../../core/properties/PropertySchema';
|
||||||
|
import { valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath';
|
||||||
import {
|
import {
|
||||||
addValue,
|
addValue,
|
||||||
createField,
|
createField,
|
||||||
|
type Field,
|
||||||
type FieldContext,
|
type FieldContext,
|
||||||
NAME_WIDTH,
|
FIELD_INDENT,
|
||||||
|
nameWidth,
|
||||||
READ_ONLY,
|
READ_ONLY,
|
||||||
removeButton,
|
removeButton,
|
||||||
} from '../inspector/fieldFactory';
|
} from '../inspector/fieldFactory';
|
||||||
@@ -103,7 +107,7 @@ export class ListEditor extends VStack {
|
|||||||
this.addButton = new Dropdown('+ Item', { variant: 'ghost' });
|
this.addButton = new Dropdown('+ Item', { variant: 'ghost' });
|
||||||
// Built on click, not once: which kinds fit depends on the list the tab is showing.
|
// Built on click, not once: which kinds fit depends on the list the tab is showing.
|
||||||
this.addButton.onClick = () => {
|
this.addButton.onClick = () => {
|
||||||
const kinds = this.access()?.kinds ?? LIST_ITEM_KINDS;
|
const kinds = this.access()?.kinds ?? NESTED_KINDS;
|
||||||
new Menu(kindMenu(kinds, (kind) => this.addItem(kind))).openBelow(this.addButton);
|
new Menu(kindMenu(kinds, (kind) => this.addItem(kind))).openBelow(this.addButton);
|
||||||
};
|
};
|
||||||
footer.add(this.addButton);
|
footer.add(this.addButton);
|
||||||
@@ -139,7 +143,7 @@ export class ListEditor extends VStack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** For the check scripts: what the tab shows and how its rows read right now. */
|
/** For the check scripts: what the tab shows and how its rows read right now. */
|
||||||
debugInfo(): { title: string; items: string[]; kinds: ListItemKind[]; offers: ListItemKind[] } {
|
debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[] } {
|
||||||
const access = this.access();
|
const access = this.access();
|
||||||
if (!access) return { title: '', items: [], kinds: [], offers: [] };
|
if (!access) return { title: '', items: [], kinds: [], offers: [] };
|
||||||
return {
|
return {
|
||||||
@@ -155,17 +159,10 @@ export class ListEditor extends VStack {
|
|||||||
return slot ? resolveList(this.state, slot) : null;
|
return slot ? resolveList(this.state, slot) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything that decides how many rows there are and of what kind – a struct item's fields included. */
|
/** Everything that decides how many rows there are and of what kind – a struct item's fields included, at any depth. */
|
||||||
private signatureOf(access: ListAccess | null): string {
|
private signatureOf(access: ListAccess | null): string {
|
||||||
if (!access) return '';
|
if (!access) return '';
|
||||||
const shape = access.value
|
const shape = access.value.map(shapeOf).join(',');
|
||||||
.map(
|
|
||||||
(item) =>
|
|
||||||
`${itemKindOf(item)}${structItemFields(item)
|
|
||||||
.map((f) => `:${f.key}:${f.kind}`)
|
|
||||||
.join('')}`,
|
|
||||||
)
|
|
||||||
.join(',');
|
|
||||||
return `${access.title}|${shape}|${access.value.length}`;
|
return `${access.title}|${shape}|${access.value.length}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,24 +209,12 @@ export class ListEditor extends VStack {
|
|||||||
label.minWidth = INDEX_WIDTH;
|
label.minWidth = INDEX_WIDTH;
|
||||||
if (tall) label.minHeight = Theme.rowHeight;
|
if (tall) label.minHeight = Theme.rowHeight;
|
||||||
row.add(label);
|
row.add(label);
|
||||||
const mark = markPicker(
|
const mark = this.kindPicker(access, [index], kind);
|
||||||
kind,
|
|
||||||
// Through `untag`, so a color keeps its number when it becomes one.
|
|
||||||
kindMenu(
|
|
||||||
access.kinds.filter((next) => next !== kind),
|
|
||||||
(next) => this.replaceItem(access, index, makeItem(next, untag(access.value[index]))),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (tall) mark.minHeight = Theme.rowHeight;
|
if (tall) mark.minHeight = Theme.rowHeight;
|
||||||
row.add(mark);
|
row.add(mark);
|
||||||
if (tall) this.addStructItem(access, row, mark, item, index);
|
if (tall) this.addStructItem(access, row, mark, index);
|
||||||
else {
|
else {
|
||||||
const field = createField(
|
const field = this.valueField(access, [index], kind, item);
|
||||||
this.ctx,
|
|
||||||
flatDef(String(index), kind),
|
|
||||||
untag(item) ?? '',
|
|
||||||
access.itemHandlers(index),
|
|
||||||
);
|
|
||||||
addValue(row, field);
|
addValue(row, field);
|
||||||
this.setters.push((next) => field.setValue(untag(next) ?? ''));
|
this.setters.push((next) => field.setValue(untag(next) ?? ''));
|
||||||
this.rowWidgets.push(rowWidgets(mark, field.widget));
|
this.rowWidgets.push(rowWidgets(mark, field.widget));
|
||||||
@@ -241,92 +226,138 @@ export class ListEditor extends VStack {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A struct item carries its own shape, so its fields are named, typed and removed right here. */
|
/** The mark of the value at `path`, offering the other kinds. Through `untag`, so a color keeps its number when it becomes one. */
|
||||||
private addStructItem(
|
private kindPicker(access: ListAccess, path: ValuePath, kind: NestedKind): Widget {
|
||||||
|
return markPicker(
|
||||||
|
kind,
|
||||||
|
kindMenu(
|
||||||
|
access.kinds.filter((next) => next !== kind),
|
||||||
|
(next) => this.replaceAt(access, path, makeItem(next, untag(valueAt(access.value, path)))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The widget of a value that is not a struct: a list is its item count, opening it one level down. */
|
||||||
|
private valueField(
|
||||||
access: ListAccess,
|
access: ListAccess,
|
||||||
row: HStack,
|
path: ValuePath,
|
||||||
mark: Widget,
|
kind: NestedKind,
|
||||||
item: PropertyValue,
|
value: PropertyValue | undefined,
|
||||||
index: number,
|
): Field {
|
||||||
): void {
|
const key = String(path.at(-1));
|
||||||
const bundle = structItemOf(item);
|
if (kind === 'list')
|
||||||
const fields = structItemFields(item);
|
return createField(this.ctx, { key, label: key, kind: 'list', default: [] }, value ?? [], {
|
||||||
// A field value edit does not rebuild the row, so every button reads the item as it is now.
|
...READ_ONLY,
|
||||||
const now = () => structItemOf(access.value[index]);
|
openList: () => access.openList(path),
|
||||||
|
});
|
||||||
|
return createField(
|
||||||
|
this.ctx,
|
||||||
|
flatDef(key, kind as FieldKind),
|
||||||
|
untag(value) ?? '',
|
||||||
|
access.handlersAt(path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A struct item carries its own shape, so its fields are named, typed and removed right here. */
|
||||||
|
private addStructItem(access: ListAccess, row: HStack, mark: Widget, index: number): void {
|
||||||
const stack = new VStack({ gap: Theme.gap });
|
const stack = new VStack({ gap: Theme.gap });
|
||||||
const widgets = rowWidgets(mark, stack);
|
const widgets = rowWidgets(mark, stack);
|
||||||
const setters = new Map<string, (value: PropertyValue) => void>();
|
const leaves: Leaf[] = [];
|
||||||
const handlers = access.itemHandlers(index);
|
this.addStructFields(access, stack, [index], 0, widgets, leaves);
|
||||||
for (const field of fields) {
|
|
||||||
const line = new HStack({ gap: Theme.gap, align: 'center' });
|
|
||||||
line.minHeight = Theme.rowHeight;
|
|
||||||
const fieldMark = markPicker(
|
|
||||||
field.kind,
|
|
||||||
kindMenu(
|
|
||||||
FIELD_KINDS.filter((next) => next !== field.kind),
|
|
||||||
(next) =>
|
|
||||||
this.replaceItem(access, index, {
|
|
||||||
...now(),
|
|
||||||
[field.key]: makeFlatItem(next, untag(now()[field.key])),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const name = nameField(field.key, (to) => {
|
|
||||||
if (to && to !== field.key) this.replaceItem(access, index, renamedField(now(), field.key, to));
|
|
||||||
});
|
|
||||||
line.add(fieldMark);
|
|
||||||
line.add(name);
|
|
||||||
const sub = createField(
|
|
||||||
this.ctx,
|
|
||||||
flatDef(field.key, field.kind),
|
|
||||||
untag(bundle[field.key]) ?? '',
|
|
||||||
handlers.structFieldHandlers?.(field) ?? READ_ONLY,
|
|
||||||
);
|
|
||||||
setters.set(field.key, addValue(line, sub));
|
|
||||||
widgets.parts.set(field.key, sub.widget);
|
|
||||||
widgets.marks.set(field.key, fieldMark);
|
|
||||||
widgets.names.set(field.key, name);
|
|
||||||
line.add(removeButton(() => this.replaceItem(access, index, withoutField(now(), field.key))));
|
|
||||||
stack.add(line);
|
|
||||||
}
|
|
||||||
const foot = new HStack({ gap: Theme.gap, align: 'center' });
|
|
||||||
foot.minHeight = Theme.rowHeight;
|
|
||||||
foot.add(addFieldMenu((kind) => this.addField(access, index, now(), kind)));
|
|
||||||
foot.add(new Spacer(), 1);
|
|
||||||
stack.add(foot);
|
|
||||||
row.add(stack, 1);
|
row.add(stack, 1);
|
||||||
this.setters.push((next) => {
|
this.setters.push((next) => {
|
||||||
const now = structItemOf(next);
|
for (const leaf of leaves) leaf.set(untag(valueAt(next, leaf.within)) ?? '');
|
||||||
for (const field of fields) setters.get(field.key)?.(untag(now[field.key]) ?? '');
|
|
||||||
});
|
});
|
||||||
this.rowWidgets.push(widgets);
|
this.rowWidgets.push(widgets);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addField(access: ListAccess, index: number, bundle: PropertyValue, kind: FieldKind): void {
|
/**
|
||||||
const fields = structItemFields(bundle);
|
* The fields of the struct at `path`, a line each; a field that is a struct again gets its own
|
||||||
const key = nextFieldName(fields);
|
* lines below, indented one step further (#239). Check scripts find a nested field by its keys
|
||||||
this.replaceItem(access, index, { ...structItemOf(bundle), [key]: makeFlatItem(kind, undefined) });
|
* joined with dots (`loot.count`).
|
||||||
|
*/
|
||||||
|
private addStructFields(
|
||||||
|
access: ListAccess,
|
||||||
|
stack: VStack,
|
||||||
|
path: ValuePath,
|
||||||
|
depth: number,
|
||||||
|
widgets: RowWidgets,
|
||||||
|
leaves: Leaf[],
|
||||||
|
): void {
|
||||||
|
// A field value edit does not rebuild the row, so every button reads the struct as it is now.
|
||||||
|
const now = () => structItemOf(valueAt(access.value, path));
|
||||||
|
for (const field of structItemFields(valueAt(access.value, path))) {
|
||||||
|
const fieldPath = [...path, field.key];
|
||||||
|
const id = fieldPath.slice(1).join('.');
|
||||||
|
const line = indentedLine(depth);
|
||||||
|
const fieldMark = this.kindPicker(access, fieldPath, field.kind);
|
||||||
|
const name = nameField(field.key, depth, (to) => {
|
||||||
|
if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to));
|
||||||
|
});
|
||||||
|
line.add(fieldMark);
|
||||||
|
line.add(name);
|
||||||
|
if (field.kind === 'struct') line.add(new Spacer(), 1);
|
||||||
|
else {
|
||||||
|
const value = valueAt(access.value, fieldPath);
|
||||||
|
const sub = this.valueField(access, fieldPath, field.kind, value);
|
||||||
|
leaves.push({ within: fieldPath.slice(1), set: addValue(line, sub) });
|
||||||
|
widgets.parts.set(id, sub.widget);
|
||||||
|
}
|
||||||
|
widgets.marks.set(id, fieldMark);
|
||||||
|
widgets.names.set(id, name);
|
||||||
|
line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key))));
|
||||||
|
stack.add(line);
|
||||||
|
if (field.kind === 'struct') this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves);
|
||||||
|
}
|
||||||
|
const foot = indentedLine(depth);
|
||||||
|
foot.add(addFieldMenu((kind) => this.addField(access, path, kind)));
|
||||||
|
foot.add(new Spacer(), 1);
|
||||||
|
stack.add(foot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addField(access: ListAccess, path: ValuePath, kind: NestedKind): void {
|
||||||
|
const bundle = valueAt(access.value, path);
|
||||||
|
const key = nextFieldName(structItemFields(bundle));
|
||||||
|
this.replaceAt(access, path, { ...structItemOf(bundle), [key]: makeItem(kind, undefined) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every shape change is a value edit: the kind lives in the item, so there is nothing else to set. */
|
/** Every shape change is a value edit: the kind lives in the item, so there is nothing else to set. */
|
||||||
private replaceItem(access: ListAccess, index: number, item: ListItem): void {
|
private replaceAt(access: ListAccess, path: ValuePath, value: ListItem): void {
|
||||||
access.replace(access.value.map((value, i) => (i === index ? item : value)));
|
const next = withValueAt(access.value, path, value);
|
||||||
|
if (isList(next)) access.replace(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addItem(kind: ListItemKind): void {
|
private addItem(kind: NestedKind): void {
|
||||||
const access = this.access();
|
const access = this.access();
|
||||||
if (access) access.replace([...access.value, makeItem(kind, undefined)]);
|
if (access) access.replace([...access.value, makeItem(kind, undefined)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The field's name, changeable: a struct item's shape is its own, not a declaration elsewhere. */
|
/** One value of a struct item's tree: where it sits inside the item, and how its widget is set. */
|
||||||
function nameField(key: string, rename: (next: string) => void): TextField {
|
interface Leaf {
|
||||||
return new TextField(key, { width: NAME_WIDTH, onChange: (next) => rename(next.trim()) });
|
within: ValuePath;
|
||||||
|
set: (value: PropertyValue) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFieldMenu(add: (kind: FieldKind) => void): Dropdown {
|
/** A line of a struct item, pushed right one step per level it sits below the item. */
|
||||||
|
function indentedLine(depth: number): HStack {
|
||||||
|
const line = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
line.minHeight = Theme.rowHeight;
|
||||||
|
if (depth > 0) line.add(new Spacer(depth * (FIELD_INDENT + Theme.gap) - Theme.gap));
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The field's name, changeable: a struct item's shape is its own, not a declaration elsewhere. It
|
||||||
|
* narrows as it is indented, so the values of every level start on one column.
|
||||||
|
*/
|
||||||
|
function nameField(key: string, depth: number, rename: (next: string) => void): TextField {
|
||||||
|
return new TextField(key, { width: nameWidth(depth), onChange: (next) => rename(next.trim()) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFieldMenu(add: (kind: NestedKind) => void): Dropdown {
|
||||||
const button = new Dropdown('+ Field', { variant: 'ghost' });
|
const button = new Dropdown('+ Field', { variant: 'ghost' });
|
||||||
button.onClick = () => new Menu(kindMenu(FIELD_KINDS, add)).openBelow(button);
|
button.onClick = () => new Menu(kindMenu(NESTED_KINDS, add)).openBelow(button);
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,3 +372,12 @@ const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem
|
|||||||
for (const [key, value] of Object.entries(structItemOf(bundle))) next[key === from ? to : key] = value;
|
for (const [key, value] of Object.entries(structItemOf(bundle))) next[key === from ? to : key] = value;
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** An item's kind, and for a struct the shape of every field below it – what its rows are built from. */
|
||||||
|
function shapeOf(item: PropertyValue | undefined): string {
|
||||||
|
const kind = itemKindOf(item);
|
||||||
|
if (kind !== 'struct' || !isStruct(item)) return kind;
|
||||||
|
return `struct(${structItemFields(item)
|
||||||
|
.map((f) => `${f.key}:${shapeOf(item[f.key])}`)
|
||||||
|
.join(',')})`;
|
||||||
|
}
|
||||||
+59
-67
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so a List tab
|
* Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so a List tab
|
||||||
* edit gets the same command, coercion and undo step. A `field` slot still writes the whole struct.
|
* 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 { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||||||
import { resolveDef } from '../../core/graph/nested';
|
import { resolveDef } from '../../core/graph/nested';
|
||||||
@@ -8,10 +8,11 @@ import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/Nod
|
|||||||
import type { Asset } from '../../core/model/Asset';
|
import type { Asset } from '../../core/model/Asset';
|
||||||
import { graphLookup } from '../../core/model/graphLookup';
|
import { graphLookup } from '../../core/model/graphLookup';
|
||||||
import type { ObjectRef } from '../../core/model/instance';
|
import type { ObjectRef } from '../../core/model/instance';
|
||||||
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
|
import { isList, type ListValue, type PropertyValue } from '../../core/model/Properties';
|
||||||
import { findAsset } from '../../core/model/Project';
|
import { findAsset } from '../../core/model/Project';
|
||||||
import { FIELD_KINDS, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds';
|
import { NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
|
||||||
import { fieldLike, type ListItem, itemLike, structItemOf } from '../../core/properties/listItems';
|
import { itemLike } from '../../core/properties/listItems';
|
||||||
|
import { pathLabel, valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath';
|
||||||
import {
|
import {
|
||||||
type PropertyDef,
|
type PropertyDef,
|
||||||
schemaForAsset,
|
schemaForAsset,
|
||||||
@@ -32,16 +33,19 @@ export interface ListAccess {
|
|||||||
readonly def: ListDef;
|
readonly def: ListDef;
|
||||||
/** What is in the list now – re-read on every access. */
|
/** What is in the list now – re-read on every access. */
|
||||||
readonly value: ListValue;
|
readonly value: ListValue;
|
||||||
/**
|
/** The kinds this list may hold: every one, at any depth (#239). */
|
||||||
* The kinds this list may hold. A list in a struct field takes primitives only – `struct → list →
|
readonly kinds: readonly NestedKind[];
|
||||||
* struct` is past the depth the stored value goes, so offering it would add an item that the next
|
|
||||||
* write silently drops (`flatListOf`).
|
|
||||||
*/
|
|
||||||
readonly kinds: readonly ListItemKind[];
|
|
||||||
/** What the tab header says the list belongs to. */
|
/** What the tab header says the list belongs to. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** One item, previewed and committed exactly as the inspector does it. */
|
/** One item, previewed and committed exactly as the inspector does it. */
|
||||||
itemHandlers(index: number): FieldHandlers;
|
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 the List tab on a list inside this one, `path` leading from this list to it. */
|
||||||
|
openList(path: ValuePath): void;
|
||||||
/** A whole new list in one command – adding, deleting, and the bulk text field. */
|
/** A whole new list in one command – adding, deleting, and the bulk text field. */
|
||||||
replace(next: ListValue): void;
|
replace(next: ListValue): void;
|
||||||
}
|
}
|
||||||
@@ -56,7 +60,7 @@ interface Owner<Target> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null {
|
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null {
|
||||||
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot.field);
|
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot);
|
||||||
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot));
|
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot));
|
||||||
if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
|
if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
|
||||||
return inSlot(nodeOwner(state, slot));
|
return inSlot(nodeOwner(state, slot));
|
||||||
@@ -64,42 +68,43 @@ export function resolveList(state: EditorState, slot: ListSlot): ListAccess | nu
|
|||||||
|
|
||||||
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
|
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
|
||||||
|
|
||||||
const withItem = (list: ListValue, index: number, item: ListItem): ListValue =>
|
/** The owner's own list, or – with a `path` – a list nested inside its value. */
|
||||||
list.map((value, i) => (i === index ? item : value));
|
function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot): ListAccess | null {
|
||||||
|
const path = slot.path ?? [];
|
||||||
/** The owner's own list, or – with a `field` – the list one of its struct fields holds. */
|
const open = (inner: ValuePath) => state.openList({ ...slot, path: [...path, ...inner] });
|
||||||
function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string): ListAccess | null {
|
if (path.length === 0)
|
||||||
if (field === undefined)
|
|
||||||
return owner.def.kind === 'list'
|
return owner.def.kind === 'list'
|
||||||
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec)
|
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, open)
|
||||||
: null;
|
: null;
|
||||||
if (owner.def.kind !== 'struct') return null;
|
// A struct is brought into its declared shape first, so a declared list not stored yet is there.
|
||||||
const declared = owner.def.fields.find((f) => f.key === field);
|
const def = owner.def;
|
||||||
if (declared?.kind !== 'list') return null;
|
const shaped = (value: PropertyValue | undefined): PropertyValue | undefined =>
|
||||||
const fields = owner.def.fields;
|
def.kind === 'struct' ? structOf(def.fields, value) : value;
|
||||||
const read = (target: Target) => {
|
if (!isList(valueAt(shaped(owner.current()), path))) return null;
|
||||||
const bundle = owner.spec.read(target);
|
// Built on what the target stores, or – where it stores nothing, as an unset override – on what
|
||||||
return bundle === undefined ? undefined : asList(structOf(fields, bundle)[field]);
|
// it shows: writing the list alone would drop its neighbours, and a snapshot would undo an edit
|
||||||
};
|
// made in between.
|
||||||
// Written back as a whole bundle, rebuilt from what is in the owner at that moment: writing the
|
const whole = (target: Target | undefined) =>
|
||||||
// list alone would drop its neighbours, and a snapshot would undo an edit made in between.
|
shaped((target === undefined ? undefined : owner.spec.read(target)) ?? owner.current());
|
||||||
const bundleWith = (target: Target | undefined, list: PropertyValue): StructValue => ({
|
const wholeWith = (target: Target | undefined, list: PropertyValue): PropertyValue =>
|
||||||
...structOf(fields, target === undefined ? undefined : owner.spec.read(target)),
|
withValueAt(whole(target), path, asList(list)) ?? asList(list);
|
||||||
[field]: asList(list),
|
const last = pathLabel(path).at(-1) ?? '';
|
||||||
});
|
|
||||||
return access<Target>(
|
return access<Target>(
|
||||||
state,
|
state,
|
||||||
{ key: field, label: field, kind: 'list', default: [] },
|
{ key: last, label: last, kind: 'list', default: [] },
|
||||||
`${owner.title} · ${field}`,
|
[owner.title, ...pathLabel(path)].join(' · '),
|
||||||
() => asList(structOf(fields, owner.current())[field]),
|
() => asList(valueAt(shaped(owner.current()), path)),
|
||||||
{
|
{
|
||||||
target: owner.spec.target,
|
target: owner.spec.target,
|
||||||
read,
|
read: (target) => {
|
||||||
write: (target, list) => owner.spec.write(target, bundleWith(target, list)),
|
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,
|
remove: owner.spec.remove,
|
||||||
commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)),
|
commit: (list) => owner.spec.commit(wholeWith(owner.spec.target(), list)),
|
||||||
},
|
},
|
||||||
FIELD_KINDS,
|
open,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,43 +119,30 @@ function access<Target>(
|
|||||||
title: string,
|
title: string,
|
||||||
current: () => ListValue,
|
current: () => ListValue,
|
||||||
spec: Omit<PreviewedValue<Target>, 'toModel'>,
|
spec: Omit<PreviewedValue<Target>, 'toModel'>,
|
||||||
kinds: readonly ListItemKind[] = LIST_ITEM_KINDS,
|
open: (path: ValuePath) => void,
|
||||||
): ListAccess {
|
): 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 {
|
return {
|
||||||
def,
|
def,
|
||||||
kinds,
|
kinds: NESTED_KINDS,
|
||||||
title,
|
title,
|
||||||
// Re-read, not a snapshot: an item edit does not rebuild the rows, so a delete or kind change
|
// 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.
|
// clicked later has to build on the list with that edit in it, not on the one the rows showed.
|
||||||
get value() {
|
get value() {
|
||||||
return current();
|
return current();
|
||||||
},
|
},
|
||||||
itemHandlers: (index) => ({
|
itemHandlers: (index) => handlersAt([index]),
|
||||||
...previewingHandlers<Target>(
|
handlersAt,
|
||||||
{ state },
|
openList: open,
|
||||||
{
|
|
||||||
...spec,
|
|
||||||
// The row keeps the kind it has: a color item written from its field stays a color.
|
|
||||||
toModel: (input) => withItem(current(), index, itemLike(current()[index], input)),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// A struct item is edited field by field, and each write rebuilds the whole item from what
|
|
||||||
// is in the list at that moment -- the same rule as a row, one level deeper.
|
|
||||||
structFieldHandlers: (field: { key: string }) =>
|
|
||||||
previewingHandlers<Target>(
|
|
||||||
{ state },
|
|
||||||
{
|
|
||||||
...spec,
|
|
||||||
toModel: (input) => {
|
|
||||||
const item = structItemOf(current()[index]);
|
|
||||||
return withItem(current(), index, {
|
|
||||||
...item,
|
|
||||||
[field.key]: fieldLike(item[field.key], input),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
|
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
|
||||||
// A preview does not, which is why `toModel` above has to.
|
// A preview does not, which is why `toModel` above has to.
|
||||||
replace: (next) => state.execute(spec.commit(next)),
|
replace: (next) => state.execute(spec.commit(next)),
|
||||||
|
|||||||
+8
-4
@@ -20,6 +20,7 @@ import { deserializeProject, serializeProject } from '../core/store/serialize';
|
|||||||
import { requestRender } from '../ui';
|
import { requestRender } from '../ui';
|
||||||
import { consoleLog } from './console/log';
|
import { consoleLog } from './console/log';
|
||||||
import type { GameScale, PlayFrom } from '../core/model/playSettings';
|
import type { GameScale, PlayFrom } from '../core/model/playSettings';
|
||||||
|
import type { ValuePath } from '../core/properties/valuePath';
|
||||||
|
|
||||||
export type Selection =
|
export type Selection =
|
||||||
| { kind: 'asset'; assetId: string }
|
| { kind: 'asset'; assetId: string }
|
||||||
@@ -55,10 +56,11 @@ export type Selection =
|
|||||||
/**
|
/**
|
||||||
* Which list the List tab edits. Deliberately not a `Selection`: a list is not something one
|
* Which list the List tab edits. Deliberately not a `Selection`: a list is not something one
|
||||||
* selects, it is a value inside something already selected, and exactly three places hold one.
|
* selects, it is a value inside something already selected, and exactly three places hold one.
|
||||||
* `field` names a list held by a struct field one level down; a struct item of a list needs no
|
* `path` leads to a list nested inside the value `key` names (#239): a key per struct field, an
|
||||||
* such step, because the List tab renders it inline.
|
* index per list item – `[3, 'drops']` is the list in field `drops` of item 3. A struct item needs
|
||||||
|
* no step of its own to be edited, because the List tab renders it inline.
|
||||||
*/
|
*/
|
||||||
export type ListSlot = { field?: string } & (
|
export type ListSlot = { path?: ValuePath } & (
|
||||||
| { kind: 'asset'; assetId: string; key: string }
|
| { kind: 'asset'; assetId: string; key: string }
|
||||||
| { kind: 'object'; holderId: string; objectId: string; key: string }
|
| { kind: 'object'; holderId: string; objectId: string; key: string }
|
||||||
| { kind: 'node'; assetId: string; nodeId: string; key: string }
|
| { kind: 'node'; assetId: string; nodeId: string; key: string }
|
||||||
@@ -66,7 +68,9 @@ export type ListSlot = { field?: string } & (
|
|||||||
|
|
||||||
function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean {
|
function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean {
|
||||||
if (!a || !b) return a === b;
|
if (!a || !b) return a === b;
|
||||||
if (a.field !== b.field) return false;
|
const pa = a.path ?? [];
|
||||||
|
const pb = b.path ?? [];
|
||||||
|
if (pa.length !== pb.length || pa.some((step, i) => step !== pb[i])) return false;
|
||||||
switch (a.kind) {
|
switch (a.kind) {
|
||||||
case 'asset':
|
case 'asset':
|
||||||
return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key;
|
return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key;
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ import {
|
|||||||
KINDS,
|
KINDS,
|
||||||
kindLabel,
|
kindLabel,
|
||||||
kindMark,
|
kindMark,
|
||||||
LIST_ITEM_KINDS,
|
NESTED_KINDS,
|
||||||
plainKindOf,
|
plainKindOf,
|
||||||
STRUCT_FIELD_KINDS,
|
|
||||||
VARIABLE_KINDS,
|
VARIABLE_KINDS,
|
||||||
} from '../../src/core/properties/kinds';
|
} from '../../src/core/properties/kinds';
|
||||||
import { itemKindOf, listItemOf, makeItem } from '../../src/core/properties/listItems';
|
import { itemKindOf, listItemOf, makeItem } from '../../src/core/properties/listItems';
|
||||||
@@ -48,8 +47,7 @@ describe('the kind table', () => {
|
|||||||
expect(FIELD_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('field', row.kind));
|
expect(FIELD_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('field', row.kind));
|
||||||
expect(DECLARED_PORT_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('port', row.kind));
|
expect(DECLARED_PORT_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('port', row.kind));
|
||||||
}
|
}
|
||||||
expect(LIST_ITEM_KINDS).toEqual([...FIELD_KINDS, 'struct']);
|
expect(NESTED_KINDS).toEqual([...FIELD_KINDS, 'list', 'struct']);
|
||||||
expect(STRUCT_FIELD_KINDS).toEqual([...FIELD_KINDS, 'list']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads nothing as the empty value of a kind, and the empty value as itself', () => {
|
it('reads nothing as the empty value of a kind, and the empty value as itself', () => {
|
||||||
|
|||||||
@@ -92,9 +92,13 @@ describe('the kind of one list item', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops a list inside a struct item, the one level too deep', () => {
|
/** Once dropped as one level too deep; since #239 nothing bounds the depth. */
|
||||||
const { f, behaviorId } = withList([{ x: 1, deep: [1, 2] }]);
|
it('keeps a list inside a struct item, and a list in a list', () => {
|
||||||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual([{ x: 1 }]);
|
const { f, behaviorId } = withList([{ x: 1, deep: [1, [2, tagged('color', 0xff0000)]] }, [[3]]]);
|
||||||
|
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual([
|
||||||
|
{ x: 1, deep: [1, [2, tagged('color', 0xff0000)]] },
|
||||||
|
[[3]],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -84,12 +84,19 @@ describe('an untyped slot on load', () => {
|
|||||||
expect(paramsAfterLoad({ v: { tags: ['a', 'b'], n: 1 } }).v).toEqual({ tags: ['a', 'b'], n: 1 });
|
expect(paramsAfterLoad({ v: { tags: ['a', 'b'], n: 1 } }).v).toEqual({ tags: ['a', 'b'], n: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops a struct inside a struct, which no declaration can ask for', () => {
|
/** Both were dropped while the depth was bounded; since #239 they come back as stored. */
|
||||||
expect(paramsAfterLoad({ v: { inner: { x: 1 }, n: 1 } }).v).toEqual({ n: 1 });
|
it('keeps a struct inside a struct', () => {
|
||||||
|
expect(paramsAfterLoad({ v: { inner: { x: 1, deeper: { y: 2 } }, n: 1 } }).v).toEqual({
|
||||||
|
inner: { x: 1, deeper: { y: 2 } },
|
||||||
|
n: 1,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops a list inside a list item, the level below what a field may hold', () => {
|
it('keeps a list inside a list item, and drops only what is no value at all', () => {
|
||||||
expect(paramsAfterLoad({ items: [{ deep: [1, 2], x: 1 }] }).items).toEqual([{ x: 1 }]);
|
expect(paramsAfterLoad({ items: [{ deep: [1, [2, null]], x: 1 }, [null, 3]] }).items).toEqual([
|
||||||
|
{ deep: [1, [2]], x: 1 },
|
||||||
|
[3],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* Structs and lists nest to any depth (#239): a struct field may be a struct with a declaration of
|
||||||
|
* its own, a list item a list, and nothing deeper is dropped – not on load, not on the way through
|
||||||
|
* a graph. What was stored under the old bound still loads as it was.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AddPortsCommand } from '../../src/core/commands/portCommands';
|
||||||
|
import {
|
||||||
|
AddStructFieldCommand,
|
||||||
|
AddVariableCommand,
|
||||||
|
RemoveStructFieldCommand,
|
||||||
|
RenameStructFieldCommand,
|
||||||
|
} from '../../src/core/commands/variableCommands';
|
||||||
|
import { writeTyped } from '../../src/core/graph';
|
||||||
|
import { liveValue, withoutObjects } from '../../src/core/graph/liveObjects';
|
||||||
|
import { asField, heldObject, type Value } from '../../src/core/graph/values';
|
||||||
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||||
|
import {
|
||||||
|
NO_OBJECT,
|
||||||
|
type PropertyBag,
|
||||||
|
type PropertyValue,
|
||||||
|
sameValue,
|
||||||
|
tagged,
|
||||||
|
} from '../../src/core/model/Properties';
|
||||||
|
import { variableDef, type StructFieldDef } from '../../src/core/properties/PropertySchema';
|
||||||
|
import { valueAt, withValueAt } from '../../src/core/properties/valuePath';
|
||||||
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||||
|
import {
|
||||||
|
fakeEntity,
|
||||||
|
graphEditor,
|
||||||
|
makeBehavior,
|
||||||
|
makeSpriteScene,
|
||||||
|
place,
|
||||||
|
roundTrip,
|
||||||
|
type SceneFixture,
|
||||||
|
startWorld,
|
||||||
|
} from '../helpers';
|
||||||
|
|
||||||
|
/** `enemy` = { hp, loot: { item, drops } }, `loot` declared inside the struct. */
|
||||||
|
function enemyOn(f: SceneFixture): BehaviorAsset {
|
||||||
|
const behavior = makeBehavior(f, 'Enemy');
|
||||||
|
const id = behavior.id;
|
||||||
|
const run = (command: Parameters<typeof f.stack.execute>[0]) => f.stack.execute(command);
|
||||||
|
run(new AddVariableCommand(id, 'enemy', 'struct'));
|
||||||
|
run(new AddStructFieldCommand(id, 'enemy', { key: 'hp', kind: 'number' }));
|
||||||
|
run(new AddStructFieldCommand(id, 'enemy', { key: 'loot', kind: 'struct', fields: [] }));
|
||||||
|
run(new AddStructFieldCommand(id, 'enemy', { key: 'item', kind: 'text' }, ['loot']));
|
||||||
|
run(new AddStructFieldCommand(id, 'enemy', { key: 'drops', kind: 'list' }, ['loot']));
|
||||||
|
return behavior;
|
||||||
|
}
|
||||||
|
|
||||||
|
const declared = (behavior: BehaviorAsset) => behavior.variables.find((v) => v.key === 'enemy')!;
|
||||||
|
|
||||||
|
describe('a struct field that is a struct', () => {
|
||||||
|
it('declares its own fields, and every value takes that shape at once', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const behavior = enemyOn(f);
|
||||||
|
expect(declared(behavior)).toMatchObject({
|
||||||
|
fields: [
|
||||||
|
{ key: 'hp', kind: 'number' },
|
||||||
|
{
|
||||||
|
key: 'loot',
|
||||||
|
kind: 'struct',
|
||||||
|
fields: [
|
||||||
|
{ key: 'item', kind: 'text' },
|
||||||
|
{ key: 'drops', kind: 'list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const empty = { hp: 0, loot: { item: '', drops: [] } };
|
||||||
|
expect(declared(behavior).default).toEqual(empty);
|
||||||
|
expect(behavior.props.enemy).toEqual(empty);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brings what arrives into the declared shape, however deep', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const fields = (declared(enemyOn(f)) as { fields: readonly StructFieldDef[] }).fields;
|
||||||
|
const def = variableDef('e', 'struct', { hp: '5', loot: { item: 3, extra: 1 }, other: 2 }, fields);
|
||||||
|
expect(def.default).toEqual({ hp: 5, loot: { item: '3', drops: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps declaration and a deep value through save and load', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const behavior = enemyOn(f);
|
||||||
|
const deep = {
|
||||||
|
hp: 3,
|
||||||
|
loot: { item: 'gem', drops: [{ name: 'a', tags: ['x', [1, tagged('color', 0xff0000)]] }, [[2]]] },
|
||||||
|
};
|
||||||
|
behavior.props.enemy = deep;
|
||||||
|
const back = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
||||||
|
expect(back.props.enemy).toEqual(deep);
|
||||||
|
expect(declared(back)).toEqual(declared(behavior));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds, renames and removes a nested field in the declaration and every value, undo included', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const behavior = enemyOn(f);
|
||||||
|
behavior.props.enemy = { hp: 1, loot: { item: 'gem', drops: [7] } };
|
||||||
|
|
||||||
|
f.stack.execute(new RenameStructFieldCommand(behavior.id, 'enemy', 'item', 'name', ['loot']));
|
||||||
|
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } });
|
||||||
|
|
||||||
|
f.stack.execute(new RemoveStructFieldCommand(behavior.id, 'enemy', 'drops', ['loot']));
|
||||||
|
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem' } });
|
||||||
|
expect((declared(behavior) as { fields: readonly StructFieldDef[] }).fields).toEqual([
|
||||||
|
{ key: 'hp', kind: 'number' },
|
||||||
|
{ key: 'loot', kind: 'struct', fields: [{ key: 'name', kind: 'text' }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
f.stack.undo();
|
||||||
|
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } });
|
||||||
|
f.stack.undo();
|
||||||
|
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { item: 'gem', drops: [7] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a path that leads to no struct', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const behavior = enemyOn(f);
|
||||||
|
expect(() =>
|
||||||
|
f.stack.execute(new AddStructFieldCommand(behavior.id, 'enemy', { key: 'x', kind: 'number' }, ['hp'])),
|
||||||
|
).toThrow('Field "hp" has no fields');
|
||||||
|
expect(behavior.props.enemy).toEqual({ hp: 0, loot: { item: '', drops: [] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stored values from before the bound was lifted', () => {
|
||||||
|
it('load exactly as they were', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const behavior = makeBehavior(f, 'Old');
|
||||||
|
f.stack.execute(new AddVariableCommand(behavior.id, 'rows', 'list'));
|
||||||
|
f.stack.execute(new AddVariableCommand(behavior.id, 'route', 'struct'));
|
||||||
|
f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'name', kind: 'text' }));
|
||||||
|
f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'stops', kind: 'list' }));
|
||||||
|
behavior.props.rows = [{ x: 1, c: tagged('color', 5) }, 2, 'a', tagged('texture', 't')];
|
||||||
|
behavior.props.route = { name: 'n', stops: [1, tagged('color', 3)] };
|
||||||
|
const stored = serializeProject(f.project);
|
||||||
|
expect(serializeProject(deserializeProject(stored))).toEqual(stored);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a nested value in a running game', () => {
|
||||||
|
/** Pack(item) into Pack(loot) into `enemy`; on every update Expand twice and log the item. */
|
||||||
|
function packer(f: SceneFixture): BehaviorAsset {
|
||||||
|
const behavior = enemyOn(f);
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const inner = edit.addNode('PackStruct');
|
||||||
|
f.stack.execute(new AddPortsCommand(behavior.id, inner.id, [{ key: 'item', kind: 'text' }]));
|
||||||
|
inner.params.item = 'gem';
|
||||||
|
const outer = edit.addNode('PackStruct');
|
||||||
|
f.stack.execute(new AddPortsCommand(behavior.id, outer.id, [{ key: 'loot', kind: 'struct' }]));
|
||||||
|
const set = edit.variables(['enemy'], { write: true });
|
||||||
|
const get = edit.variables(['enemy']);
|
||||||
|
const first = edit.addNode('ExpandStruct');
|
||||||
|
f.stack.execute(new AddPortsCommand(behavior.id, first.id, [{ key: 'loot', kind: 'struct' }]));
|
||||||
|
const second = edit.addNode('ExpandStruct');
|
||||||
|
f.stack.execute(new AddPortsCommand(behavior.id, second.id, [{ key: 'item', kind: 'text' }]));
|
||||||
|
const log = edit.addNode('Log');
|
||||||
|
edit.wire([
|
||||||
|
[edit.addNode('OnStart'), 'exec', set, 'exec'],
|
||||||
|
[inner, 'value', outer, 'loot'],
|
||||||
|
[outer, 'value', set, 'enemy'],
|
||||||
|
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||||||
|
[get, 'enemy', first, 'value'],
|
||||||
|
[first, 'loot', second, 'value'],
|
||||||
|
[second, 'item', log, 'message'],
|
||||||
|
]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
return behavior;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is written level by level and taken apart the same way', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const behavior = packer(f);
|
||||||
|
const run = startWorld(f, { behaviors: true });
|
||||||
|
run.frame();
|
||||||
|
run.frame();
|
||||||
|
expect(run.world.log.at(-1)).toBe('gem');
|
||||||
|
// Merged into the declared shape: the fields nobody wrote keep their values.
|
||||||
|
const instance = run.world.entities.find((e) => e.assetId === behavior.id)!;
|
||||||
|
expect(instance.props.enemy).toEqual({ hp: 0, loot: { item: 'gem', drops: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a list in a list and a struct in a struct when written into a variable', () => {
|
||||||
|
const bag: PropertyBag = { grid: [], nest: { a: { b: 0 } } };
|
||||||
|
writeTyped(bag, 'grid', [
|
||||||
|
[1, 2],
|
||||||
|
[3, [4]],
|
||||||
|
]);
|
||||||
|
writeTyped(bag, 'nest', { a: { b: 5, c: 1 } });
|
||||||
|
expect(bag.grid).toEqual([
|
||||||
|
[1, 2],
|
||||||
|
[3, [4]],
|
||||||
|
]);
|
||||||
|
expect(bag.nest).toEqual({ a: { b: 5 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds an object at any depth, and lets none of them out', () => {
|
||||||
|
const crate = fakeEntity('Crate');
|
||||||
|
const held = asField([{ who: { at: [crate] } }] as unknown as Value);
|
||||||
|
expect(valueAt(held, [0, 'who', 'at', 0])).toBe(heldObject(crate));
|
||||||
|
expect(withoutObjects(held)).toEqual([{ who: { at: [NO_OBJECT] } }]);
|
||||||
|
expect(liveValue(held, () => false)).toEqual([{ who: { at: [NO_OBJECT] } }]);
|
||||||
|
expect(liveValue(held, () => true)).toEqual(held);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a path into a nested value', () => {
|
||||||
|
const value: PropertyValue = { a: [1, { b: [2, 3] }] };
|
||||||
|
|
||||||
|
it('reads what sits there, and nothing where a step finds nothing', () => {
|
||||||
|
expect(valueAt(value, ['a', 1, 'b', 0])).toBe(2);
|
||||||
|
expect(valueAt(value, ['a', 5])).toBeUndefined();
|
||||||
|
expect(valueAt(value, ['a', 'b'])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a copy, leaving the value it started from alone', () => {
|
||||||
|
const next = withValueAt(value, ['a', 1, 'b', 1], 9);
|
||||||
|
expect(next).toEqual({ a: [1, { b: [2, 9] }] });
|
||||||
|
expect(value).toEqual({ a: [1, { b: [2, 3] }] });
|
||||||
|
expect(withValueAt(value, ['a', 7], 9)).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares nested values by content', () => {
|
||||||
|
expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [2] }] })).toBe(true);
|
||||||
|
expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [3] }] })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,12 +19,7 @@ import { bagWithoutObjects, liveValue } from '../../src/core/graph/liveObjects';
|
|||||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||||
import { NO_OBJECT, type PropertyBag } from '../../src/core/model/Properties';
|
import { NO_OBJECT, type PropertyBag } from '../../src/core/model/Properties';
|
||||||
import { findScene } from '../../src/core/model/Project';
|
import { findScene } from '../../src/core/model/Project';
|
||||||
import {
|
import { isKindWith, NESTED_KINDS, VARIABLE_KINDS } from '../../src/core/properties/kinds';
|
||||||
isKindWith,
|
|
||||||
LIST_ITEM_KINDS,
|
|
||||||
STRUCT_FIELD_KINDS,
|
|
||||||
VARIABLE_KINDS,
|
|
||||||
} from '../../src/core/properties/kinds';
|
|
||||||
import { makeItem } from '../../src/core/properties/listItems';
|
import { makeItem } from '../../src/core/properties/listItems';
|
||||||
import { serializeProject } from '../../src/core/store/serialize';
|
import { serializeProject } from '../../src/core/store/serialize';
|
||||||
import {
|
import {
|
||||||
@@ -123,8 +118,7 @@ const placedOf = (f: SceneFixture, behavior: BehaviorAsset) =>
|
|||||||
describe('the object kind', () => {
|
describe('the object kind', () => {
|
||||||
it('can be a variable, a struct field and a list item', () => {
|
it('can be a variable, a struct field and a list item', () => {
|
||||||
expect(VARIABLE_KINDS).toContain('object');
|
expect(VARIABLE_KINDS).toContain('object');
|
||||||
expect(STRUCT_FIELD_KINDS).toContain('object');
|
expect(NESTED_KINDS).toContain('object');
|
||||||
expect(LIST_ITEM_KINDS).toContain('object');
|
|
||||||
expect(isKindWith('tagged', 'object')).toBe(true);
|
expect(isKindWith('tagged', 'object')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { deserializeProject, serializeProject } from '../../src/core/store/seria
|
|||||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||||
import { tagged } from '../../src/core/model/Properties';
|
import { tagged } from '../../src/core/model/Properties';
|
||||||
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
|
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
|
||||||
import { FIELD_KINDS, LIST_ITEM_KINDS } from '../../src/core/properties/kinds';
|
import { NESTED_KINDS } from '../../src/core/properties/kinds';
|
||||||
import { variableDef } from '../../src/core/properties/PropertySchema';
|
import { variableDef } from '../../src/core/properties/PropertySchema';
|
||||||
import { resolveList } from '../../src/editor/list/slot';
|
import { resolveList } from '../../src/editor/list/slot';
|
||||||
import { EditorState, type ListSlot } from '../../src/editor/state';
|
import { EditorState, type ListSlot } from '../../src/editor/state';
|
||||||
@@ -210,7 +210,7 @@ describe('a list a struct field holds', () => {
|
|||||||
expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'stops', kind: 'list' }))).toBe(
|
expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'stops', kind: 'list' }))).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
slot = { kind: 'asset', assetId: id, key: 'route', field: 'stops' };
|
slot = { kind: 'asset', assetId: id, key: 'route', path: ['stops'] };
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = () => resolveList(f.state, slot)!;
|
const list = () => resolveList(f.state, slot)!;
|
||||||
@@ -244,9 +244,9 @@ describe('a list a struct field holds', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is nothing at all for a field that is not a list, or is not there', () => {
|
it('is nothing at all for a field that is not a list, or is not there', () => {
|
||||||
expect(resolveList(f.state, { ...slot, field: 'name' })).toBe(null);
|
expect(resolveList(f.state, { ...slot, path: ['name'] })).toBe(null);
|
||||||
expect(resolveList(f.state, { ...slot, field: 'no-such' })).toBe(null);
|
expect(resolveList(f.state, { ...slot, path: ['no-such'] })).toBe(null);
|
||||||
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums', field: 'x' })).toBe(
|
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums', path: ['x'] })).toBe(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -264,21 +264,18 @@ describe('a list a struct field holds', () => {
|
|||||||
expect(asset.props.route).toEqual({ name: '', stops: [4, 5] });
|
expect(asset.props.route).toEqual({ name: '', stops: [4, 5] });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The one shape the model does not store: a list inside a struct inside a list. */
|
/** Once the one shape the model dropped (#239): a list inside a struct inside a list. */
|
||||||
it('is dropped inside the struct items of a list', () => {
|
it('keeps a list inside the struct items of a list', () => {
|
||||||
const def = variableDef('rows', 'list', [{ x: 1, inner: [1, 2] }]);
|
const def = variableDef('rows', 'list', [{ x: 1, inner: [1, 2] }]);
|
||||||
expect(def.default).toEqual([{ x: 1 }]);
|
expect(def.default).toEqual([{ x: 1, inner: [1, 2] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/** Nothing is dropped any more, so a list in a struct field offers every kind, as the whole list does. */
|
||||||
* And because they are dropped, the tab must not offer them: an item added here would vanish on
|
it('offers every kind, in a struct field as in the whole list', () => {
|
||||||
* the next write, which reads as the menu entry doing nothing at all.
|
expect(list().kinds).toEqual(NESTED_KINDS);
|
||||||
*/
|
expect(list().kinds).toContain('struct');
|
||||||
it('offers the flat kinds only, where the whole list has struct as well', () => {
|
expect(list().kinds).toContain('list');
|
||||||
expect(list().kinds).toEqual(FIELD_KINDS);
|
|
||||||
expect(list().kinds).not.toContain('struct');
|
|
||||||
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
|
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
|
||||||
expect(whole.kinds).toEqual(LIST_ITEM_KINDS);
|
expect(whole.kinds).toEqual(NESTED_KINDS);
|
||||||
expect(whole.kinds).toContain('struct');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Editing a value three levels deep (#239): the List tab reaches a nested list by a path of keys
|
||||||
|
* and indices, an inspector row a nested struct field by its keys. Either way one edit is one
|
||||||
|
* command on the whole value, so undo takes back exactly that edit and the neighbours stay.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||||
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||||
|
import type { PropertyValue } from '../../src/core/model/Properties';
|
||||||
|
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
|
||||||
|
import { requirePropDef } from '../../src/core/commands/require';
|
||||||
|
import { structFieldsOf } from '../../src/editor/inspector/previewHandlers';
|
||||||
|
import { resolveList } from '../../src/editor/list/slot';
|
||||||
|
import { EditorState, type ListSlot } from '../../src/editor/state';
|
||||||
|
|
||||||
|
function setup(): { state: EditorState; id: string } {
|
||||||
|
const state = new EditorState();
|
||||||
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||||
|
const create = new CreateAssetCommand('behavior', 'B');
|
||||||
|
expect(state.execute(create)).toBe(true);
|
||||||
|
return { state, id: create.asset.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = (f: ReturnType<typeof setup>) => (findAsset(f.state.project, f.id) as BehaviorAsset).props;
|
||||||
|
|
||||||
|
describe('a list nested in the items of a list', () => {
|
||||||
|
let f: ReturnType<typeof setup>;
|
||||||
|
const rows = [{ name: 'a', drops: [1, { x: [2, 3] }] }, 'plain'];
|
||||||
|
beforeEach(() => {
|
||||||
|
f = setup();
|
||||||
|
expect(f.state.execute(new AddVariableCommand(f.id, 'rows', 'list'))).toBe(true);
|
||||||
|
resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows' })!.replace(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
const at = (path: ListSlot['path']) =>
|
||||||
|
resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows', path });
|
||||||
|
|
||||||
|
it('is found by its path, and the title spells the path out', () => {
|
||||||
|
expect(at([0, 'drops'])!.value).toEqual([1, { x: [2, 3] }]);
|
||||||
|
expect(at([0, 'drops'])!.title).toBe('B · rows · [0] · drops');
|
||||||
|
expect(at([0, 'drops', 1, 'x'])!.value).toEqual([2, 3]);
|
||||||
|
expect(at([0, 'drops', 1, 'x'])!.title).toBe('B · rows · [0] · drops · [1] · x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is nothing where the path leads to no list', () => {
|
||||||
|
expect(at([0, 'name'])).toBe(null);
|
||||||
|
expect(at([1, 'drops'])).toBe(null);
|
||||||
|
expect(at([5])).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes one deep item as one command on the whole list, and undo takes back just that', () => {
|
||||||
|
at([0, 'drops', 1, 'x'])!.itemHandlers(1).onChange(9);
|
||||||
|
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']);
|
||||||
|
at([0, 'drops'])!.replace([1, { x: [2, 9] }, [4]]);
|
||||||
|
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }, [4]] }, 'plain']);
|
||||||
|
|
||||||
|
f.state.commands.undo();
|
||||||
|
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']);
|
||||||
|
f.state.commands.undo();
|
||||||
|
expect(props(f).rows).toEqual(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits a value inside an item by its path, keeping the kind it has', () => {
|
||||||
|
const list = at([])!;
|
||||||
|
list.handlersAt([0, 'drops', 1, 'x', 0]).onChange('7');
|
||||||
|
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [7, 3] }] }, 'plain']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews a deep edit and still undoes to before the preview', () => {
|
||||||
|
const handlers = at([])!.handlersAt([0, 'name']);
|
||||||
|
handlers.onInput('ab');
|
||||||
|
handlers.onInput('abc');
|
||||||
|
handlers.onChange('abc');
|
||||||
|
expect(props(f).rows).toEqual([{ name: 'abc', drops: [1, { x: [2, 3] }] }, 'plain']);
|
||||||
|
f.state.commands.undo();
|
||||||
|
expect(props(f).rows).toEqual(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the tab one level further down', () => {
|
||||||
|
at([0, 'drops'])!.openList([1, 'x']);
|
||||||
|
expect(f.state.listSlot).toEqual({
|
||||||
|
kind: 'asset',
|
||||||
|
assetId: f.id,
|
||||||
|
key: 'rows',
|
||||||
|
path: [0, 'drops', 1, 'x'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a field of a struct declared inside a struct', () => {
|
||||||
|
let f: ReturnType<typeof setup>;
|
||||||
|
beforeEach(() => {
|
||||||
|
f = setup();
|
||||||
|
const run = (command: Parameters<EditorState['execute']>[0]) =>
|
||||||
|
expect(f.state.execute(command)).toBe(true);
|
||||||
|
run(new AddVariableCommand(f.id, 'enemy', 'struct'));
|
||||||
|
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'hp', kind: 'number' }));
|
||||||
|
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'loot', kind: 'struct', fields: [] }));
|
||||||
|
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'count', kind: 'number' }, ['loot']));
|
||||||
|
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'drops', kind: 'list' }, ['loot']));
|
||||||
|
run(new SetAssetPropertyCommand(f.id, 'enemy', { hp: 5, loot: { count: 1, drops: ['a'] } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlers = () => {
|
||||||
|
const def = requirePropDef(f.state.project, findAsset(f.state.project, f.id)!, 'enemy');
|
||||||
|
if (def.kind !== 'struct') throw new Error('enemy is not a struct');
|
||||||
|
return structFieldsOf<{ props: Record<string, PropertyValue> }>(
|
||||||
|
{ state: f.state },
|
||||||
|
def,
|
||||||
|
{
|
||||||
|
target: () => findAsset(f.state.project, f.id),
|
||||||
|
read: (a) => a.props.enemy,
|
||||||
|
write: (a, value) => (a.props.enemy = value),
|
||||||
|
commit: (value) => new SetAssetPropertyCommand(f.id, 'enemy', value),
|
||||||
|
},
|
||||||
|
(path) => ({ kind: 'asset', assetId: f.id, key: 'enemy', path }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('writes the nested field and leaves every other one alone, one undo step', () => {
|
||||||
|
handlers()(['loot', 'count'], { key: 'count', kind: 'number' }).onChange(4);
|
||||||
|
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 4, drops: ['a'] } });
|
||||||
|
f.state.commands.undo();
|
||||||
|
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a'] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a list field in the List tab by its path', () => {
|
||||||
|
handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).openList!();
|
||||||
|
const list = resolveList(f.state, f.state.listSlot!)!;
|
||||||
|
expect(list.title).toBe('B · enemy · loot · drops');
|
||||||
|
list.replace(['a', ['b']]);
|
||||||
|
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a', ['b']] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,13 +69,13 @@ describe('editing one field of a struct', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fills the untouched fields of a first override from the asset, not from an empty bundle', () => {
|
it('fills the untouched fields of a first override from the asset, not from an empty bundle', () => {
|
||||||
objectHandlers()({ key: 'x', kind: 'number' }).onChange(9);
|
objectHandlers()(['x'], { key: 'x', kind: 'number' }).onChange(9);
|
||||||
expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'T' });
|
expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'T' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the others alone on a second edit, and undo goes back one field at a time', () => {
|
it('leaves the others alone on a second edit, and undo goes back one field at a time', () => {
|
||||||
objectHandlers()({ key: 'x', kind: 'number' }).onChange(9);
|
objectHandlers()(['x'], { key: 'x', kind: 'number' }).onChange(9);
|
||||||
objectHandlers()({ key: 'tag', kind: 'text' }).onChange('hi');
|
objectHandlers()(['tag'], { key: 'tag', kind: 'text' }).onChange('hi');
|
||||||
expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'hi' });
|
expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'hi' });
|
||||||
|
|
||||||
f.state.commands.undo();
|
f.state.commands.undo();
|
||||||
@@ -85,7 +85,7 @@ describe('editing one field of a struct', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('builds on the preview while typing, and still undoes to before it', () => {
|
it('builds on the preview while typing, and still undoes to before it', () => {
|
||||||
const handlers = objectHandlers()({ key: 'x', kind: 'number' });
|
const handlers = objectHandlers()(['x'], { key: 'x', kind: 'number' });
|
||||||
handlers.onInput(1);
|
handlers.onInput(1);
|
||||||
handlers.onInput(12);
|
handlers.onInput(12);
|
||||||
handlers.onChange(12);
|
handlers.onChange(12);
|
||||||
@@ -96,7 +96,7 @@ describe('editing one field of a struct', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('copies those values in rather than keeping them tied to the asset', () => {
|
it('copies those values in rather than keeping them tied to the asset', () => {
|
||||||
objectHandlers()({ key: 'x', kind: 'number' }).onChange(9);
|
objectHandlers()(['x'], { key: 'x', kind: 'number' }).onChange(9);
|
||||||
f.state.execute(new SetAssetPropertyCommand(f.behaviorId, 'piece', { x: 3, y: 99, tag: 'T' }));
|
f.state.execute(new SetAssetPropertyCommand(f.behaviorId, 'piece', { x: 3, y: 99, tag: 'T' }));
|
||||||
// The whole bundle is overridden now, so the asset moving on no longer reaches y.
|
// The whole bundle is overridden now, so the asset moving on no longer reaches y.
|
||||||
expect(resolveSceneObjectProps(f.state.project, asset(), object()).piece).toEqual({
|
expect(resolveSceneObjectProps(f.state.project, asset(), object()).piece).toEqual({
|
||||||
@@ -117,7 +117,7 @@ describe('editing one field of a struct', () => {
|
|||||||
commit: (value) => new SetAssetPropertyCommand(f.behaviorId, 'piece', value),
|
commit: (value) => new SetAssetPropertyCommand(f.behaviorId, 'piece', value),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
handlers({ key: 'y', kind: 'number' }).onChange(8);
|
handlers(['y'], { key: 'y', kind: 'number' }).onChange(8);
|
||||||
expect(resolveAssetProps(f.state.project, asset()).piece).toEqual({ x: 3, y: 8, tag: 'T' });
|
expect(resolveAssetProps(f.state.project, asset()).piece).toEqual({ x: 3, y: 8, tag: 'T' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+38
-3
@@ -105,11 +105,11 @@ export async function listTab(t) {
|
|||||||
// 21d. `+ Item` asks for a kind, since the list does not hold one for it.
|
// 21d. `+ Item` asks for a kind, since the list does not hold one for it.
|
||||||
await clickText('+ Item');
|
await clickText('+ Item');
|
||||||
check(
|
check(
|
||||||
'a list of its own offers every kind, the struct included',
|
'a list of its own offers every kind, list and struct included',
|
||||||
(await json('__gm.list.info()')).offers.join(',') ===
|
(await json('__gm.list.info()')).offers.join(',') ===
|
||||||
// In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there,
|
// In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there,
|
||||||
// and `asset` (#237) and `object` (#238) are the newest of them.
|
// and `asset` (#237) and `object` (#238) are the newest of them. `list` since #239.
|
||||||
'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,struct',
|
'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,list,struct',
|
||||||
JSON.stringify((await json('__gm.list.info()')).offers),
|
JSON.stringify((await json('__gm.list.info()')).offers),
|
||||||
);
|
);
|
||||||
await pickKind('Text');
|
await pickKind('Text');
|
||||||
@@ -136,6 +136,41 @@ export async function listTab(t) {
|
|||||||
);
|
);
|
||||||
await shot('03a-object-item');
|
await shot('03a-object-item');
|
||||||
await undo();
|
await undo();
|
||||||
|
|
||||||
|
// 21d-iii. An item may be a list again (#239): its row is an item count, and a click opens the
|
||||||
|
// List tab on that list, one level down, with the path in the title.
|
||||||
|
const nums = () => json("__gm.state.project.assets.find((a) => a.id === 'listy').props.nums");
|
||||||
|
await clickText('+ Item');
|
||||||
|
await pickKind('List');
|
||||||
|
await sleep(200);
|
||||||
|
info = await json('__gm.list.info()');
|
||||||
|
check(
|
||||||
|
'a List item is added as an empty list',
|
||||||
|
info.kinds.join(',') === 'number,number,number,list' && JSON.stringify((await nums())[3]) === '[]',
|
||||||
|
JSON.stringify(info),
|
||||||
|
);
|
||||||
|
const innerCount = await json('__gm.list.fieldAt(3)');
|
||||||
|
await click(innerCount.x, innerCount.y);
|
||||||
|
await sleep(200);
|
||||||
|
info = await json('__gm.list.info()');
|
||||||
|
check(
|
||||||
|
'clicking its count opens that list, the path in the title',
|
||||||
|
info.title.endsWith('· nums · [3]') && info.items.length === 0,
|
||||||
|
JSON.stringify(info),
|
||||||
|
);
|
||||||
|
await clickText('+ Item');
|
||||||
|
await pickKind('Number');
|
||||||
|
await sleep(200);
|
||||||
|
check(
|
||||||
|
'an item added there lands in the list inside the list',
|
||||||
|
JSON.stringify((await nums()).slice(3)) === '[[0]]',
|
||||||
|
JSON.stringify(await nums()),
|
||||||
|
);
|
||||||
|
await shot('03b-nested-list');
|
||||||
|
await undo();
|
||||||
|
await undo();
|
||||||
|
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
|
||||||
|
await sleep(200);
|
||||||
await shot('03-rows');
|
await shot('03-rows');
|
||||||
|
|
||||||
// 21e. Back on the scene tab the button carries the current count.
|
// 21e. Back on the scene tab the button carries the current count.
|
||||||
|
|||||||
+51
-4
@@ -139,12 +139,13 @@ export async function structVariable(t) {
|
|||||||
const info = await json('__gm.list.info()');
|
const info = await json('__gm.list.info()');
|
||||||
check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title);
|
check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title);
|
||||||
|
|
||||||
// The depth the stored value goes ends here: this list sits in a struct field, so a struct item
|
// Nothing bounds the depth any more (#239): a list in a struct field offers what any list does.
|
||||||
// would be dropped by the next write. It is left out of the menu rather than doing nothing.
|
|
||||||
await clickText('+ Item');
|
await clickText('+ Item');
|
||||||
check(
|
check(
|
||||||
'the menu of a list in a struct field leaves the struct kind out',
|
'the menu of a list in a struct field offers every kind, the struct included',
|
||||||
(await find('Number')) !== null && (await find('Struct')) === null,
|
(await find('Number')) !== null &&
|
||||||
|
(await find('Struct')) !== null &&
|
||||||
|
(await json('__gm.list.info()')).offers.includes('list'),
|
||||||
JSON.stringify((await json('__gm.list.info()')).offers),
|
JSON.stringify((await json('__gm.list.info()')).offers),
|
||||||
);
|
);
|
||||||
await pickKind('Number');
|
await pickKind('Number');
|
||||||
@@ -165,6 +166,52 @@ export async function structVariable(t) {
|
|||||||
|
|
||||||
await mutate('__gm.state.openList(null)');
|
await mutate('__gm.state.openList(null)');
|
||||||
|
|
||||||
|
// 22f-ii. A field may be a struct again (#239): a head row with a `+ Field` of its own, and its
|
||||||
|
// fields one indent step further in. Both menus with real clicks.
|
||||||
|
const declaredLast = () =>
|
||||||
|
json("__gm.state.project.assets.find((a) => a.id === 'structy').variables[0].fields.at(-1)");
|
||||||
|
await clickText('+ Field');
|
||||||
|
await pickKind('Struct');
|
||||||
|
await sleep(250);
|
||||||
|
check(
|
||||||
|
'+ Field ▸ Struct gives the bundle an empty struct',
|
||||||
|
JSON.stringify((await piece()).field1) === '{}',
|
||||||
|
JSON.stringify(await piece()),
|
||||||
|
);
|
||||||
|
// The nested `+ Field` is drawn after the head's, so it is the last one.
|
||||||
|
const nestedAdd = await json(`__gm.locate('+ Field', true)`);
|
||||||
|
const headAdd = await json(`__gm.locate('+ Field')`);
|
||||||
|
check(
|
||||||
|
'and that struct has its own + Field',
|
||||||
|
nestedAdd.y > headAdd.y,
|
||||||
|
JSON.stringify({ nestedAdd, headAdd }),
|
||||||
|
);
|
||||||
|
await click(nestedAdd.x, nestedAdd.y);
|
||||||
|
await pickKind('Number');
|
||||||
|
await sleep(250);
|
||||||
|
check(
|
||||||
|
'which adds a field one level down, in the value',
|
||||||
|
JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":{"field1":0}}',
|
||||||
|
JSON.stringify(await piece()),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'and in the declaration',
|
||||||
|
JSON.stringify(await declaredLast()) ===
|
||||||
|
'{"key":"field1","kind":"struct","fields":[{"key":"field1","kind":"number"}]}',
|
||||||
|
JSON.stringify(await declaredLast()),
|
||||||
|
);
|
||||||
|
const outerName = await json(`__gm.locate('field1')`);
|
||||||
|
const innerName = await json(`__gm.locate('field1', true)`);
|
||||||
|
check(
|
||||||
|
'its field sits below it, one indent step further in',
|
||||||
|
innerName.y > outerName.y && innerName.x > outerName.x,
|
||||||
|
JSON.stringify({ outerName, innerName }),
|
||||||
|
);
|
||||||
|
await shot('03b-nested-struct');
|
||||||
|
await undo();
|
||||||
|
await undo();
|
||||||
|
check('undo takes both back out', !('field1' in (await piece())), JSON.stringify(await piece()));
|
||||||
|
|
||||||
// 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a
|
// 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a
|
||||||
// field edit rebuilds the whole bundle, so the untouched fields have to come from what the row
|
// field edit rebuilds the whole bundle, so the untouched fields have to come from what the row
|
||||||
// shows (the asset's values) rather than from the empty form of the declaration.
|
// shows (the asset's values) rather than from the empty form of the declaration.
|
||||||
|
|||||||
Reference in new issue
Block a user