Show lists in the inspector and variables in their own tab (#252)
game-maker / gate (push) Successful in 3m24s
game-maker / suite (push) Successful in 10m22s

The List tab could show one list at a time and was the only place to
see its items. A list now shows its items right where it lives, and a
new Variables tab shows all variables of an asset or placed object.

- InlineList draws the items of a list under its row in the inspector,
  in struct fields and in the Variables tab. A list inside a list or a
  struct field is drawn the same way, one step indented.
- The index column is the indent. Items sit under their list without an
  own margin, and the index column is 16 px wide instead of 28 px.
- Above 20 items (INLINE_LIMIT) the rest collapses into "... N more -
  open in tab". During a run the rows show the values from the game.
- ListHead puts "N items" and "+ Item" in one row. "N items" opens the
  list in a tab. A separate Open button squeezed the counter to zero
  width in the narrow inspector column.
- VariablesEditor shows every variable of a behavior asset, a variables
  asset or a placed behavior. On an asset it edits the declarations. On
  a placed object an edit only overrides that object, with the usual
  reset and push buttons.
- Opening a list inside a tab adds a new tab and keeps the open one.
  valuesKey gives each list its own document, so a list that is already
  open is shown instead of opened twice.
- Titles read "Pickup · Variables" or "Listy · nums · [3]". The tab
  closes when its asset or object is deleted.
- Lists on node inputs stay inline only, a node owns no variables.
- The List tab, ListEditor and the __gm.list hook are gone.

Tests:
- ./check.sh: 181 files, 2645 tests, CHECK EXIT 0
- GM_STEPS=1,21,22,32: ALL PASSED, 123 checks
- e2e 21 is rewritten for inline lists and the new tab, 22 and 32 are
  adapted. Sum in README: 1486 (+6 in 21, -1 in 22), counted by hand.
- Counter-probes fail as expected: sub-lists not told apart (1 unit
  test), sub-list replacing the open tab, all items drawn inline (60
  rows instead of 20), no live values (0 rows at 24 items in a run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 21:56:21 +02:00
1 parent 92fad6b9e3
commit 4857bb75e6
26 files changed
+1203 -427

No files matched your search

+1 -1
View File
@@ -58,5 +58,5 @@ export const AssetLiteral = literal(
export const ListLiteral = literal(
'List',
'list',
'A constant list, edited item by item in the List tab – each item with its own kind.',
'A constant list, edited item by item in the inspector – each item with its own kind.',
);
+55 -33
View File
@@ -44,8 +44,8 @@ import { ContainerEditor } from './container/ContainerEditor';
import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { ListEditor } from './list/ListEditor';
import { resolveList } from './list/slot';
import { valuesTitle, VariablesEditor } from './variables/VariablesEditor';
import { valuesKey } from './variables/target';
import { AnimationEditor } from './animation/AnimationEditor';
import { TileEditor } from './tile/TileEditor';
import { AssetPanel } from './panels/AssetPanel';
@@ -66,7 +66,7 @@ import { ShaderEditor } from './shader/ShaderEditor';
import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts';
import type { PlayFrom } from '../core/model/playSettings';
import type { EditorState } from './state';
import type { EditorState, ValuesTarget } from './state';
import { copySelection } from './clipboard';
import { PixelArtEditor } from './pixelart/PixelArtEditor';
import { VectorArtEditor } from './vectorart/VectorArtEditor';
@@ -92,7 +92,7 @@ export type CentreTab =
| 'curve'
| 'tile'
| 'container'
| 'list'
| 'variables'
| 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
@@ -100,10 +100,11 @@ export type PluginTabId = `plugin:${string}`;
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
export type TabId = CentreTab | PluginTabId;
/**
* An open document (#171): an asset by its id, or the list the List tab edits – there is one. The
* user management (#179) is no document, but it opens and closes like one.
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
* `valuesKey` – as many of those as were opened. The user management (#179) is no document, but it
* opens and closes like one.
*/
export type DocumentId = `asset:${string}` | 'list' | 'management';
export type DocumentId = `asset:${string}` | `values:${string}` | 'management';
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
@@ -121,7 +122,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'list' | 'management'>;
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'management'>;
const FIXED_TABS = ['scene', 'game'] as const;
@@ -139,7 +140,7 @@ const PANE_ORDER = [
'curve',
'tile',
'container',
'list',
'variables',
'management',
] as const;
@@ -158,7 +159,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
curve: 'Curve',
tile: 'Tile',
container: 'Container',
list: 'List',
variables: 'Variables',
management: 'User management',
};
@@ -169,7 +170,7 @@ const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
};
function isDocument(tab: StripTab): tab is DocumentId {
return tab === 'list' || tab === 'management' || tab.startsWith('asset:');
return tab === 'management' || tab.startsWith('asset:') || tab.startsWith('values:');
}
const MIN_LEFT_WIDTH = 160;
@@ -204,7 +205,9 @@ export class Editor implements PlayHost {
readonly curveEditor: CurveEditor;
readonly tileEditor: TileEditor;
readonly containerEditor: ContainerEditor;
readonly listEditor: ListEditor;
readonly variablesEditor: VariablesEditor;
/** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
readonly pluginsPane = new PluginsPane();
/** The accounts, above the plugins; null unless an administrator is signed in. */
@@ -284,7 +287,7 @@ export class Editor implements PlayHost {
this.curveEditor = new CurveEditor(state);
this.tileEditor = new TileEditor(state);
this.containerEditor = new ContainerEditor(state);
this.listEditor = new ListEditor(state);
this.variablesEditor = new VariablesEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state);
@@ -320,7 +323,7 @@ export class Editor implements PlayHost {
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`).
this.inspector.liveVariables = (assetId) =>
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
this.listEditor.liveVariables = this.inspector.liveVariables;
this.variablesEditor.liveVariables = this.inspector.liveVariables;
this.usersPane =
account.role === 'admin'
@@ -395,7 +398,7 @@ export class Editor implements PlayHost {
scene: this.scenePane,
game: this.gamePane,
...this.assetEditors,
list: this.listEditor,
variables: this.variablesEditor,
management: this.managementPane,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
@@ -472,10 +475,13 @@ export class Editor implements PlayHost {
if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene');
else this.openDocument(`asset:${s.assetId}`);
});
// Opening a list is a request to look at it, so this switches even when the slot is unchanged.
state.on('list', () => {
if (state.listSlot) this.openDocument('list');
else this.closeDocument('list');
// A tab per target: a list opened from inside a tab gets one beside it, not that tab (#252).
state.on('values', () => {
const target = state.openedValues;
if (!target) return;
const doc: DocumentId = `values:${valuesKey(target)}`;
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
this.openDocument(doc);
});
state.on('project', () => {
this.play.syncFromModel();
@@ -486,6 +492,7 @@ export class Editor implements PlayHost {
this.editorRun.invalidate();
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null };
this.valueTargets.clear();
this.showTab('scene');
});
state.on('mode', () => this.editorRun.invalidate());
@@ -614,7 +621,7 @@ export class Editor implements PlayHost {
openDocument(doc: DocumentId): void {
if (!this.kindOf(doc)) return;
if (this.documents.active === doc) {
// Still worth a look at the name: the List tab may have been pointed at another list.
// Still worth a look at the name: a Variables tab's owner may have been renamed.
this.syncStrip();
return;
}
@@ -632,8 +639,7 @@ export class Editor implements PlayHost {
const kind = this.kindOf(doc);
if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
// The List tab edits the one slot there is; a closed tab lets go of it.
if (doc === 'list' && this.state.listSlot) this.state.openList(null);
this.valueTargets.delete(doc);
if (!wasShowing) return;
if (this.documents.active) this.showDocument(this.documents.active);
else this.showTab('scene');
@@ -645,8 +651,9 @@ export class Editor implements PlayHost {
* a document without knowing its tab.
*/
showTab(tab: TabId): void {
if (tab === 'list') {
if (this.state.listSlot) this.openDocument('list');
if (tab === 'variables') {
const doc = [...this.documents.docs].reverse().find((d) => d.startsWith('values:'));
if (doc) this.openDocument(doc);
return;
}
if (tab === 'management') {
@@ -672,7 +679,10 @@ export class Editor implements PlayHost {
}
private kindOf(doc: DocumentId): CentreTab | null {
if (doc === 'list') return this.state.listSlot ? 'list' : null;
if (doc.startsWith('values:')) {
const target = this.valueTargets.get(doc);
return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
}
if (doc === 'management') return 'management';
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined;
@@ -683,25 +693,27 @@ export class Editor implements PlayHost {
const kind = this.kindOf(doc);
if (!kind) return;
if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
const target = this.valueTargets.get(doc);
if (kind === 'variables' && target) this.variablesEditor.show(target);
// Read on every showing: an account may have been changed from another tab in the meantime.
if (kind === 'management') void this.usersPane?.reload();
this.display(kind, doc);
}
/** What a document's tab says: the asset's name, or the list's owner and name, as its tab heads it. */
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'list') {
const slot = this.state.listSlot;
return (slot && resolveList(this.state, slot)?.title) ?? '';
}
const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? '';
}
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
const type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const target = this.valueTargets.get(doc);
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
}
@@ -764,8 +776,18 @@ export class Editor implements PlayHost {
* Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
* on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
*/
tabRow(): { shown: TabPlace<StripTab>[]; hidden: StripTab[]; order: StripTab[] } {
return { shown: this.tabs.places(), hidden: this.tabs.hidden(), order: [...this.tabs.tabs] };
tabRow(): {
shown: TabPlace<StripTab>[];
hidden: StripTab[];
order: StripTab[];
labels: Record<string, string>;
} {
return {
shown: this.tabs.places(),
hidden: this.tabs.hidden(),
order: [...this.tabs.tabs],
labels: Object.fromEntries(this.documents.docs.map((doc) => [doc, this.labelOf(doc)])),
};
}
boot(): Promise<void> {
+31 -7
View File
@@ -4,9 +4,15 @@ import { currentFramesDialog } from '../animation/FramesDialog';
import { currentRowDialog } from '../animation/RowDialog';
import { centreOf, type HookContext } from './hookHelpers';
/** The Texture, Animation, Tile and List tabs. */
/** Where a check looks for an inline list: the Variables tab showing, or the inspector. */
type ListPlace = 'tab' | 'inspector';
/** The Texture, Animation, Tile and Variables tabs. */
export function documentHooks({ state, editor }: HookContext) {
const { textureEditor } = editor;
const listsIn = (where: ListPlace) =>
where === 'tab' ? editor.variablesEditor.lists() : editor.inspector.lists();
const listIn = (where: ListPlace, title: string) => listsIn(where).find((list) => list.title === title);
return {
texture: {
current: () => textureEditor.currentAssetId,
@@ -81,14 +87,32 @@ export function documentHooks({ state, editor }: HookContext) {
pictureRowAt: (index: number) => editor.tileEditor.strip.list.rowGlobal(String(index)),
fitAt: () => centreOf(editor.tileEditor.fitButton),
},
list: {
info: () => editor.listEditor.debugInfo(),
values: {
/** What the Variables tab showing now is pointed at, and the variable rows it built. */
info: () => editor.variablesEditor.debugInfo(),
/**
* Every list drawn inline – in the showing Variables tab or in the inspector – with what it
* holds and how much of it is drawn; the others below find one by its title (#252).
*/
lists: (where: ListPlace) => listsIn(where).map((list) => list.debugInfo()),
/** Where an item's widget sits, so a check can click it – a number field has no findable text. */
fieldAt: (index: number, field = '') => editor.listEditor.fieldAt(index, field),
fieldAt: (where: ListPlace, title: string, index: number, field = '') =>
listIn(where, title)?.fieldAt(index, field) ?? null,
/** The kind mark of a row, or of one field of a struct item – both are pickers. */
markAt: (index: number, field = '') => editor.listEditor.markAt(index, field),
nameAt: (index: number, field: string) => editor.listEditor.nameAt(index, field),
removeAt: (index: number) => editor.listEditor.removeAt(index),
markAt: (where: ListPlace, title: string, index: number, field = '') =>
listIn(where, title)?.markAt(index, field) ?? null,
nameAt: (where: ListPlace, title: string, index: number, field: string) =>
listIn(where, title)?.nameAt(index, field) ?? null,
removeAt: (where: ListPlace, title: string, index: number) =>
listIn(where, title)?.removeAt(index) ?? null,
moreAt: (where: ListPlace, title: string) => listIn(where, title)?.moreAt() ?? null,
/** The head of a list: its "+ Item" and – where it has one – its "Open". */
headAt: (where: ListPlace, title: string) => {
const head = listIn(where, title)?.head;
return head
? { add: centreOf(head.addButton), open: head.openButton ? centreOf(head.openButton) : null }
: null;
},
},
};
}
+9
View File
@@ -16,6 +16,9 @@ import {
refreshObjectSection,
} from './objectSection';
import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection';
import { INLINE_LIMIT } from '../variables/target';
import { inlineListsIn } from '../variables/VariablesEditor';
import type { InlineList } from '../list/InlineList';
/**
* Shows the properties of the current selection. The rows are generated from the PropertySchema
@@ -73,6 +76,11 @@ export class Inspector extends Panel {
return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft };
}
/** For the check scripts: every list drawn inline in here, at any depth. */
lists(): InlineList[] {
return inlineListsIn(this.host.body);
}
private emptyHost(): InspectorHost {
return {
state: this.state,
@@ -80,6 +88,7 @@ export class Inspector extends Panel {
project: () => this.state.project,
liveVariables: (assetId) => this.liveVariables(assetId),
followReference: (assetId, e) => this.followReference(assetId, e),
listLimit: INLINE_LIMIT,
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(),
nameField: null,
+22 -5
View File
@@ -42,12 +42,13 @@ import { Button, Dropdown, HStack, Label, Menu, Theme } from '../../ui';
import { FontPreview } from '../font/FontPreview';
import { clearSaved, hasSaved } from '../saves';
import { SoundPreview } from '../sound/SoundPreview';
import type { VariablesOwner } from '../state';
import { aspectActions, sizeCommand } from './aspectAction';
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
import { buildShapeListSection, shapeListSignature } from './shapeSection';
import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { listSource, previewingHandlers, structFields } from './previewHandlers';
import { assetValue } from './valueSpecs';
/** A struct field declares its own fields, starting with none. */
@@ -76,7 +77,7 @@ export function assetSignature(project: Project, assetId: string): string {
}
export function defSignature(def: PropertyDef | StructFieldDef): string {
// Only a struct has rows of its own, at any depth; a list is one row whatever its items are.
// Only a struct has rows of its own, at any depth; a list's items rebuild themselves (#252).
const fields = def.kind === 'struct' ? def.fields.map(defSignature).join('+') : '';
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
}
@@ -170,7 +171,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
const spec = assetValue(host.state, assetId, def.key);
return {
...previewingHandlers<Asset>(host, spec),
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
...listSource(host, def, () => ({ kind: 'asset', assetId, key: def.key })),
...structFields<Asset>(host, def, spec, (path) => ({ kind: 'asset', assetId, key: def.key, path })),
};
}
@@ -193,8 +194,15 @@ function fieldsAt(fields: readonly StructFieldDef[], path: readonly string[]): r
return field?.kind === 'struct' ? fieldsAt(field.fields, rest) : [];
}
/** Declared variables, each with its declaration editable – plus an "add" menu. */
function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAsset): void {
/**
* Declared variables, each with its declaration editable – plus an "add" menu, and in the inspector
* the way to the same rows in a Variables tab (#252).
*/
export function buildVariables(
host: InspectorHost,
asset: BehaviorAsset | VariablesAsset,
inTab = false,
): void {
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' });
@@ -207,6 +215,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAss
new Menu(items).openBelow(add);
};
header.add(add);
if (!inTab) header.add(openVariablesButton(host, { kind: 'asset', assetId: asset.id }));
host.body.add(header);
if (asset.variables.length === 0) addNote(host, 'No variables yet.');
@@ -244,6 +253,14 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAss
}
}
/** Opens the variables of `owner` in a Variables tab of their own, or brings up the one they have. */
export function openVariablesButton(host: InspectorHost, owner: VariablesOwner): Button {
return new Button('Open', {
variant: 'ghost',
onClick: () => host.state.openValues({ kind: 'variables', owner }),
});
}
/** Not in the schema table either: a row there would share the bag with the declared values. */
const PERSISTENT: PropertyDef = { key: 'persistent', label: 'Persistent', kind: 'boolean', default: false };
+57 -9
View File
@@ -49,6 +49,8 @@ import {
type Widget,
} from '../../ui';
import { ROW_ACTION_ICON_SVG } from '../panels/markIcons';
import { InlineList, ListHead } from '../list/InlineList';
import type { ListAccess } from '../list/slot';
/**
* What a field needs beyond its own value: the project, to resolve an asset reference to a name
@@ -62,6 +64,8 @@ export interface FieldContext {
* plain text – only the inspector knows what a click (ping) and a double-click (open) do.
*/
followReference?(assetId: string, e: FederatedPointerEvent): void;
/** Items a list draws inline before "… N more" (#252); none means all of them. */
listLimit?: number;
}
export interface FieldHandlers {
@@ -69,8 +73,11 @@ export interface FieldHandlers {
onInput: (value: PropertyValue) => void;
/** Final value – commit as a command. */
onChange: (value: PropertyValue) => void;
/** Sends a list to the List tab; only the caller knows where the value it edits lives. */
openList?: () => void;
/**
* The list a `list` row draws inline (#252), re-resolved on every call; only the caller knows
* where the value it edits lives. Without it the row only says how many items there are.
*/
list?: () => ListAccess | null;
/**
* Handlers for one field of a struct, `path` being the keys from the bundle down to it (a struct
* field nests further, #239). Writing a field means writing the whole bundle, so only the caller –
@@ -250,7 +257,7 @@ export function createFieldRow(
retarget(value);
return {
row,
row: withBelow(row, field, 1),
setValue: (v) => {
field.setValue(v);
retarget(v);
@@ -294,6 +301,32 @@ export interface Field {
setValue(value: PropertyValue): void;
/** The per-field widgets of a struct, so a caller can address one of them. */
parts?: ReadonlyMap<string, Widget>;
/** What goes under the row rather than into it: a list's items (#252). */
below?: Widget;
}
/**
* The row alone, or – for a field with something below it – the row with that under it, `depth`
* indent steps in.
*/
export function withBelow(row: Widget, field: Field, depth: number): Widget {
if (!field.below) return row;
const stack = new VStack({ gap: Theme.gap });
stack.add(row);
stack.add(indentedBy(field.below, depth));
return stack;
}
/**
* A widget `depth` indent steps in, as far as a struct field of that depth. A list's items go one
* step less: their index column already reads as the step in.
*/
export function indentedBy(widget: Widget, depth: number): HStack {
const steps = widget instanceof InlineList ? Math.max(0, depth - 1) : depth;
const line = new HStack({ gap: 0 });
if (steps > 0) line.add(new Spacer(steps * (FIELD_INDENT + Theme.gap)));
line.add(widget, 1);
return line;
}
/** A widget that does not grow gets a spacer after it, so whatever follows sits at the row's end. */
@@ -452,11 +485,26 @@ export function createField(
return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) };
}
case 'list': {
// A board is hundreds of entries: the row says how many and hands the editing to the
// List tab, where a row per item has the space for it.
const count = (v: PropertyValue) => `${(isList(v) ? v : []).length} items`;
const button = new Button(count(value), { variant: 'ghost', onClick: handlers.openList });
return { widget: button, grow: true, setValue: (v) => button.setText(count(v)) };
const count = (v: PropertyValue) => (isList(v) ? v : []).length;
const source = handlers.list;
if (!source) {
const label = new Label(`${count(value)} items`, { style: 'muted', padding: Theme.fieldPadding });
return { widget: label, grow: true, setValue: (v) => label.setText(`${count(v)} items`) };
}
// The items go under the row: a board is hundreds of entries, so the inspector draws the
// first few and the head opens the rest in a tab of its own.
const list = new InlineList(ctx, source, { limit: ctx.listLimit });
const head = new ListHead(source, (kind) => list.addItem(kind));
list.head = head;
return {
widget: head,
grow: true,
setValue: (v) => {
head.setCount(count(v));
list.refresh();
},
below: list,
};
}
case 'struct': {
// A bundle has few fields, so unlike a list they fit under each other right here. Editing
@@ -471,7 +519,7 @@ export function createField(
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), own);
addValue(row, sub);
fields.set(field.key, sub);
stack.add(row);
stack.add(withBelow(row, sub, 1));
}
return {
widget: stack,
+2 -2
View File
@@ -35,7 +35,7 @@ import {
buildVariablePortSection,
variableChoices,
} from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers';
import { listSource, previewingHandlers } from './previewHandlers';
import { buildTransitionPicker, buildTransitionSection } from './transitionSection';
import { currentNode, nodeParamValue } from './valueSpecs';
@@ -162,6 +162,6 @@ function paramHandlers(
const key = def.key;
return {
...previewingHandlers<GraphNode>(host, nodeParamValue(host.state, assetId, nodeId, key)),
...listOpener(host.state, def, () => ({ kind: 'node', assetId, nodeId, key })),
...listSource(host, def, () => ({ kind: 'node', assetId, nodeId, key })),
};
}
+29 -11
View File
@@ -44,10 +44,10 @@ import {
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, TextField, Theme } from '../../ui';
import { Button, HStack, Label, TextField, Theme } from '../../ui';
import { createContainer, removeObjects } from '../actions';
import { aspectActions, sizeCommand } from './aspectAction';
import { defSignature } from './assetSection';
import { defSignature, openVariablesButton } from './assetSection';
import {
actionIf,
createFieldRow,
@@ -59,7 +59,7 @@ import {
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
import type { EditorState, ListSlot } from '../state';
import { parentRow } from './parentRow';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { listSource, previewingHandlers, structFields } from './previewHandlers';
import { overrideValue, tilemapSizeValue } from './valueSpecs';
import { buildVariable } from './variableRows';
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
@@ -214,19 +214,37 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
});
}
// Asset properties (or behavior variables) with overrides; the switches are shown above.
const values = resolveSceneObjectProps(host.state.project, asset, object);
const own = objectRows(host.state.project, asset, object).filter((def) => isRowShown(def, values));
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior');
buildOwnRows(host, holderId, objectId);
markObjectRows(host, object, asset, target);
if (asset.type === 'tilemap') gridEdgesSection(host, holderId, object);
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
}
/**
* Asset properties (or behavior variables) with overrides; the switches are shown above them. A
* behavior's variables also open in a Variables tab (#252), which builds just this part.
*/
export function buildOwnRows(host: InspectorHost, holderId: string, objectId: string, inTab = false): void {
const { project } = host.state;
const view = viewOf(project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return;
const values = resolveSceneObjectProps(project, asset, object);
const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values));
const behavior = asset.type === 'behavior';
if (behavior) {
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
if (!inTab) header.add(openVariablesButton(host, { kind: 'object', holderId, objectId }));
host.body.add(header);
} else if (own.length > 0) addTitle(host, 'PROPERTIES');
if (own.length === 0 && behavior) addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, behavior);
if (inTab) markObjectRows(host, object, asset, targetOf(project, holderId, objectId));
}
/**
* Grows and shrinks the grid at any edge, one column or row per click, while the painted cells stay
* where they are in the scene (#136). The cells belong to the asset, so they move in every other
@@ -388,7 +406,7 @@ function propertyRow(
const value = values[def.key] ?? def.default;
const handlers = {
...previewingHandlers<ObjectRef>(host, overrideSpec),
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
...listSource(host, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
...structFields<ObjectRef>(host, def, overrideSpec, fieldSlot),
};
// A variable reads exactly like it does on its asset, which adds its own rows to the body.
+14 -12
View File
@@ -9,18 +9,18 @@ import type { Command } from '../../core/commands/Command';
import type { PropertyValue } from '../../core/model/Properties';
import { type ValuePath, withValueAt } from '../../core/properties/valuePath';
import type { EditorState, ListSlot } from '../state';
import { type LiveVariables, resolveList } from '../list/slot';
import type { FieldHandlers } from './fieldFactory';
/** What a section knows beyond the state: a Variables asset's values in the run going on. */
type Host = { state: EditorState; liveVariables?: LiveVariables };
/**
* The `openList` half of a row's handlers, for the sections that know where their value lives.
* Empty for every other kind, so spreading it into the handlers is always safe.
* The `list` half of a row's handlers, for the sections that know where their value lives. Empty
* for every other kind, so spreading it into the handlers is always safe.
*/
export function listOpener(
state: EditorState,
def: PropertyDef,
slot: () => ListSlot,
): Pick<FieldHandlers, 'openList'> {
return def.kind === 'list' ? { openList: () => state.openList(slot()) } : {};
export function listSource(host: Host, def: PropertyDef, slot: () => ListSlot): Pick<FieldHandlers, 'list'> {
return def.kind === 'list' ? { list: () => resolveList(host.state, slot(), host.liveVariables) } : {};
}
/**
@@ -29,7 +29,7 @@ export function listOpener(
* field edited undo the first.
*/
export function structFields<Target>(
host: { state: EditorState },
host: Host,
def: PropertyDef,
spec: PreviewedValue<Target>,
fieldSlot?: (path: ValuePath) => ListSlot,
@@ -38,11 +38,11 @@ export function structFields<Target>(
}
/**
* `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 its items can be drawn and opened.
* `path` leads from the bundle to the field, through struct fields as deep as they nest (#239).
*/
export function structFieldsOf<Target>(
host: { state: EditorState },
host: Host,
def: StructDef,
spec: PreviewedValue<Target>,
fieldSlot?: (path: ValuePath) => ListSlot,
@@ -55,7 +55,9 @@ export function structFieldsOf<Target>(
return withValueAt(bundle, path, coerceFieldValue(input, field)) ?? bundle;
},
}),
...(field.kind === 'list' && fieldSlot ? { openList: () => host.state.openList(fieldSlot(path)) } : {}),
...(field.kind === 'list' && fieldSlot
? { list: () => resolveList(host.state, fieldSlot(path), host.liveVariables) }
: {}),
});
}
+1 -1
View File
@@ -14,7 +14,7 @@ import type { EditorState } from '../state';
import { currentProps, currentRef } from './objectQueries';
import type { PreviewedValue } from './previewHandlers';
/** Where one value lives, shared by the inspector and the List tab so both write it the same way. */
/** Where one value lives, shared by the inspector rows and the inline lists so both write it the same way. */
export function assetValue(state: EditorState, assetId: string, key: string): PreviewedValue<Asset> {
return {
target: () => findAsset(state.project, assetId),
+10 -3
View File
@@ -22,6 +22,7 @@ import {
type FieldHandlers,
fieldDef,
type FieldRow,
indentedBy,
MARK_WIDTH,
nameWidth,
READ_ONLY,
@@ -77,16 +78,20 @@ export function buildVariable(
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
const head = gridRow(0, markLabel(def.kind), name);
let setValue: (next: PropertyValue) => void = () => {};
let below: Widget | undefined;
if (def.kind === 'struct') {
head.add(new Spacer(), 1);
if (edit) head.add(addFieldMenu(edit, []));
} else {
setValue = addValue(head, createField(host, def, value, handlers));
const field = createField(host, def, value, handlers);
setValue = addValue(head, field);
below = field.below;
}
if (keep) head.add(keepButton(keep));
if (edit) head.add(removeButton(edit.remove));
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
host.body.add(head);
if (below) host.body.add(indentedBy(below, 1));
if (def.kind === 'struct') {
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
@@ -141,9 +146,11 @@ function structFieldRows(
continue;
}
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
setters.set(field.key, addValue(row, createField(host, fieldDef(field), item, own)));
const sub = createField(host, fieldDef(field), item, own);
setters.set(field.key, addValue(row, sub));
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
host.body.add(row);
if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2));
}
return (next) => {
for (const field of fields) setters.get(field.key)?.(structFieldValue(next, field));
@@ -183,7 +190,7 @@ export function markLabel(kind: string): Widget {
}
/**
* A kind mark that can be clicked – the List tab's way of saying what an item holds. The label may
* A kind mark that can be clicked – a list's way of saying what an item holds. The label may
* take the button's whole width: the column is sized for its widest mark, and the usual inset
* would cut `Aa` to `A…`.
*/
@@ -1,12 +1,14 @@
/**
* Edits one list, a row per item, with the same field widgets the inspector builds -- a value that
* can be hundreds long has no room in an inspector row. The inspector stays visible beside this
* tab, so what the list belongs to is still on screen while it is edited.
* The items of a list, drawn right where the list stands (#252): under its row in the inspector,
* under a struct field, inside another list, and as the whole content of a Variables tab. A row per
* item with the same field widgets the inspector builds; a list item is drawn inline again, one
* step further in.
*
* A list declares nothing, so this is also where an item's kind is chosen: the mark in front of a
* row is a picker, and changing it (or a struct item's shape) is an ordinary value edit.
*/
import {
Button,
Dropdown,
HStack,
Label,
@@ -42,18 +44,17 @@ import { valueAt, type ValuePath, withValueAt } from '../../core/properties/valu
import {
addValue,
createField,
type Field,
type FieldContext,
FIELD_INDENT,
nameWidth,
READ_ONLY,
removeButton,
} from '../inspector/fieldFactory';
import { kindMenu, markPicker } from '../inspector/variableRows';
import type { EditorState } from '../state';
import { type ListAccess, type LiveVariables, resolveList } from './slot';
import { shownItems } from '../variables/target';
import type { ListAccess } from './slot';
const INDEX_WIDTH = 40;
/** Two digits; a longer index widens its own label. */
const INDEX_WIDTH = 16;
/** What a check script may ask a row for: its value widget, its mark, and the same per struct field. */
interface RowWidgets {
@@ -74,65 +75,100 @@ const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({
});
const centreOf = (widget: Widget | undefined): { x: number; y: number } | null => {
if (!widget) return null;
if (!widget || widget.destroyed || !widget.visible) return null;
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
};
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: Dropdown;
private rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
/**
* What a list's head reads: its count, and the way to add an item. Where the list has a tab of its
* own the count is the button that opens it – an inspector row has no room for a third control.
*/
export class ListHead extends HStack {
private readonly count: Label | Button;
readonly addButton: Dropdown;
readonly openButton: Button | null;
/** `openable` false: the head of the tab the list is open in already. */
constructor(access: () => ListAccess | null, add: (kind: NestedKind) => void, openable = true) {
super({ gap: Theme.gap, align: 'center' });
this.minHeight = Theme.rowHeight;
const now = access();
this.openButton =
openable && now?.openList
? new Button('', { variant: 'ghost', onClick: () => access()?.openList?.([]) })
: null;
this.count = this.openButton ?? new Label('', { style: 'muted', padding: Theme.fieldPadding });
this.add(this.count);
this.add(new Spacer(), 1);
this.addButton = new Dropdown('+ Item', { variant: 'ghost' });
// Built on click, not once: which kinds fit depends on the list as it is then.
this.addButton.onClick = () =>
new Menu(kindMenu(access()?.kinds ?? NESTED_KINDS, add)).openBelow(this.addButton);
this.add(this.addButton);
this.setCount(now?.value.length ?? 0);
}
setCount(count: number): void {
const text = `${count} items`;
if (this.count.text === text) return;
this.count.setText(text);
// Its width follows the number, and the spacer after it has to give or take that much.
if (this.w > 0) this.relayout();
}
}
export interface InlineListOptions {
/** Items drawn before "… N more"; none means all of them. */
limit?: number;
}
export class InlineList extends VStack {
/** The structure the rows were built for; only a change here may destroy a row being typed in. */
private signature = '';
private setters: ((item: PropertyValue) => void)[] = [];
private rowWidgets: RowWidgets[] = [];
/** Last live refresh (play mode), in ms – the same pace as the inspector's. */
private lastLiveRefresh = 0;
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
liveVariables: LiveVariables = () => null;
/** The lists drawn inside this one: each follows its own items. */
private inner: InlineList[] = [];
private innerHeads: ListHead[] = [];
private more: Widget | null = null;
/** The head drawn for this list – count, "+ Item", "Open" – wherever its builder put it. */
head: ListHead | null = null;
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);
const footer = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
this.addButton = new Dropdown('+ Item', { variant: 'ghost' });
// Built on click, not once: which kinds fit depends on the list the tab is showing.
this.addButton.onClick = () => {
const kinds = this.access()?.kinds ?? NESTED_KINDS;
new Menu(kindMenu(kinds, (kind) => this.addItem(kind))).openBelow(this.addButton);
};
footer.add(this.addButton);
footer.add(new Spacer(), 1);
this.add(footer);
state.on('list', () => this.rebuild());
state.on('load', () => this.rebuild());
state.on('project', () => this.refresh());
state.on('runtime', () => this.refreshLive());
this.rebuild();
constructor(
private readonly ctx: FieldContext,
readonly access: () => ListAccess | null,
private readonly options: InlineListOptions = {},
) {
super({ gap: Theme.gap });
this.build();
}
/** Play mode: follow a list the behaviors fill, ~10× a second, but never under a field being typed in. */
private refreshLive(): void {
const now = performance.now();
if (now - this.lastLiveRefresh < 100) return;
if (ui().focus.isWithin(this)) return;
this.lastLiveRefresh = now;
this.refresh();
/** The title of the list this draws – what the check scripts find it by. */
get title(): string {
return this.access()?.title ?? '';
}
/** Adds an item of `kind` at the end – the head's `+ Item`. */
addItem(kind: NestedKind): void {
const access = this.access();
if (access) access.replace([...access.value, makeItem(kind, undefined)]);
}
/** Re-reads the list: new values go into the rows, a new structure builds them again. */
refresh(): void {
const access = this.access();
if (this.signatureOf(access) !== this.signature) {
// A rebuild throws away the field being typed in; the next refresh after it is left does it.
if (ui().focus.isWithin(this)) return;
this.build();
this.scrollArea()?.relayoutContent();
return;
}
if (!access) return;
this.setters.forEach((set, i) => set(access.value[i] ?? ''));
this.inner.forEach((list) => list.refresh());
this.innerHeads.forEach((head, i) => head.setCount(this.inner[i]?.access()?.value.length ?? 0));
}
/** For the check scripts: where an item's widget sits, since a number field carries no findable text. */
@@ -157,13 +193,27 @@ export class ListEditor extends VStack {
return centreOf(this.rowWidgets[index]?.names.get(field));
}
/** For the check scripts: what the tab shows and how its rows read right now. */
debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[]; rows: number } {
/** For the check scripts: the "… N more" line, which opens the list in its tab. */
moreAt(): { x: number; y: number } | null {
return centreOf(this.more ?? undefined);
}
/** For the check scripts: what the list holds and how much of it the last build drew. */
debugInfo(): {
title: string;
items: string[];
kinds: NestedKind[];
offers: NestedKind[];
rows: number;
more: number;
} {
const access = this.access();
if (!access) return { title: '', items: [], kinds: [], offers: [], rows: this.rowWidgets.length };
const base = { rows: this.rowWidgets.length, more: 0 };
if (!access) return { ...base, title: '', items: [], kinds: [], offers: [] };
return {
...base,
// Drawn, not read: `items` re-reads the list, `rows` is what the last build put on screen.
rows: this.rowWidgets.length,
more: shownItems(access.value.length, this.options.limit).more,
title: access.title,
items: access.value.map((item) => textOf(item)),
kinds: access.value.map(itemKindOf),
@@ -171,52 +221,52 @@ export class ListEditor extends VStack {
};
}
private access(): ListAccess | null {
const slot = this.state.listSlot;
return slot ? resolveList(this.state, slot, this.liveVariables) : null;
}
/** 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 {
if (!access) return '';
const shape = access.value.map(shapeOf).join(',');
const { shown } = shownItems(access.value.length, this.options.limit);
const shape = access.value.slice(0, shown).map(shapeOf).join(',');
return `${access.title}|${shape}|${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] ?? ''));
private scrollArea(): ScrollContainer | null {
for (let parent = this.parent; parent; parent = parent.parent)
if (parent instanceof ScrollContainer) return parent;
return null;
}
private rebuild(): void {
private build(): 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);
for (const child of [...this.children]) child.destroy({ children: true });
this.setters = [];
this.rowWidgets = [];
this.title.setText(access ? access.title : 'No list open');
this.count.setText(access ? `${access.value.length} items` : '');
this.addButton.visible = access !== null;
if (!access) {
this.rows.add(new Label('Open a list from its row in the inspector.', { style: 'muted' }));
} else {
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.inner = [];
this.innerHeads = [];
this.more = null;
if (access) {
const { shown, more } = shownItems(access.value.length, this.options.limit);
access.value.slice(0, shown).forEach((item, index) => this.itemRow(access, item, index));
if (more > 0) this.add(this.moreLine(access, more));
}
this.scroll.relayoutContent();
this.relayout();
}
private itemRow(access: ListAccess, item: ListValue[number], index: number): HStack {
/** The line under a cut-off list: how much is missing, and – where the list has a tab – the way there. */
private moreLine(access: ListAccess, more: number): Widget {
const text = `… ${more} more`;
const open = access.openList;
this.more = open
? new Button(`${text} – open in tab`, { variant: 'ghost', onClick: () => open([]) })
: new Label(text, { style: 'muted' });
const line = new HStack({ gap: Theme.gap, align: 'center' });
line.add(new Spacer(INDEX_WIDTH));
line.add(this.more);
line.add(new Spacer(), 1);
return line;
}
private itemRow(access: ListAccess, item: ListValue[number], index: number): void {
const kind = itemKindOf(item);
// A struct item is a stack of field rows: its row grows with it and aligns to the top.
const tall = kind === 'struct';
@@ -229,8 +279,15 @@ export class ListEditor extends VStack {
const mark = this.kindPicker(access, [index], kind);
if (tall) mark.minHeight = Theme.rowHeight;
row.add(mark);
let below: Widget | null = null;
if (tall) this.addStructItem(access, row, mark, index);
else {
else if (kind === 'list') {
const nested = this.nestedList(access, [index]);
row.add(nested.head, 1);
below = nested.list;
this.setters.push(() => {});
this.rowWidgets.push(rowWidgets(mark, nested.head));
} else {
const field = this.valueField(access, [index], kind, item);
addValue(row, field);
this.setters.push((next) => field.setValue(untag(next) ?? ''));
@@ -240,7 +297,19 @@ export class ListEditor extends VStack {
row.add(remove);
const widgets = this.rowWidgets[index];
if (widgets) widgets.remove = remove;
return row;
this.add(row);
if (below) this.add(indented(below, INDEX_WIDTH + Theme.gap));
}
/** A list inside this one: its head goes where a value would, its items below, one step further in. */
private nestedList(access: ListAccess, path: ValuePath): { head: ListHead; list: InlineList } {
const inner = () => access.sub(path);
const list = new InlineList(this.ctx, inner, this.options);
const head = new ListHead(inner, (kind) => list.addItem(kind));
list.head = head;
this.inner.push(list);
this.innerHeads.push(head);
return { head, list };
}
/** The mark of the value at `path`, offering the other kinds. Through `untag`, so a color keeps its number when it becomes one. */
@@ -254,19 +323,14 @@ export class ListEditor extends VStack {
);
}
/** The widget of a value that is not a struct: a list is its item count, opening it one level down. */
/** The widget of a value that is neither a struct nor a list. */
private valueField(
access: ListAccess,
path: ValuePath,
kind: NestedKind,
value: PropertyValue | undefined,
): Field {
) {
const key = String(path.at(-1));
if (kind === 'list')
return createField(this.ctx, { key, label: key, kind: 'list', default: [] }, value ?? [], {
...READ_ONLY,
openList: () => access.openList(path),
});
return createField(
this.ctx,
flatDef(key, kind as FieldKind),
@@ -290,8 +354,8 @@ export class ListEditor extends VStack {
/**
* The fields of the struct at `path`, a line each; a field that is a struct again gets its own
* lines below, indented one step further (#239). Check scripts find a nested field by its keys
* joined with dots (`loot.count`).
* lines below, indented one step further (#239), and so does the item list of a list field. Check
* scripts find a nested field by its keys joined with dots (`loot.count`).
*/
private addStructFields(
access: ListAccess,
@@ -313,8 +377,14 @@ export class ListEditor extends VStack {
});
line.add(fieldMark);
line.add(name);
let below: Widget | null = null;
if (field.kind === 'struct') line.add(new Spacer(), 1);
else {
else if (field.kind === 'list') {
const nested = this.nestedList(access, fieldPath);
line.add(nested.head, 1);
below = nested.list;
widgets.parts.set(id, nested.head);
} 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) });
@@ -324,6 +394,8 @@ export class ListEditor extends VStack {
widgets.names.set(id, name);
line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key))));
stack.add(line);
// Items under the field's own line: their index column is the step in.
if (below) stack.add(indented(below, depth * (FIELD_INDENT + Theme.gap)));
if (field.kind === 'struct') this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves);
}
const foot = indentedLine(depth);
@@ -343,11 +415,6 @@ export class ListEditor extends VStack {
const next = withValueAt(access.value, path, value);
if (isList(next)) access.replace(next);
}
private addItem(kind: NestedKind): void {
const access = this.access();
if (access) access.replace([...access.value, makeItem(kind, undefined)]);
}
}
/** One value of a struct item's tree: where it sits inside the item, and how its widget is set. */
@@ -356,6 +423,14 @@ interface Leaf {
set: (value: PropertyValue) => void;
}
/** A widget pushed `by` pixels right, taking the rest of the width. */
function indented(widget: Widget, by: number): HStack {
const line = new HStack({ gap: 0 });
if (by > 0) line.add(new Spacer(by));
line.add(widget, 1);
return line;
}
/** 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' });
+29 -9
View File
@@ -1,6 +1,7 @@
/**
* 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 slot with a `path` still writes the whole value.
* Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so an
* inline list edit gets the same command, coercion and undo step. A slot with a `path` still writes
* the whole value.
*/
import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
import { withoutObjects } from '../../core/graph/liveObjects';
@@ -26,6 +27,7 @@ import { currentProps, currentRef } from '../inspector/objectQueries';
import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers';
import { assetValue, currentNode, nodeParamValue, overrideValue } from '../inspector/valueSpecs';
import type { EditorState, ListSlot } from '../state';
import { deeperSlot } from '../variables/target';
export type ListDef = Extract<PropertyDef, { kind: 'list' }>;
@@ -45,8 +47,13 @@ export interface ListAccess {
* 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;
/**
* Opens a Variables tab on a list inside this one, `path` leading from this list to it – `[]` is
* this list. None for a node's list: that one is edited inline only.
*/
openList?: (path: ValuePath) => void;
/** A list inside this one, drawn inline one step further in. */
sub(path: ValuePath): ListAccess | null;
/** A whole new list in one command – adding, deleting, and the bulk text field. */
replace(next: ListValue): void;
}
@@ -79,10 +86,16 @@ const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ?
/** The owner's own list, or – with a `path` – a list nested inside its value. */
function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot): ListAccess | null {
const path = slot.path ?? [];
const open = (inner: ValuePath) => state.openList({ ...slot, path: [...path, ...inner] });
const nested: Nested = {
open:
slot.kind === 'node'
? undefined
: (inner) => state.openValues({ kind: 'list', slot: deeperSlot(slot, inner) }),
sub: (inner) => listIn(state, owner, deeperSlot(slot, inner)),
};
if (path.length === 0)
return owner.def.kind === 'list'
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, open)
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, nested)
: null;
// A struct is brought into its declared shape first, so a declared list not stored yet is there.
const def = owner.def;
@@ -112,10 +125,16 @@ function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot
remove: owner.spec.remove,
commit: (list) => owner.spec.commit(wholeWith(owner.spec.target(), list)),
},
open,
nested,
);
}
/** How a list reaches the lists inside it: in a tab of their own, and inline. */
interface Nested {
open: ((path: ValuePath) => void) | undefined;
sub: (path: ValuePath) => ListAccess | null;
}
/**
* `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so
* the next keystroke has to build on what is in there now, and the commit on what the restore
@@ -127,7 +146,7 @@ function access<Target>(
title: string,
current: () => ListValue,
spec: Omit<PreviewedValue<Target>, 'toModel'>,
open: (path: ValuePath) => void,
nested: Nested,
): ListAccess {
// The value keeps the kind it has: a color item written from its field stays a color.
const handlersAt = (path: ValuePath): FieldHandlers =>
@@ -150,7 +169,8 @@ function access<Target>(
},
itemHandlers: (index) => handlersAt([index]),
handlersAt,
openList: open,
openList: nested.open,
sub: nested.sub,
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
// A preview does not, which is why `toModel` above has to.
replace: (next) => state.execute(spec.commit(next)),
+1 -1
View File
@@ -56,7 +56,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
vectorart,
};
/** The List tab's: it edits a list value, which is no asset. */
/** A Variables tab showing one list (#252): a list value is no asset. */
export const LIST_ICON_SVG = list;
/** A group's row in the hierarchy (#241): a folder, as it only sorts what is listed. */
+1 -1
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- Three bullet points: the List tab edits a list value, not an asset. -->
<!-- Three bullet points: a Variables tab showing one list value, not an asset. -->
<circle cx="-5" cy="-4.5" r="1.25" fill="currentColor" />
<circle cx="-5" cy="0" r="1.25" fill="currentColor" />
<circle cx="-5" cy="4.5" r="1.25" fill="currentColor" />

