diff --git a/README.md b/README.md index 6221da63..c254f5bd 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ Live: · Widget playground: (); + while (current?.parentId && !seen.has(current.id)) { + seen.add(current.id); + const parent = findObject(tree, current.parentId); + if (!parent || seen.has(parent.id)) break; + result.push(parent.id); + current = parent; + } + return result; +} + /** Whether `ancestorId` is `objectId` itself or one of its ancestors (guards against cycles). */ export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean { let current: SceneObject | undefined = findObject(tree, objectId); @@ -114,3 +129,31 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] { } return result; } + +export interface VisibleEntry extends TreeEntry { + /** Whether the object has children at all – only those rows offer a expand/collapse chevron. */ + hasChildren: boolean; +} + +/** + * Tree order with the subtrees of collapsed objects left out: a row is shown when *every* + * ancestor is expanded. Expansion is a view state (which ids are open), never stored in the + * project – anything not listed counts as collapsed, so a freshly loaded scene shows only its + * top level. + */ +export function visibleTreeOrder(tree: ObjectTree, expanded: ReadonlySet): VisibleEntry[] { + const parents = new Set(tree.objects.map((o) => o.parentId).filter(Boolean)); + const result: VisibleEntry[] = []; + // Depth of the collapsed row we are currently skipping the subtree of (-1 = showing rows). + let collapsedAt = -1; + for (const entry of treeOrder(tree)) { + if (collapsedAt >= 0) { + if (entry.depth > collapsedAt) continue; + collapsedAt = -1; + } + const hasChildren = parents.has(entry.object.id); + result.push({ ...entry, hasChildren }); + if (hasChildren && !expanded.has(entry.object.id)) collapsedAt = entry.depth; + } + return result; +} diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts index 9858336b..9eba2894 100644 --- a/src/core/properties/resolve.ts +++ b/src/core/properties/resolve.ts @@ -48,3 +48,22 @@ export function isModified(asset: Asset, key: string): boolean { export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined { return resolveAssetProps(asset)[key]; } + +/** + * Whether the object's value for `key` really differs from the asset's – an override that + * happens to carry the asset's own value is none. The inspector offers "reset"/"apply" only + * where they would change something, so this is what decides whether the buttons appear. + */ +export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string): boolean { + const own = object.overrides[key]; + return own !== undefined && own !== inheritedValue(asset, key); +} + +/** + * Whether the object's override for `key` differs from the template it was instantiated from. + * Compared as overrides, not as effective values: both sides resolve against the same asset, + * so "neither overrides" means equal even when the asset value changed since. + */ +export function deviatesFromTemplate(object: SceneObject, template: SceneObject, key: string): boolean { + return object.overrides[key] !== template.overrides[key]; +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 27298724..4bbc55cd 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -81,7 +81,11 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor): }, }, world: () => editor.worldSnapshot(), + /** Rows the hierarchy shows – collapsed subtrees are not in here. */ hierarchyOrder: () => editor.hierarchy.rowOrder(), + hierarchyChevron: (id: string) => editor.hierarchy.chevronPosition(id), + hierarchyExpandAll: () => editor.hierarchy.expandAll(), + hierarchyCollapseAll: () => editor.hierarchy.collapseAll(), fps: () => editor.fps, tab: () => editor.tab, showTab: (tab: CentreTab) => editor.showTab(tab), diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 72ecb91f..7ba5b23a 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -14,29 +14,36 @@ export interface FieldRow { /** Push a model value into the widget (no events fired). */ setValue(value: PropertyValue): void; /** - * Highlight the row as deviating from its base value and show the reset button: - * an override on scene objects, a non-default value on assets. + * Highlight the row as deviating from its base value (an override on scene objects, a + * non-default value on assets) and re-evaluate which actions currently apply. */ setModified(modified: boolean): void; } export const FIELD_LABEL_WIDTH = 64; -/** One way of writing a changed value back to its source ("Apply to asset", "Apply to container"). */ -export interface ApplyAction { +/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */ +export interface FieldAction { label: string; onSelect: () => void; } +/** + * The actions available *right now* – asked again on every refresh and on every click. + * Anything that would not change the value is left out, so a row never offers a dead entry. + */ +export type FieldActions = () => FieldAction[]; + /** * Maps one PropertySchema entry to a labelled row containing the matching widget. * The inspector is entirely generated through this function: adding a new property * kind means adding a case here, adding a property means adding a schema line. * - * A modified row shows its actions at the right: ↺ resets to the base value, ↑ applies the - * value to its source – directly with one action, via a menu (↑ ▾) with several. + * A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to + * its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry + * means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾). */ -export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, onReset?: () => void, apply?: ApplyAction[]): FieldRow { +export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions): FieldRow { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; const label = new Label(def.label, { style: 'muted' }); @@ -47,32 +54,50 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: row.add(field.widget, field.grow ? 1 : 0); if (!field.grow) row.add(new Label(''), 1); - const actions: Button[] = []; - if (apply && apply.length > 0) { - const single = apply.length === 1; - const button = new Button(single ? '↑' : '↑ ▾', { variant: 'ghost', width: single ? Theme.controlHeight : undefined }); + const actions: { button: Button; glyph: string; available: FieldActions }[] = []; + const addActions = (glyph: string, available: FieldActions): void => { + const button = new Button(glyph, { variant: 'ghost', width: Theme.controlHeight }); + // Asked again on click: the menu must not act on a state that has moved on since the + // last refresh (an undo between refreshes, a value edited in the viewport). button.onClick = () => { - if (single) return apply[0]!.onSelect(); + const items = available(); + if (items.length === 0) return; + if (items.length === 1) return items[0]!.onSelect(); const g = button.getGlobalPosition(); - new Menu(apply.map((a) => ({ label: a.label, onSelect: a.onSelect }))).open(g.x, g.y + button.h + 2); + new Menu(items.map((a) => ({ label: a.label, onSelect: a.onSelect }))).open(g.x, g.y + button.h + 2); }; - actions.push(button); - } - if (onReset) actions.push(new Button('↺', { variant: 'ghost', width: Theme.controlHeight, onClick: onReset })); - for (const button of actions) { button.visible = false; + actions.push({ button, glyph, available }); row.add(button); - } + }; + if (apply) addActions('↑', apply); + if (reset) addActions('↺', reset); + + const refreshActions = (): void => { + let changed = false; + for (const { button, glyph, available } of actions) { + const count = available().length; + const text = count > 1 ? `${glyph} ▾` : glyph; + const visible = count > 0; + if (button.visible !== visible) { + button.visible = visible; + changed = true; + } + if (visible && button.text !== text) { + button.setText(text); + changed = true; + } + } + if (changed) row.relayout(); + }; + refreshActions(); return { row, setValue: field.setValue, setModified(modified) { label.setStyle(modified ? 'bodyBold' : 'muted'); - if (actions.length > 0 && actions[0]!.visible !== modified) { - for (const button of actions) button.visible = modified; - row.relayout(); - } + refreshActions(); }, }; } diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 9be33d3e..fb198f00 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -1,6 +1,6 @@ import { AddColliderCommand } from '../../core/commands/colliderCommands'; import { SetParentCommand } from '../../core/commands/sceneObjectCommands'; -import { treeOrder } from '../../core/model/hierarchy'; +import { ancestorIdsOf, visibleTreeOrder } from '../../core/model/hierarchy'; import { findAsset } from '../../core/model/Project'; import { resolveSceneObjectProps } from '../../core/properties/resolve'; import { ListView, Menu, Panel, ScrollContainer } from '../../ui'; @@ -12,10 +12,16 @@ import { type EditorState, objectSelection } from '../state'; * Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto * another to parent it there; drop it on the empty space below the rows to move it to the * top level. The object keeps its world position either way. + * + * Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them. + * Everything starts collapsed – which ids are open is view state kept here, never part of the + * project, and selecting an object always unfolds the way down to it. */ export class HierarchyPanel extends Panel { private readonly list = new ListView(); private readonly scroll = new ScrollContainer(); + /** Ids of the objects whose children are shown. Anything missing is collapsed. */ + private readonly expanded = new Set(); constructor(private readonly state: EditorState) { super({ title: 'Scene objects', padding: 0, gap: 0 }); @@ -32,8 +38,12 @@ export class HierarchyPanel extends Panel { if (!scene) return; const object = scene.objects.find((o) => o.id === draggedId); if (!object || object.parentId === (targetId ?? '')) return; + // Unfold the new parent, otherwise the dragged row would seem to vanish on drop. + if (targetId) this.expanded.add(targetId); state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? '')); }; + this.list.onToggle = (id, expand) => this.setExpanded(id, expand); + this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id)); this.list.onContextMenu = (id, x, y) => { const scene = state.activeScene; if (!scene) return; @@ -54,6 +64,8 @@ export class HierarchyPanel extends Panel { }, { label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) }, { label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) }, + { label: 'Expand all', onSelect: () => this.expandAll() }, + { label: 'Collapse all', onSelect: () => this.collapseAll() }, { label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) }, ]).open(x, y); }; @@ -61,22 +73,62 @@ export class HierarchyPanel extends Panel { this.content.add(this.scroll, 1); state.on('project', () => this.refresh()); - state.on('load', () => this.refresh()); - state.on('selection', () => this.syncSelection()); + // A different project brings different objects – start folded again. + state.on('load', () => { this.expanded.clear(); this.refresh(); }); + state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection())); this.refresh(); } - /** Ids in the order the rows are shown (test hook). */ + /** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */ rowOrder(): string[] { const scene = this.state.activeScene; - return scene ? treeOrder(scene).map((e) => e.object.id) : []; + return scene ? visibleTreeOrder(scene, this.expanded).map((e) => e.object.id) : []; + } + + /** Global centre of a row's chevron, so tests can click the real hit area (test hook). */ + chevronPosition(id: string): { x: number; y: number } | null { + return this.list.chevronGlobal(id); + } + + expandAll(): void { + const scene = this.state.activeScene; + for (const object of scene?.objects ?? []) this.expanded.add(object.id); + this.refresh(); + } + + collapseAll(): void { + this.expanded.clear(); + // Keep the selected object reachable instead of folding it away under the user's hands. + this.revealSelection(); + this.refresh(); + } + + private setExpanded(id: string, expand: boolean): void { + if (expand) this.expanded.add(id); + else this.expanded.delete(id); + this.refresh(); + } + + /** Unfolds the ancestors of every selected object. Returns whether that changed anything. */ + private revealSelection(): boolean { + const scene = this.state.activeScene; + if (!scene) return false; + let changed = false; + for (const id of this.state.selectedObjectIds) { + for (const ancestorId of ancestorIdsOf(scene, id)) { + if (this.expanded.has(ancestorId)) continue; + this.expanded.add(ancestorId); + changed = true; + } + } + return changed; } private refresh(): void { const scene = this.state.activeScene; this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects'); this.list.setItems( - (scene ? treeOrder(scene) : []).map(({ object, depth }) => { + (scene ? visibleTreeOrder(scene, this.expanded) : []).map(({ object, depth, hasChildren }) => { const asset = findAsset(this.state.project, object.assetId); const props = asset ? resolveSceneObjectProps(asset, object) : {}; return { @@ -85,11 +137,13 @@ export class HierarchyPanel extends Panel { hint: asset?.type ?? '', depth, swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined, + expanded: hasChildren ? this.expanded.has(object.id) : undefined, }; }), ); this.syncSelection(); this.scroll.relayoutContent(); + this.scrollToSelection(); } private syncSelection(): void { @@ -97,4 +151,12 @@ export class HierarchyPanel extends Panel { this.list.select(s?.kind === 'object' ? s.objectId : null, true); this.list.highlight(s?.kind === 'object' ? s.objectIds : []); } + + /** Keeps the primary selection in view after unfolding brought it back into the list. */ + private scrollToSelection(): void { + const s = this.state.selection; + if (s?.kind !== 'object' || !s.objectId) return; + const index = this.rowOrder().indexOf(s.objectId); + if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight); + } } diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts index c586d74d..6284d036 100644 --- a/src/editor/panels/Inspector.ts +++ b/src/editor/panels/Inspector.ts @@ -26,7 +26,7 @@ import type { Position, PropertyValue } from '../../core/model/Properties'; import { findAsset, findScene, findSceneObject } from '../../core/model/Project'; import type { SceneObject } from '../../core/model/SceneObject'; import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema'; -import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve'; +import { deviatesFromAsset, deviatesFromTemplate, isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve'; import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui'; import { createContainer, removeObjects } from '../actions'; import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory'; @@ -148,7 +148,11 @@ export class Inspector extends Panel { const assetId = asset.id; const values = resolveAssetProps(asset); for (const def of schema) { - const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key))); + const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => { + const target = findAsset(this.state.project, assetId); + if (!target || !isModified(target, def.key)) return []; + return [{ label: 'Reset to default', onSelect: () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)) }]; + }); row.setModified(isModified(asset, def.key)); this.fields.set(def.key, row); this.body.add(row.row); @@ -277,7 +281,7 @@ export class Inspector extends Panel { // The master switch, right where it is looked for: above everything type-specific. for (const def of COMMON_PROPS) { - this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object), templateLinkOf(this.state.project, scene, object) ?? null).row); + this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object)).row); } // Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top). @@ -306,8 +310,14 @@ export class Inspector extends Panel { this.state.execute(new MoveSceneObjectCommand(sceneId, objectId, next, previous)); }, }, - link ? () => this.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) : undefined, - link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] : undefined, + () => + this.positionMoved(sceneId, objectId, axis) + ? [{ label: 'Reset to container', onSelect: () => this.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }] + : [], + () => + this.positionMoved(sceneId, objectId, axis) + ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] + : [], ); this.fields.set(axis, row); this.body.add(row.row); @@ -321,7 +331,7 @@ export class Inspector extends Panel { const own = schema.filter((def) => !isCommonProp(def.key)); if (own.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' })); if (own.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' })); - for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values, link ?? null).row); + for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values).row); this.markObjectRows(object, asset, link); if (asset.type === 'container') { @@ -350,8 +360,14 @@ export class Inspector extends Panel { * One editable property of a scene object: writes an override, offers ↺ (back to the asset * or, inside a container, to the template) and ↑ (apply upwards). Registered in `fields` * so live refreshes and `markObjectRows` find it. + * + * Inside a container a value has two bases – the template it was instantiated from and, one + * step further up, the asset – so ↺/↑ can become a menu. Only bases the value really differs + * from are listed, so a menu never contains an entry that would change nothing. Dropping the + * override is what makes the object follow the asset again: the template is only read when + * the container is placed, never at draw time. */ - private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record, link: TemplateLink | null): FieldRow { + private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record): FieldRow { let original: PropertyValue | undefined; let hadOverride = false; const row = createFieldRow( @@ -379,11 +395,20 @@ export class Inspector extends Panel { this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)); }, }, - () => this.state.execute(link ? new RevertOverrideToTemplateCommand(sceneId, objectId, def.key) : new ClearOverrideCommand(sceneId, objectId, def.key)), - [ - ...(link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []), - { label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }, - ], + () => { + const { template, asset } = this.deviations(sceneId, objectId, def.key); + return [ + ...(template ? [{ label: 'Reset to container', onSelect: () => this.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []), + ...(asset ? [{ label: 'Reset to asset', onSelect: () => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []), + ]; + }, + () => { + const { template, asset } = this.deviations(sceneId, objectId, def.key); + return [ + ...(template ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []), + ...(asset ? [{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []), + ]; + }, ); this.fields.set(def.key, row); return row; @@ -539,21 +564,54 @@ export class Inspector extends Panel { /** * Which rows deviate from their base: for a container instance the base is the template - * (position and overrides), for any other object only overrides count as a deviation. + * (position and overrides), for any other object the asset. Bold marks exactly what the + * ↺/↑ buttons offer – a value that matches its base is neither highlighted nor actionable. */ private markObjectRows(object: SceneObject, asset: Asset, link: TemplateLink | undefined): void { for (const axis of ['x', 'y', 'z'] as const) this.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]); for (const def of schemaForAsset(asset)) { - const modified = link ? object.overrides[def.key] !== link.template.overrides[def.key] : isOverridden(object, def.key); - this.fields.get(def.key)?.setModified(modified); + const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key); + this.fields.get(def.key)?.setModified(deviates); } } + /** + * The two bases a property of a scene object can be reset to or applied upwards, each only + * listed when it actually differs: the template of the container it belongs to (a value + * copied when the container was placed) and the asset behind it. Inside a container both + * can apply at once – then the row shows a menu, otherwise a single button, and nothing + * at all when the value matches everywhere. + */ + private deviations(sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } { + const object = this.currentObject(sceneId, objectId); + const asset = object && findAsset(this.state.project, object.assetId); + if (!object || !asset) return { template: false, asset: false }; + const link = this.linkOf(sceneId, objectId); + return { + template: Boolean(link) && deviatesFromTemplate(object, link!.template, key), + asset: deviatesFromAsset(object, asset, key), + }; + } + + /** Whether the object sits somewhere else on `axis` than the container template says. */ + private positionMoved(sceneId: string, objectId: string, axis: 'x' | 'y' | 'z'): boolean { + const object = this.currentObject(sceneId, objectId); + const link = this.linkOf(sceneId, objectId); + return Boolean(object && link) && object!.position[axis] !== link!.template.position[axis]; + } + private currentObject(sceneId: string, objectId: string) { const scene = findScene(this.state.project, sceneId); return scene && findSceneObject(scene, objectId); } + /** The object's template link as it is *now* – actions must not work off a stale snapshot. */ + private linkOf(sceneId: string, objectId: string): TemplateLink | undefined { + const scene = findScene(this.state.project, sceneId); + const object = this.currentObject(sceneId, objectId); + return scene && object ? templateLinkOf(this.state.project, scene, object) : undefined; + } + private currentNode(assetId: string, nodeId: string) { const asset = findAsset(this.state.project, assetId); return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined; diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 3a9bb3be..dca643c8 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -12,11 +12,19 @@ export interface ListItem { depth?: number; /** Optional marker color drawn as a small square before the label. */ swatch?: number; + /** + * Rows that have children: whether their subtree is currently shown. Such rows get a + * chevron that reports clicks through `onToggle`. Left undefined for leaves. + */ + expanded?: boolean; } /** Pixels the pointer must travel before a press turns into a drag. */ const DRAG_THRESHOLD = 4; +/** Width reserved in front of every row of a tree list for the expand/collapse chevron. */ +const CHEVRON_SLOT = 14; + /** Modifier keys held while a row was clicked. */ export interface PickModifiers { /** Ctrl (or ⌘) – toggle the row in a multi-selection. */ @@ -41,6 +49,11 @@ export class ListView extends Widget { onContextMenu?: (id: string, globalX: number, globalY: number) => void; /** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */ onDrop?: (draggedId: string, targetId: string | null) => void; + /** + * Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list + * a tree: every row reserves the chevron slot, so labels stay aligned across a level. + */ + onToggle?: (id: string, expand: boolean) => void; rowHeight: number = Theme.rowHeight; emptyText = 'Nothing here yet'; private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') }); @@ -74,10 +87,13 @@ export class ListView extends Widget { this.items = items; for (const row of this.rows) row.destroy({ children: true }); this.rows = items.map((item, index) => { - const row = new Row(item, this.rowHeight); + const row = new Row(item, this.rowHeight, Boolean(this.onToggle)); row.on('pointerdown', (e: FederatedPointerEvent) => { const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey }; - if (e.button === 2) { + // The chevron only folds the row – it never selects, so folding keeps the selection put. + if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) { + this.onToggle?.(item.id, !item.expanded); + } else if (e.button === 2) { // A right-click inside a multi-selection keeps it (the menu acts on all of it). if (!this.highlighted.has(item.id)) this.select(item.id); this.onContextMenu?.(item.id, e.global.x, e.global.y); @@ -117,6 +133,11 @@ export class ListView extends Widget { return [...this.highlighted]; } + /** Global centre of a row's chevron – null when the row has none (test hook). */ + chevronGlobal(id: string): { x: number; y: number } | null { + return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null; + } + override measure(): Size { return { w: this.minWidth, h: Math.max(this.minHeight, this.items.length * this.rowHeight) }; } @@ -133,6 +154,13 @@ export class ListView extends Widget { if (prev) this.select(prev.id); return true; } + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + const item = this.items[index]; + const expand = event.key === 'ArrowRight'; + if (!item || item.expanded === undefined || item.expanded === expand) return false; + this.onToggle?.(item.id, expand); + return true; + } if (event.key === 'Enter' && this.selectedId) { this.onActivate?.(this.selectedId); return true; @@ -200,7 +228,7 @@ class Row extends Widget { private selected = false; private listFocused = false; - constructor(readonly item: ListItem, height: number) { + constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) { super(); this.cursor = 'pointer'; this.minHeight = height; @@ -216,10 +244,27 @@ class Row extends Widget { this.invalidate(); } + /** Left edge of the chevron slot (or of the content itself in a flat list). */ + private get indent(): number { + return Theme.padding + (this.item.depth ?? 0) * 14; + } + + chevronGlobal(): { x: number; y: number } | null { + if (!this.chevronSlot || this.item.expanded === undefined) return null; + const p = this.getGlobalPosition(); + return { x: p.x + this.indent + CHEVRON_SLOT / 2, y: p.y + this.h / 2 }; + } + + /** Whether a click at this local x lands on the chevron rather than on the row. */ + hitsChevron(localX: number): boolean { + if (!this.chevronSlot || this.item.expanded === undefined) return false; + return localX >= this.indent - 2 && localX < this.indent + CHEVRON_SLOT; + } + override redraw(): void { const c = Theme.colors; const depth = this.item.depth ?? 0; - const indent = Theme.padding + depth * 14; + const indent = this.indent; this.bg.clear(); if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive); else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover); @@ -229,6 +274,10 @@ class Row extends Widget { this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border }); } let x = indent; + if (this.chevronSlot) { + if (this.item.expanded !== undefined) this.drawChevron(x + 4, this.h / 2, this.item.expanded); + x += CHEVRON_SLOT; + } if (this.item.swatch !== undefined) { this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch); this.bg.roundRect(x + 0.5, (this.h - 12) / 2 + 0.5, 11, 11, 2).stroke({ width: 1, color: c.border }); @@ -240,4 +289,11 @@ class Row extends Widget { this.labelText.position.set(x, Math.round((this.h - this.labelText.height) / 2)); this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), Math.round((this.h - this.hint.height) / 2)); } + + /** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */ + private drawChevron(x: number, y: number, expanded: boolean): void { + const color = this.selected ? Theme.colors.text : Theme.colors.textMuted; + if (expanded) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color); + else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color); + } } diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index 1de6d902..c5d7f4f5 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -64,6 +64,12 @@ export class ScrollContainer extends Widget { this.applyScroll(); } + /** Scrolls just far enough for the content band [top, top + height) to be inside the view. */ + ensureVisible(top: number, height: number): void { + if (top < this.scrollY) this.scrollTo(top); + else if (top + height > this.scrollY + this.h) this.scrollTo(top + height - this.h); + } + /** Re-measure the content after it changed (rows added/removed). */ relayoutContent(): void { if (!this.content) return; diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index 3c700810..5c230d4e 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -3,7 +3,7 @@ import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/ import { CommandStack } from '../../src/core/commands/CommandStack'; import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; import { GraphInterpreter, type NodeContext, worldPositionOf } from '../../src/core/graph'; -import { descendantsOf, localPositionFor, treeOrder, worldPosition } from '../../src/core/model/hierarchy'; +import { ancestorIdsOf, descendantsOf, localPositionFor, treeOrder, visibleTreeOrder, worldPosition } from '../../src/core/model/hierarchy'; import { createEmptyProject, findScene } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { World } from '../../src/runtime/World'; @@ -72,6 +72,29 @@ describe('scene hierarchy', () => { expect(descendantsOf(sceneOf(), a).map((o) => o.id)).toEqual([c, b]); }); + it('hides the subtrees of collapsed objects and marks the rows that can fold', () => { + const { stack, sceneId, a, b, c, sceneOf } = setup(); + // a > c > b: two levels, so folding a hides both descendants. + stack.execute(new SetParentCommand(sceneId, c, a)); + stack.execute(new SetParentCommand(sceneId, b, c)); + const rows = (expanded: string[]) => visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]); + + // Nothing expanded (the default when a scene is opened) – only the top level is listed. + expect(rows([])).toEqual([[a, 0, true]]); + expect(rows([a])).toEqual([[a, 0, true], [c, 1, true]]); + expect(rows([a, c])).toEqual([[a, 0, true], [c, 1, true], [b, 2, false]]); + // Expanding a nested object without its parent shows nothing extra: every ancestor must be open. + expect(rows([c])).toEqual([[a, 0, true]]); + }); + + it('lists the ancestors of an object nearest first, so a selection can be unfolded', () => { + const { stack, sceneId, a, b, c, sceneOf } = setup(); + stack.execute(new SetParentCommand(sceneId, c, a)); + stack.execute(new SetParentCommand(sceneId, b, c)); + expect(ancestorIdsOf(sceneOf(), b)).toEqual([c, a]); + expect(ancestorIdsOf(sceneOf(), a)).toEqual([]); + }); + it('removing an object or deleting an asset takes the subtree along, undo restores it', () => { const { project, stack, sceneId, spriteId, a, b, c, sceneOf } = setup(); const behavior = new CreateAssetCommand('behavior', 'Logic'); diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts index f68f1f12..26bc0916 100644 --- a/tests/core/resolve.test.ts +++ b/tests/core/resolve.test.ts @@ -2,7 +2,15 @@ import { describe, expect, it } from 'vitest'; import { buildAsset } from '../../src/core/commands/assetCommands'; import { createSceneObject } from '../../src/core/model/SceneObject'; import { coerceValue, defaultsFor, schemaFor } from '../../src/core/properties/PropertySchema'; -import { inheritedValue, isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve'; +import { + deviatesFromAsset, + deviatesFromTemplate, + inheritedValue, + isModified, + isOverridden, + resolveAssetProps, + resolveSceneObjectProps, +} from '../../src/core/properties/resolve'; describe('PropertySchema', () => { it('has no position on any asset type', () => { @@ -59,6 +67,40 @@ describe('resolve', () => { expect(isModified(sprite, 'bogus')).toBe(false); }); + it('ignores an override that carries the asset value – nothing to reset or apply', () => { + const sprite = buildAsset('sprite', 'Box'); + sprite.props.width = 250; + const object = createSceneObject('o1', sprite.id, { x: 0, y: 0 }); + expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); // no override at all + + object.overrides.width = 250; // same value as the asset → not a deviation + expect(isOverridden(object, 'width')).toBe(true); + expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); + + object.overrides.width = 300; + expect(deviatesFromAsset(object, sprite, 'width')).toBe(true); + + // The asset moving to the object's value settles the difference again. + sprite.props.width = 300; + expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); + }); + + it('compares an instance against its template as overrides, not as values', () => { + const sprite = buildAsset('sprite', 'Box'); + const template = createSceneObject('t1', sprite.id, { x: 0, y: 0 }); + const object = createSceneObject('o1', sprite.id, { x: 0, y: 0 }); + expect(deviatesFromTemplate(object, template, 'width')).toBe(false); // neither overrides + + template.overrides.width = 140; + expect(deviatesFromTemplate(object, template, 'width')).toBe(true); + + object.overrides.width = 140; + expect(deviatesFromTemplate(object, template, 'width')).toBe(false); + + object.overrides.width = 77; + expect(deviatesFromTemplate(object, template, 'width')).toBe(true); + }); + it('keeps position on the scene object only', () => { const sprite = buildAsset('sprite', 'Box'); const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 }); diff --git a/tools/e2e.mjs b/tools/e2e.mjs index 4dcb9999..2f766582 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -25,6 +25,33 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async check('page loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | ')); check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)'))); + // 0a. Hierarchy folding. A freshly opened scene is fully collapsed, so only its top-level + // objects are listed (o4/oc1 hang under o1, oc2 under o2). The chevron folds a row open + // and shut without touching the selection, and selecting a nested object unfolds the way + // down to it. + const rowIds = () => evaluate('JSON.stringify(__gm.hierarchyOrder())').then(JSON.parse); + const selectionBefore = JSON.parse(await stateJson()).selection; + check('hierarchy starts collapsed – only top-level objects are listed', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds())); + check('a leaf row offers no chevron', (await evaluate(`__gm.hierarchyChevron('o3')`)) === null); + const chevron = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyChevron('o2'))`)); + await click(chevron.x, chevron.y); + await sleep(150); + check( + 'clicking the chevron unfolds the row, leaving the selection alone', + JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'oc2', 'o3', 'o5']) && JSON.stringify(JSON.parse(await stateJson()).selection) === JSON.stringify(selectionBefore), + JSON.stringify(await rowIds()), + ); + await shot('01a-hierarchy-unfolded'); + await click(chevron.x, chevron.y); + await sleep(150); + check('clicking it again folds the subtree away', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds())); + await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' })`); + await sleep(150); + check('selecting a nested object unfolds its ancestors', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds())); + await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); + await sleep(150); + check('Collapse all folds everything back', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds())); + // 1. Select the blue crate in the hierarchy. await clickText('Blue crate'); let s = JSON.parse(await stateJson()); @@ -129,7 +156,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async await clickText('▶ Play'); await sleep(300); check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game'); - check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyOrder().length')) === 7); + check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7); await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' })`); await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400; __gm.state.preview()`); await sleep(100); @@ -155,7 +182,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async check('Spawn node adds a crate object to the runtime scene at (0, 150)', spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8, JSON.stringify(spawned)); const spawnWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())')); check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log })); - check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyOrder().length')) === 8); + check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8); info = await evaluate('JSON.stringify(__gm.gameInfo())'); check('the Game view renders the spawned crate', JSON.parse(info).visibleItems === visibleBeforeSpawn + 1, `${info} before=${visibleBeforeSpawn}`); await shot('08c-spawned'); @@ -426,7 +453,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async const objectJson = (id) => evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === '${id}'))`); let o2 = JSON.parse(await objectJson('o2')); check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2)); - const order = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyOrder())`)); + const order = JSON.parse(await evaluate(`__gm.hierarchyExpandAll(); JSON.stringify(__gm.hierarchyOrder())`)); // o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider. const o1At = order.indexOf('o1'); check('children are listed directly below their parent', JSON.stringify(order.slice(o1At + 1, o1At + 5)) === JSON.stringify(['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order)); @@ -609,7 +636,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async const o2c = JSON.parse(await objectJson('o2')); check('the crates are re-parented below the container with local positions relative to its centre', o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, JSON.stringify({ o1: o1c.position, o2: o2c.position })); check('the template holds the whole subtree (2 crates, 2 colliders, the Mover)', containerAsset?.objects.length === 5 && containerAsset.objects.filter((t) => t.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((t) => t.id === o1c.templateId), JSON.stringify(containerAsset?.objects.map((t) => [t.assetId, t.parentId === '' ? 'root' : 'child']))); - const orderC = JSON.parse(await evaluate('JSON.stringify(__gm.hierarchyOrder())')); + const orderC = JSON.parse(await evaluate('__gm.hierarchyExpandAll(); JSON.stringify(__gm.hierarchyOrder())')); const cAt = orderC.indexOf(containerObj?.id); check('hierarchy lists the container with its content indented below', cAt >= 0 && JSON.stringify(orderC.slice(cAt)) === JSON.stringify([containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC)); check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay'); @@ -715,6 +742,37 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async await sleep(200); check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && JSON.parse(await objectJson(secondCrate.id)).overrides.width === 77); + // 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases + // the value really differs from are offered, so "Reset to container" is left out and the + // menu collapses to a direct ↺. Deviating from both brings the menu back. + await sleep(200); + check( + 'a reset that would change nothing is left out – a direct button instead of a menu', + Boolean(await evaluate(`__gm.locate('↺')`)) && !(await evaluate(`__gm.locate('↺ ▾')`)) && !(await evaluate(`__gm.locate('↑ ▾')`)), + ); + await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55; __gm.state.preview(); })()`); + await sleep(200); + const resetMenu = await evaluate(`__gm.locate('↺ ▾')`); + check('deviating from template and asset brings the reset menu back', Boolean(resetMenu)); + await click(resetMenu.x, resetMenu.y); + await sleep(150); + check('the reset menu offers container and asset', Boolean(await evaluate(`__gm.locate('Reset to container')`)) && Boolean(await evaluate(`__gm.locate('Reset to asset')`))); + await shot('28b-reset-menu'); + await clickText('Reset to container'); + await sleep(200); + check('Reset to container puts the override back on the template value', JSON.parse(await objectJson('o1')).overrides.width === 77); + const singleReset = await evaluate(`__gm.locate('↺')`); + check('with only the asset left to differ from, the menu is a single button again', Boolean(singleReset) && !(await evaluate(`__gm.locate('↺ ▾')`))); + await click(singleReset.x, singleReset.y); + await sleep(200); + check('↺ drops the override so the object follows the asset again', JSON.parse(await objectJson('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77); + // Dropping the override left the object on the asset value (100) while the template still + // says 77 – that is a real deviation, so exactly one entry remains: "Reset to container". + check('the remaining deviation from the template keeps a single button, not a menu', Boolean(await evaluate(`__gm.locate('↺')`)) && !(await evaluate(`__gm.locate('↺ ▾')`))); + await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); + await sleep(200); + check('undo restores the override', JSON.parse(await objectJson('o1')).overrides.width === 77); + // 17. Text assets: created through the + menu, placed, and rendered as real text in the scene. await clickText('+'); await sleep(150); diff --git a/tools/smoke-demo.mjs b/tools/smoke-demo.mjs index f0554f3f..f6745908 100644 --- a/tools/smoke-demo.mjs +++ b/tools/smoke-demo.mjs @@ -39,7 +39,8 @@ await runChecks({ projectId, baseUrl, port: 9335, shotPrefix: 'gm-smoke' }, asyn const objs = await objects(); const roots = objs.filter((o) => o.parentId === '').map((o) => o.id); check('demo scene has the seeded objects and roots', objs.length === OBJECT_COUNT && ROOTS.every((id) => roots.includes(id)), `${objs.length} objects, roots=${JSON.stringify(roots)}`); - check('hierarchy lists every object', (await evaluate('__gm.hierarchyOrder().length')) === OBJECT_COUNT); + check('hierarchy opens collapsed – one row per root', (await evaluate('__gm.hierarchyOrder().length')) === ROOTS.length, JSON.stringify(await evaluate('JSON.stringify(__gm.hierarchyOrder())'))); + check('hierarchy lists every object once expanded', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === OBJECT_COUNT); // 2. Every child of a container instance points at an object of that container's template. const templateLinks = JSON.parse(await evaluate(`JSON.stringify((() => {