Give a list its own tab instead of a comma line
A list property in the inspector was a single text field holding a comma
separated line -- unreadable past a handful of items, and it could not
carry anything but text. It becomes a button ("3 items") that opens a
sixth centre tab, where every item is a row built by the very widget the
inspector would build for that item kind.
The renderer was free: a PropertyDef of kind 'list' already carries
`items`, so item i becomes a synthetic def of that kind -- number, text,
boolean and colour rows appear without a new branch.
The way into the tab goes through the state (EditorState.listSlot,
openList(), event 'list'), like every other tab switch here; a tab may
not call showTab itself. ListSlot lives next to Selection rather than in
the tab folder, or state.ts would have to import upwards.
A row's handlers must not close over a snapshot of the list: they outlive
a commit, since the tab only rebuilds when the length changes, and the
second edited row would otherwise revive the value the first replaced.
showTab switched five `visible` lines by hand, where a sixth tab is
silently forgotten; it is a Record<CentreTab, Widget> now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a440ba433
commit
e52e95c0e9
15 files changed
+750
-70
No files matched your search
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Edits one list, a row per item, with the same field widgets the inspector builds. A list row in
|
||||
* the inspector has one line for a value that can be hundreds long; the inspector stays visible
|
||||
* beside this tab, so what the list belongs to is still on screen while it is edited.
|
||||
*/
|
||||
import {
|
||||
listFromText,
|
||||
type ListItemKind,
|
||||
listToText,
|
||||
type PropertyDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
Label,
|
||||
ScrollContainer,
|
||||
Spacer,
|
||||
TextField,
|
||||
Theme,
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import { createField, type FieldContext } from '../inspector/fieldFactory';
|
||||
import type { EditorState } from '../state';
|
||||
import { type ListAccess, resolveList } from './slot';
|
||||
|
||||
const INDEX_WIDTH = 40;
|
||||
|
||||
type ItemDef = Extract<PropertyDef, { kind: ListItemKind }>;
|
||||
|
||||
export class ListEditor extends VStack {
|
||||
private readonly ctx: FieldContext;
|
||||
private readonly title = new Label('', { style: 'bodyBold' });
|
||||
private readonly count = new Label('', { style: 'small', align: 'right' });
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private readonly addButton: Button;
|
||||
private readonly bulk: TextField;
|
||||
private rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
/** The structure the rows were built for; only a change here may destroy a row being typed in. */
|
||||
private signature = '';
|
||||
private setters: ((value: number | string | boolean) => void)[] = [];
|
||||
private fields: Widget[] = [];
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ gap: 0 });
|
||||
this.ctx = { project: () => this.state.project };
|
||||
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + 2 * Theme.padding;
|
||||
header.add(this.title, 1);
|
||||
header.add(this.count);
|
||||
this.add(header);
|
||||
|
||||
this.scroll.setContent(this.rows);
|
||||
this.add(this.scroll, 1);
|
||||
|
||||
// The comma text is what the inspector used to show. It stays, as the way to paste a whole
|
||||
// list at once – per-item rows do not replace that.
|
||||
const footer = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
this.addButton = new Button('+ Item', { variant: 'ghost', onClick: () => this.addItem() });
|
||||
footer.add(this.addButton);
|
||||
this.bulk = new TextField('', {
|
||||
placeholder: 'comma-separated',
|
||||
onChange: (text) => this.setAll(text),
|
||||
});
|
||||
footer.add(this.bulk, 1);
|
||||
this.add(footer);
|
||||
|
||||
state.on('list', () => this.rebuild());
|
||||
state.on('load', () => this.rebuild());
|
||||
state.on('project', () => this.refresh());
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
/** For the check scripts: where an item's widget sits, since a number field carries no findable text. */
|
||||
fieldAt(index: number): { x: number; y: number } | null {
|
||||
const widget = this.fields[index];
|
||||
if (!widget) return null;
|
||||
const p = widget.getGlobalPosition();
|
||||
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
|
||||
}
|
||||
|
||||
/** For the check scripts: what the tab shows and how its rows read right now. */
|
||||
debugInfo(): { title: string; items: string[]; kind: string } {
|
||||
const access = this.access();
|
||||
if (!access) return { title: '', items: [], kind: '' };
|
||||
return {
|
||||
title: access.title,
|
||||
items: access.value.map((item) => String(item)),
|
||||
kind: access.def.items,
|
||||
};
|
||||
}
|
||||
|
||||
private access(): ListAccess | null {
|
||||
const slot = this.state.listSlot;
|
||||
return slot ? resolveList(this.state, slot) : null;
|
||||
}
|
||||
|
||||
/** Everything that decides how many rows there are and of what kind. */
|
||||
private signatureOf(access: ListAccess | null): string {
|
||||
return access ? `${access.title}|${access.def.items}|${access.value.length}` : '';
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const access = this.access();
|
||||
if (this.signatureOf(access) !== this.signature) {
|
||||
this.rebuild();
|
||||
return;
|
||||
}
|
||||
if (!access) return;
|
||||
this.setters.forEach((set, i) => set(access.value[i] ?? ''));
|
||||
this.bulk.setValue(listToText(access.value, access.def.items));
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
const access = this.access();
|
||||
this.signature = this.signatureOf(access);
|
||||
this.rows.destroy({ children: true });
|
||||
this.rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
this.scroll.setContent(this.rows);
|
||||
this.setters = [];
|
||||
this.fields = [];
|
||||
|
||||
this.title.setText(access ? access.title : 'No list open');
|
||||
this.count.setText(access ? `${access.value.length} items` : '');
|
||||
this.addButton.visible = access !== null;
|
||||
this.bulk.visible = access !== null;
|
||||
|
||||
if (!access) {
|
||||
this.rows.add(new Label('Open a list from its row in the inspector.', { style: 'muted' }));
|
||||
} else {
|
||||
this.bulk.setValue(listToText(access.value, access.def.items));
|
||||
access.value.forEach((item, index) => this.rows.add(this.itemRow(access, item, index)));
|
||||
if (access.value.length === 0) this.rows.add(new Label('The list is empty.', { style: 'muted' }));
|
||||
}
|
||||
this.scroll.relayoutContent();
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
private itemRow(access: ListAccess, item: number | string | boolean, index: number): HStack {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const label = new Label(String(index), { style: 'small', align: 'right' });
|
||||
label.minWidth = INDEX_WIDTH;
|
||||
row.add(label);
|
||||
const field = createField(this.ctx, itemDef(access.def.items, index), item, access.itemHandlers(index));
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
new Button('×', {
|
||||
variant: 'ghost',
|
||||
width: Theme.controlHeight,
|
||||
onClick: () => access.replace(access.value.filter((_, i) => i !== index)),
|
||||
}),
|
||||
);
|
||||
this.setters.push(field.setValue);
|
||||
this.fields.push(field.widget);
|
||||
return row;
|
||||
}
|
||||
|
||||
private addItem(): void {
|
||||
const access = this.access();
|
||||
if (access) access.replace([...access.value, itemDef(access.def.items, 0).default]);
|
||||
}
|
||||
|
||||
private setAll(text: string): void {
|
||||
const access = this.access();
|
||||
if (access) access.replace(listFromText(text, access.def.items));
|
||||
}
|
||||
}
|
||||
|
||||
/** One item as a property definition, so the inspector's own widget renders it. */
|
||||
function itemDef(items: ListItemKind, index: number): ItemDef {
|
||||
const key = String(index);
|
||||
switch (items) {
|
||||
case 'number':
|
||||
return { key, label: key, kind: 'number', default: 0 };
|
||||
case 'text':
|
||||
return { key, label: key, kind: 'text', default: '' };
|
||||
case 'boolean':
|
||||
return { key, label: key, kind: 'boolean', default: false };
|
||||
case 'color':
|
||||
return { key, label: key, kind: 'color', default: 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Resolves a `ListSlot` to the list in it and to the two ways of writing it back. Each of the
|
||||
* three cases is the spec the matching inspector section already uses, so an item edited in the
|
||||
* List tab goes through the same command, the same coercion and the same undo step as the row
|
||||
* it replaces.
|
||||
*/
|
||||
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { findNode, type GraphNode } from '../../core/graph/Graph';
|
||||
import { resolveDef } from '../../core/graph/nested';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import { isList, type ListValue, type PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import {
|
||||
coerceListItem,
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaForAsset,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||
import type { FieldHandlers } from '../inspector/fieldFactory';
|
||||
import { currentProps, currentRef } from '../inspector/objectQueries';
|
||||
import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers';
|
||||
import type { EditorState, ListSlot } from '../state';
|
||||
|
||||
export type ListDef = Extract<PropertyDef, { kind: 'list' }>;
|
||||
|
||||
/** What the tab needs of a slot: what is in it, and the two ways of writing it back. */
|
||||
export interface ListAccess {
|
||||
readonly def: ListDef;
|
||||
readonly value: ListValue;
|
||||
/** What the tab header says the list belongs to. */
|
||||
readonly title: string;
|
||||
/** One item, previewed and committed exactly as the inspector does it. */
|
||||
itemHandlers(index: number): FieldHandlers;
|
||||
/** A whole new list in one command – adding, deleting, and the bulk text field. */
|
||||
replace(next: ListValue): void;
|
||||
}
|
||||
|
||||
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null {
|
||||
if (slot.kind === 'asset') return assetAccess(state, slot);
|
||||
if (slot.kind === 'object') return objectAccess(state, slot);
|
||||
return nodeAccess(state, slot);
|
||||
}
|
||||
|
||||
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
|
||||
|
||||
const listDefIn = (schema: PropertySchema, key: string): ListDef | null => {
|
||||
const def = schema.find((d) => d.key === key);
|
||||
return def?.kind === 'list' ? def : null;
|
||||
};
|
||||
|
||||
const withItem = (list: ListValue, index: number, item: number | string | boolean): ListValue =>
|
||||
list.map((value, i) => (i === index ? item : value));
|
||||
|
||||
/**
|
||||
* `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so
|
||||
* the next keystroke has to build on what is in there now, and the commit on what the restore
|
||||
* put back. Everything else is the spec the matching inspector section already uses.
|
||||
*/
|
||||
function access<Target>(
|
||||
state: EditorState,
|
||||
def: ListDef,
|
||||
title: string,
|
||||
current: () => ListValue,
|
||||
spec: Omit<PreviewedValue<Target>, 'toModel'>,
|
||||
): ListAccess {
|
||||
return {
|
||||
def,
|
||||
title,
|
||||
value: current(),
|
||||
itemHandlers: (index) =>
|
||||
previewingHandlers<Target>(
|
||||
{ state },
|
||||
{ ...spec, toModel: (input) => withItem(current(), index, coerceListItem(input, def.items)) },
|
||||
),
|
||||
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
|
||||
// A preview does not, which is why `toModel` above has to.
|
||||
replace: (next) => state.execute(spec.commit(next)),
|
||||
};
|
||||
}
|
||||
|
||||
function assetAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): ListAccess | null {
|
||||
const asset = findAsset(state.project, slot.assetId);
|
||||
const def = asset && listDefIn(schemaForAsset(asset), slot.key);
|
||||
if (!asset || !def) return null;
|
||||
const at = () => findAsset(state.project, slot.assetId);
|
||||
return access<Asset>(
|
||||
state,
|
||||
def,
|
||||
`${asset.name} · ${def.label}`,
|
||||
() => {
|
||||
const now = at();
|
||||
return now ? asList(resolveAssetProps(now)[slot.key]) : [];
|
||||
},
|
||||
{
|
||||
target: at,
|
||||
read: (found) => found.props[slot.key],
|
||||
write: (found, value) => (found.props[slot.key] = value),
|
||||
commit: (value) => new SetAssetPropertyCommand(slot.assetId, slot.key, value),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function objectAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'object' }>): ListAccess | null {
|
||||
const ref = currentRef(state, slot.holderId, slot.objectId);
|
||||
const owner = ref && findAsset(state.project, ref.object.assetId);
|
||||
const def = owner && listDefIn(schemaForAsset(owner), slot.key);
|
||||
if (!ref || !def) return null;
|
||||
return access<ObjectRef>(
|
||||
state,
|
||||
def,
|
||||
`${ref.object.name} · ${def.label}`,
|
||||
() => asList(currentProps(state, slot.holderId, slot.objectId)?.[slot.key]),
|
||||
{
|
||||
target: () => currentRef(state, slot.holderId, slot.objectId),
|
||||
read: (found) => found.object.overrides[slot.key],
|
||||
write: (found, value) => found.setOverride(slot.key, value),
|
||||
remove: (found) => found.setOverride(slot.key, undefined),
|
||||
commit: (value) => new SetOverrideCommand(slot.holderId, slot.objectId, slot.key, value),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function nodeAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): ListAccess | null {
|
||||
const found = nodeIn(state, slot);
|
||||
if (!found) return null;
|
||||
const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project));
|
||||
const def = nodeDef && listDefIn(nodeDef.params, slot.key);
|
||||
if (!def) return null;
|
||||
return access<GraphNode>(
|
||||
state,
|
||||
def,
|
||||
`${nodeDef.label} · ${def.label}`,
|
||||
() => asList(nodeIn(state, slot)?.node.params[slot.key] ?? def.default),
|
||||
{
|
||||
target: () => nodeIn(state, slot)?.node,
|
||||
read: (node) => node.params[slot.key],
|
||||
write: (node, value) => (node.params[slot.key] = value),
|
||||
remove: (node) => delete node.params[slot.key],
|
||||
commit: (value) => new SetNodeParamCommand(slot.assetId, slot.nodeId, slot.key, value),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** The node together with its graph, because `resolveDef` needs both. */
|
||||
function nodeIn(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>) {
|
||||
const asset = findAsset(state.project, slot.assetId);
|
||||
if (asset?.type !== 'behavior') return null;
|
||||
const node = findNode(asset.graph, slot.nodeId);
|
||||
return node ? { node, graph: asset.graph } : null;
|
||||
}
|
||||
Reference in new issue
Block a user