Before

Width:  |  Height:  |  Size: 643 B

After

Width:  |  Height:  |  Size: 650 B

+21 -33
View File
@@ -57,11 +57,10 @@ export type Selection =
| null;
/**
* 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.
* `path` leads to a list nested inside the value `key` names (#239): a key per struct field, an
* 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.
* Where a list lives. 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. `path` leads to a
* list nested inside the value `key` names (#239): a key per struct field, an index per list item –
* `[3, 'drops']` is the list in field `drops` of item 3.
*/
export type ListSlot = { path?: ValuePath } & (
| { kind: 'asset'; assetId: string; key: string }
@@ -69,20 +68,12 @@ export type ListSlot = { path?: ValuePath } & (
| { kind: 'node'; assetId: string; nodeId: string; key: string }
);
function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean {
if (!a || !b) return a === b;
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) {
case 'asset':
return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key;
case 'object':
return b.kind === 'object' && b.holderId === a.holderId && b.objectId === a.objectId && b.key === a.key;
case 'node':
return b.kind === 'node' && b.assetId === a.assetId && b.nodeId === a.nodeId && b.key === a.key;
}
}
/** Whose variables a Variables tab shows (#252): a behavior or Variables asset, or a placed behavior. */
export type VariablesOwner =
{ kind: 'asset'; assetId: string } | { kind: 'object'; holderId: string; objectId: string };
/** What a Variables tab shows: every variable of an owner, or one list at any depth. */
export type ValuesTarget = { kind: 'variables'; owner: VariablesOwner } | { kind: 'list'; slot: ListSlot };
/** Builds an object selection; the last id is the primary one unless given explicitly. */
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
@@ -188,8 +179,8 @@ export type EditorEvent =
/** A different project was loaded. */
| 'load'
| 'selection'
/** The List tab was pointed at another list (or at none). */
| 'list'
/** A Variables tab was asked for (`openedValues`). */
| 'values'
/** An asset was opened (double-click, Enter, or just created): its tab should show. */
| 'open'
/** An asset should light up where it is listed (`pingedAssetId`), without being selected. */
@@ -214,7 +205,8 @@ export class EditorState {
revision = 0;
commands = new CommandStack(this.project);
selection: Selection = null;
listSlot: ListSlot | null = null;
/** The last target asked for by `openValues`; the tabs themselves are the editor's. */
openedValues: ValuesTarget | null = null;
mode: EditorMode = 'edit';
/**
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
@@ -265,7 +257,7 @@ export class EditorState {
this.revision = revision;
this.commands = new CommandStack(project);
this.selection = null;
this.listSlot = null;
this.openedValues = null;
this.clipboard = null;
this.saveStatus = 'saved';
this.emit('load');
@@ -426,22 +418,18 @@ export class EditorState {
}
/**
* Points the List tab at a list. No tab knows the `Editor`, so this is how the button on a
* list row gets there: the state changes, the editor switches.
* Asks for a Variables tab on `target` – a new one, or the one already showing it. No tab knows
* the `Editor`, so this is how the button on a list or a VARIABLES header gets there.
*/
openList(slot: ListSlot | null): void {
if (sameSlot(this.listSlot, slot)) {
if (slot) this.emit('list');
return;
}
this.listSlot = slot;
this.emit('list');
openValues(target: ValuesTarget): void {
this.openedValues = target;
this.emit('values');
}
/**
* Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the
* Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets
* there, and like `openList` it works without knowing the `Editor`.
* there, and like `openValues` it works without knowing the `Editor`.
*/
openAsset(assetId: string): void {
this.select({ kind: 'asset', assetId });
+189
View File
@@ -0,0 +1,189 @@
/**
* The Variables tab (#252): every variable of a behavior, a Variables asset or a placed behavior,
* with the very rows the inspector draws for them – or one list, as deep inside them as it sits.
* One editor for every such tab, pointed at the target of the one showing, like the asset editors.
*/
import type { Container } from 'pixi.js';
import { findObject } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
import type { InspectorHost } from '../inspector/host';
import { viewOf } from '../inspector/objectQueries';
import { buildOwnRows, objectSignature, refreshObjectSection } from '../inspector/objectSection';
import { InlineList, ListHead } from '../list/InlineList';
import { type LiveVariables, resolveList } from '../list/slot';
import type { EditorState, ValuesTarget } from '../state';
/** The asset or placed object whose variables `target` shows, when it still has any to show. */
function ownerOf(state: EditorState, target: Extract<ValuesTarget, { kind: 'variables' }>) {
const { owner } = target;
if (owner.kind === 'asset') {
const asset = findAsset(state.project, owner.assetId);
return asset?.type === 'behavior' || asset?.type === 'variables' ? { asset, name: asset.name } : null;
}
const view = viewOf(state.project, owner.holderId);
const object = view && findObject(view, owner.objectId);
const asset = object && findAsset(state.project, object.assetId);
return object && asset?.type === 'behavior' ? { asset, name: object.name || asset.name } : null;
}
/** What the tab of `target` says, or null once there is nothing left to show – the tab closes then. */
export function valuesTitle(state: EditorState, target: ValuesTarget, live?: LiveVariables): string | null {
if (target.kind === 'list') return resolveList(state, target.slot, live)?.title ?? null;
const owner = ownerOf(state, target);
return owner ? `${owner.name} · Variables` : null;
}
export class VariablesEditor extends VStack {
private readonly title = new Label('', { style: 'bodyBold' });
private readonly scroll = new ScrollContainer();
private host: InspectorHost;
private target: ValuesTarget | null = null;
private signature = '';
private lastLiveRefresh = 0;
private rebuildWhenLeft = false;
private readonly releaseFocusHandler: () => void;
/** The list a list target draws, and its head. */
private list: { head: ListHead; items: InlineList } | null = null;
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
liveVariables: LiveVariables = () => null;
constructor(private readonly state: EditorState) {
super({ gap: 0 });
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + 2 * Theme.padding;
header.add(this.title, 1);
this.add(header);
this.host = this.emptyHost();
this.scroll.setContent(this.host.body);
this.add(this.scroll, 1);
state.on('project', () => this.refresh());
state.on('runtime', () => this.refreshLive());
this.releaseFocusHandler = ui().focus.onFocusChange(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
});
}
/** Signing out destroys the tab while the page stays up (#177), so the handler goes with it. */
override destroy(options?: Parameters<VStack['destroy']>[0]): void {
this.releaseFocusHandler();
super.destroy(options);
}
/** Points the tab at another target; the one it showed keeps nothing, every edit is in the model. */
show(target: ValuesTarget): void {
this.target = target;
this.rebuild();
}
/** For the check scripts: what the tab shows, and the lists drawn in it. */
debugInfo(): { title: string; target: ValuesTarget | null; rows: string[] } {
return {
title: this.title.text,
target: this.target,
rows: [...this.host.fields.keys()],
};
}
/** Every list drawn in the tab, at any depth. */
lists(): InlineList[] {
return inlineListsIn(this.host.body);
}
private emptyHost(): InspectorHost {
return {
state: this.state,
project: () => this.state.project,
liveVariables: (assetId) => this.liveVariables(assetId),
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(),
nameField: null,
};
}
private currentSignature(): string {
const target = this.target;
if (!target) return '';
if (target.kind === 'list') return resolveList(this.state, target.slot) ? 'list' : 'missing';
const { owner } = target;
if (!ownerOf(this.state, target)) return 'missing';
return owner.kind === 'asset'
? assetSignature(this.state.project, owner.assetId)
: objectSignature(this.state, owner.holderId, [owner.objectId], owner.objectId);
}
private rebuild(): void {
this.rebuildWhenLeft = false;
this.host.body.destroy({ children: true });
this.host = this.emptyHost();
this.list = null;
this.signature = this.currentSignature();
const target = this.target;
this.title.setText((target && valuesTitle(this.state, target, this.liveVariables)) ?? '');
if (target?.kind === 'list') this.buildList(target.slot);
else if (target) {
const owner = ownerOf(this.state, target);
if (owner && target.owner.kind === 'asset') buildVariables(this.host, owner.asset, true);
else if (target.owner.kind === 'object')
buildOwnRows(this.host, target.owner.holderId, target.owner.objectId, true);
}
this.scroll.setContent(this.host.body);
}
private buildList(slot: Extract<ValuesTarget, { kind: 'list' }>['slot']): void {
const access = () => resolveList(this.state, slot, this.liveVariables);
const items = new InlineList(this.host, access);
const head = new ListHead(access, (kind) => items.addItem(kind), false);
items.head = head;
this.host.body.add(head);
this.host.body.add(items);
this.list = { head, items };
}
/** Play mode: follow what the behaviors write, ~10× a second, but never under a field being typed in. */
private refreshLive(): void {
if (!this.visible) return;
const now = performance.now();
if (now - this.lastLiveRefresh < 100) return;
if (ui().focus.isWithin(this)) return;
this.lastLiveRefresh = now;
this.refresh();
}
private refresh(): void {
const target = this.target;
if (!target) return;
this.title.setText(valuesTitle(this.state, target, this.liveVariables) ?? '');
if (this.currentSignature() !== this.signature) {
if (ui().focus.isWithin(this)) {
this.rebuildWhenLeft = true;
return;
}
this.rebuild();
return;
}
if (target.kind === 'list') {
this.list?.head.setCount(this.list.items.access()?.value.length ?? 0);
this.list?.items.refresh();
return;
}
const { owner } = target;
const ok =
owner.kind === 'asset'
? refreshAssetSection(this.host, owner.assetId)
: refreshObjectSection(this.host, owner.holderId, owner.objectId);
if (!ok) this.rebuild();
}
}
/** Every inline list below `root`, outermost first – what the check scripts pick one from. */
export function inlineListsIn(root: Container): InlineList[] {
const found: InlineList[] = [];
const walk = (node: Container): void => {
if (node instanceof InlineList) found.push(node);
for (const child of node.children) walk(child);
};
walk(root);
return found;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* The rules of the Variables tab (#252) that need no widget: which document a target is, and how
* much of a list is drawn inline.
*/
import type { ListSlot, ValuesTarget, VariablesOwner } from '../state';
/**
* A key that is the same for the same target however it was built – the document id of its tab,
* so asking twice for one list brings up its tab instead of opening a second.
*/
export function valuesKey(target: ValuesTarget): string {
if (target.kind === 'variables') return `vars|${ownerKey(target.owner)}`;
return `list|${slotKey(target.slot)}`;
}
function ownerKey(owner: VariablesOwner): string {
return owner.kind === 'asset' ? `asset|${owner.assetId}` : `object|${owner.holderId}|${owner.objectId}`;
}
function slotKey(slot: ListSlot): string {
const where =
slot.kind === 'asset'
? `asset|${slot.assetId}`
: slot.kind === 'object'
? `object|${slot.holderId}|${slot.objectId}`
: `node|${slot.assetId}|${slot.nodeId}`;
return `${where}|${JSON.stringify([slot.key, ...(slot.path ?? [])])}`;
}
/** The list one step further down: `inner` leads from the slot's list to the one inside it. */
export function deeperSlot(slot: ListSlot, inner: ListSlot['path']): ListSlot {
return { ...slot, path: [...(slot.path ?? []), ...(inner ?? [])] };
}
/** How many items the inspector draws of a list before it says "… N more". */
export const INLINE_LIMIT = 20;
/** The items drawn and the ones left out: all of them without a limit. */
export function shownItems(count: number, limit = Infinity): { shown: number; more: number } {
const shown = Math.min(count, Math.max(0, limit));
return { shown, more: count - shown };
}