From ce42d166577e33cd50a1ac3deef24d8e56a00e57 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 12 Sep 2026 16:33:24 +0200 Subject: [PATCH] Refactor round 2: fold duplicated command and inspector code - applyCommands sit on ObjectEditCommand with a revert hook instead of five copies of the same preamble and undo - actionIf replaces six identical Reset/Apply menu blocks, addNameField the three name rows, assetLink the two asset links - requireStructuralObject replaces ten two-line preambles and drops the duplicate holder lookup - __gm.objectsOf had no caller; the SMB remnant in e2e.sh is gone Proven: check.sh 797 tests, full e2e 633 checks, smokes 24/19/20 against the same bundle. Co-Authored-By: Claude Fable 5.1 --- e2e.sh | 5 +- src/core/commands/applyCommands.ts | 119 ++++++++------------ src/core/commands/colliderCommands.ts | 5 +- src/core/commands/containerCommands.ts | 8 +- src/core/commands/require.ts | 8 ++ src/core/commands/sceneObjectCommands.ts | 33 +++--- src/editor/debugHook.ts | 1 - src/editor/inspector/assetSection.ts | 21 ++-- src/editor/inspector/fieldFactory.ts | 3 + src/editor/inspector/frameSection.ts | 11 +- src/editor/inspector/host.ts | 11 +- src/editor/inspector/objectSection.ts | 135 +++++++---------------- src/ui/index.ts | 2 +- 13 files changed, 139 insertions(+), 223 deletions(-) diff --git a/e2e.sh b/e2e.sh index 7415f6ad..4463dc75 100755 --- a/e2e.sh +++ b/e2e.sh @@ -16,9 +16,8 @@ LOGDIR=$DIR/logs running() { systemctl is-active --quiet "$UNIT.service"; } rel() { echo "${1#"$DIR"/}"; } -# Newest by name, not by mtime: the timestamped names sort chronologically and a share-mounted -# worktree can hand out surprising mtimes. The glob demands that shape, so a hand-dropped file -# in logs/ cannot outsort the newest run. +# Newest by name, not by mtime: the timestamped names sort chronologically, and the glob demands +# that shape, so a hand-dropped file in logs/ cannot outsort the newest run. latest_log() { ls -1 "$LOGDIR"/e2e-[0-9]*.log 2>/dev/null | tail -1; } start() { diff --git a/src/core/commands/applyCommands.ts b/src/core/commands/applyCommands.ts index b37adb70..a8aa8c8f 100644 --- a/src/core/commands/applyCommands.ts +++ b/src/core/commands/applyCommands.ts @@ -4,53 +4,48 @@ * instance's deviation again, which is all it takes for the value to follow the template – the * content of a placed container is resolved from it, never copied (see `model/instance.ts`). */ -import { - captureObject, - type InstanceTarget, - instanceTargetOf, - type ObjectMemo, - restoreObject, -} from '../model/instance'; +import type { Asset } from '../model/Asset'; +import type { InstanceTarget, ObjectRef } from '../model/instance'; import { type Axis, type Position, type PropertyValue, setBagValue } from '../model/Properties'; import type { Project } from '../model/Project'; import { resolveSceneObjectProps } from '../properties/resolve'; -import { type Command, CommandError } from './Command'; -import { requireAsset, requireHolder, requireObjectRef, requirePropDef } from './require'; +import { CommandError } from './Command'; +import { requireAsset, requirePropDef } from './require'; +import { ObjectEditCommand } from './sceneObjectCommands'; const ALL_AXES: readonly Axis[] = ['x', 'y', 'z']; -function requireTarget(project: Project, holderId: string, objectId: string): InstanceTarget { - const target = instanceTargetOf(project, requireHolder(project, holderId), objectId); - if (!target) throw new CommandError('That object is not part of a placed container'); - return target; +function requireTarget(ref: ObjectRef): InstanceTarget { + if (!ref.target) throw new CommandError('That object is not part of a placed container'); + return ref.target; } /** * Writes the object's value for `key` into its asset and drops the override. For content of a * placed container the template's override goes too, so every instance follows the asset again. */ -export class ApplyOverrideToAssetCommand implements Command { +export class ApplyOverrideToAssetCommand extends ObjectEditCommand { readonly label: string; + private asset: Asset | undefined; private previousAssetValue: PropertyValue | undefined; - private previousTemplateValue: PropertyValue | undefined; - private memo: ObjectMemo | undefined; private template: InstanceTarget | undefined; + private previousTemplateValue: PropertyValue | undefined; constructor( - private readonly holderId: string, - private readonly objectId: string, + holderId: string, + objectId: string, private readonly key: string, ) { + super(holderId, objectId); this.label = `Apply ${key} to asset`; } - execute(project: Project): void { - const ref = requireObjectRef(project, this.holderId, this.objectId); + protected override apply(ref: ObjectRef, project: Project): void { const asset = requireAsset(project, ref.object.assetId); requirePropDef(asset, this.key); const value = resolveSceneObjectProps(asset, ref.object)[this.key]; + this.asset = asset; this.previousAssetValue = asset.props[this.key]; - this.memo = captureObject(ref); asset.props[this.key] = value!; ref.setOverride(this.key, undefined); this.template = ref.target; @@ -59,126 +54,102 @@ export class ApplyOverrideToAssetCommand implements Command { delete this.template.template.overrides[this.key]; } - undo(project: Project): void { - const asset = requireAsset( - project, - requireObjectRef(project, this.holderId, this.objectId).object.assetId, - ); + protected override revert(): void { if (this.template) setBagValue(this.template.template.overrides, this.key, this.previousTemplateValue); - if (this.memo) restoreObject(this.memo); - setBagValue(asset.props, this.key, this.previousAssetValue); + if (this.asset) setBagValue(this.asset.props, this.key, this.previousAssetValue); } } -export class ApplyOverrideToContainerCommand implements Command { +export class ApplyOverrideToContainerCommand extends ObjectEditCommand { readonly label: string; private previous: PropertyValue | undefined; - private memo: ObjectMemo | undefined; private target: InstanceTarget | undefined; constructor( - private readonly holderId: string, - private readonly objectId: string, + holderId: string, + objectId: string, private readonly key: string, ) { + super(holderId, objectId); this.label = `Apply ${key} to container`; } - execute(project: Project): void { - const target = requireTarget(project, this.holderId, this.objectId); - const ref = requireObjectRef(project, this.holderId, this.objectId); + protected override apply(ref: ObjectRef): void { + const target = requireTarget(ref); this.target = target; - this.memo = captureObject(ref); this.previous = target.template.overrides[this.key]; setBagValue(target.template.overrides, this.key, ref.object.overrides[this.key]); // The deviation is gone the moment the template carries it, so the edit must go with it. ref.setOverride(this.key, target.template.overrides[this.key]); } - undo(): void { + protected override revert(): void { if (this.target) setBagValue(this.target.template.overrides, this.key, this.previous); - if (this.memo) restoreObject(this.memo); } } -export class RevertOverrideToTemplateCommand implements Command { +export class RevertOverrideToTemplateCommand extends ObjectEditCommand { readonly label: string; - private memo: ObjectMemo | undefined; constructor( - private readonly holderId: string, - private readonly objectId: string, + holderId: string, + objectId: string, private readonly key: string, ) { + super(holderId, objectId); this.label = `Revert ${key}`; } - execute(project: Project): void { - const target = requireTarget(project, this.holderId, this.objectId); - const ref = requireObjectRef(project, this.holderId, this.objectId); - this.memo = captureObject(ref); - ref.setOverride(this.key, target.base.overrides[this.key]); - } - - undo(): void { - if (this.memo) restoreObject(this.memo); + protected override apply(ref: ObjectRef): void { + ref.setOverride(this.key, requireTarget(ref).base.overrides[this.key]); } } /** Writes the object's local position – one axis, or all – into its container template. */ -export class ApplyPositionToContainerCommand implements Command { +export class ApplyPositionToContainerCommand extends ObjectEditCommand { readonly label: string; private previous: Position = { x: 0, y: 0, z: 0 }; - private memo: ObjectMemo | undefined; private target: InstanceTarget | undefined; constructor( - private readonly holderId: string, - private readonly objectId: string, + holderId: string, + objectId: string, private readonly axis?: Axis, ) { + super(holderId, objectId); this.label = axis ? `Apply ${axis} to container` : 'Apply position to container'; } - execute(project: Project): void { - const target = requireTarget(project, this.holderId, this.objectId); - const ref = requireObjectRef(project, this.holderId, this.objectId); + protected override apply(ref: ObjectRef): void { + const target = requireTarget(ref); this.target = target; - this.memo = captureObject(ref); this.previous = { ...target.template.position }; for (const axis of this.axis ? [this.axis] : ALL_AXES) target.template.position[axis] = ref.object.position[axis]; ref.setPosition(ref.object.position); } - undo(): void { + protected override revert(): void { if (this.target) this.target.template.position = { ...this.previous }; - if (this.memo) restoreObject(this.memo); } } -export class RevertPositionToTemplateCommand implements Command { +export class RevertPositionToTemplateCommand extends ObjectEditCommand { readonly label: string; - private memo: ObjectMemo | undefined; constructor( - private readonly holderId: string, - private readonly objectId: string, + holderId: string, + objectId: string, private readonly axis?: Axis, ) { + super(holderId, objectId); this.label = axis ? `Revert ${axis}` : 'Revert position'; } - execute(project: Project): void { - const target = requireTarget(project, this.holderId, this.objectId); - const ref = requireObjectRef(project, this.holderId, this.objectId); - this.memo = captureObject(ref); + protected override apply(ref: ObjectRef): void { + const base = requireTarget(ref).base; const position = { ...ref.object.position }; - for (const axis of this.axis ? [this.axis] : ALL_AXES) position[axis] = target.base.position[axis]; + for (const axis of this.axis ? [this.axis] : ALL_AXES) position[axis] = base.position[axis]; ref.setPosition(position); } - - undo(): void { - if (this.memo) restoreObject(this.memo); - } } diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index b75ac5e5..ec342d1a 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -7,7 +7,7 @@ import { removeById } from '../util/list'; import { buildAsset } from './assetCommands'; import { type Command, CommandError } from './Command'; import { nextAssetName } from './naming'; -import { requireAsset, requireObject, requireHolder, requireStructural } from './require'; +import { requireAsset, requireHolder, requireStructuralObject } from './require'; /** * Creates a collider asset sized like the object (sprites: width × height; anything else: schema @@ -25,8 +25,7 @@ export class AddColliderCommand implements Command { execute(project: Project): void { const holder = requireHolder(project, this.holderId); - requireStructural(project, holder, this.targetId); - const target = requireObject(project, this.holderId, this.targetId); + const target = requireStructuralObject(project, holder, this.targetId); const targetAsset = requireAsset(project, target.assetId); if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider'); if (isLiveInstance(project, holder, target)) diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index 13c56cba..4bae243c 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -17,7 +17,7 @@ import { removeById, type Removed, restoreAt } from '../util/list'; import { buildAsset } from './assetCommands'; import { type Command, CommandError } from './Command'; import { nextAssetName } from './naming'; -import { requireObject, requireHolder, requireStructural } from './require'; +import { requireHolder, requireStructuralObject } from './require'; /** * Turns the selected objects and their descendants into a container asset placed in their stead, the @@ -39,8 +39,7 @@ export class CreateContainerCommand implements Command { execute(project: Project): void { const holder = requireHolder(project, this.holderId); const ids = [...new Set(this.objectIds)]; - for (const id of ids) requireStructural(project, holder, id); - const selected = ids.map((id) => requireObject(project, this.holderId, id)); + const selected = ids.map((id) => requireStructuralObject(project, holder, id)); if (selected.length === 0) throw new CommandError('Select at least one object to create a container'); const worlds = selected.map((o) => worldPosition(holder, o)); @@ -126,8 +125,7 @@ export class UnpackContainerCommand implements Command { execute(project: Project): void { const holder = requireHolder(project, this.holderId); - requireStructural(project, holder, this.objectId); - const object = requireObject(project, this.holderId, this.objectId); + const object = requireStructuralObject(project, holder, this.objectId); if (!isLiveInstance(project, holder, object)) throw new CommandError(`${object.name || 'That object'} is not a placed container`); const created = materializeInstance(project, holder, object); diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 051a37d3..a9c147bb 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -50,6 +50,14 @@ export function requireStructural(project: Project, holder: ObjectTree, objectId throw new CommandError(`${name} comes from a container template; open the container to change it`); } +/** A stored object the caller may restructure: template content is rejected before the lookup. */ +export function requireStructuralObject(project: Project, holder: ObjectTree, objectId: string): SceneObject { + requireStructural(project, holder, objectId); + const object = findObject(holder, objectId); + if (!object) throw new CommandError(`Scene object ${objectId} not found`); + return object; +} + export function requireBehavior(project: Project, assetId: string): BehaviorAsset { const asset = findAssetOf(project, assetId, 'behavior'); if (!asset) throw new CommandError(`Behavior ${assetId} not found`); diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index ab1f3b55..3568549c 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -20,15 +20,15 @@ import { requireObjectRef, requirePropDef, requireHolder, - requireStructural, + requireStructuralObject, } from './require'; /** * An edit to one placed object. The base captures the object before the edit and puts it back on - * undo, so a subclass only says what changes. `applyCommands` deliberately does not use this: its - * commands carry extra undo state whose restore order matters. + * undo, so a subclass only says what changes – and, if it wrote somewhere else too, how to take + * that back (`revert`). */ -abstract class ObjectEditCommand implements Command { +export abstract class ObjectEditCommand implements Command { abstract readonly label: string; private memo: ObjectMemo | undefined; @@ -39,6 +39,8 @@ abstract class ObjectEditCommand implements Command { protected abstract apply(ref: ObjectRef, project: Project): void; + protected revert(): void {} + execute(project: Project): void { const ref = requireObjectRef(project, this.holderId, this.objectId); this.memo = captureObject(ref); @@ -46,6 +48,7 @@ abstract class ObjectEditCommand implements Command { } undo(): void { + this.revert(); if (this.memo) restoreObject(this.memo); } } @@ -71,10 +74,7 @@ export class PlaceAssetCommand implements Command { const asset = requireAsset(project, this.assetId); const rejection = placementRejection(project, holder, asset); if (rejection) throw new CommandError(rejection); - if (this.parentId) { - requireObject(project, this.holderId, this.parentId); - requireStructural(project, holder, this.parentId); - } + if (this.parentId) requireStructuralObject(project, holder, this.parentId); const local = localPositionFor(holder, this.parentId, toPosition(this.position)); holder.objects.push(createSceneObject(this.objectId, this.assetId, local, this.parentId)); } @@ -99,10 +99,7 @@ export class RemoveSceneObjectsCommand implements Command { execute(project: Project): void { const holder = requireHolder(project, this.holderId); - for (const id of this.objectIds) { - requireStructural(project, holder, id); - requireObject(project, this.holderId, id); - } + for (const id of this.objectIds) requireStructuralObject(project, holder, id); this.removed = removeSubtrees(holder, this.objectIds); } @@ -226,14 +223,12 @@ export class ReorderObjectCommand implements Command { execute(project: Project): void { const holder = requireHolder(project, this.holderId); - requireStructural(project, holder, this.objectId); - const object = requireObject(project, this.holderId, this.objectId); + const object = requireStructuralObject(project, holder, this.objectId); if (this.beforeId === this.objectId) throw new CommandError('An object cannot be moved in front of itself'); let at = holder.objects.length; if (this.beforeId) { - requireStructural(project, holder, this.beforeId); - const before = requireObject(project, this.holderId, this.beforeId); + const before = requireStructuralObject(project, holder, this.beforeId); if (before.parentId !== object.parentId) throw new CommandError('An object can only be reordered among its own siblings'); at = holder.objects.indexOf(before); @@ -271,12 +266,10 @@ export class SetParentCommand implements Command { execute(project: Project): void { const holder = requireHolder(project, this.holderId); - requireStructural(project, holder, this.objectId); - const object = requireObject(project, this.holderId, this.objectId); + const object = requireStructuralObject(project, holder, this.objectId); if (this.parentId) { if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent'); - requireStructural(project, holder, this.parentId); - if (isLiveInstance(project, holder, requireObject(project, this.holderId, this.parentId))) + if (isLiveInstance(project, holder, requireStructuralObject(project, holder, this.parentId))) throw new CommandError('A placed container shows its template; unpack it to put objects inside'); if (isSelfOrAncestor(holder, this.objectId, this.parentId)) throw new CommandError('An object cannot be parented to one of its own children'); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index e4203172..1a647b50 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -168,7 +168,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }; (window as unknown as { __gm: unknown }).__gm = { state, - objectsOf, sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []), locate, locateAll, diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 7d18da15..3098be24 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -19,10 +19,10 @@ import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kind import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../core/properties/PropertySchema'; import { markedAssetSchema } from '../../core/properties/deviation'; import { isModified, resolveAssetProps } from '../../core/properties/resolve'; -import { Dropdown, HStack, Label, Menu, TextField, Theme } from '../../ui'; +import { Dropdown, HStack, Label, Menu, Theme } from '../../ui'; import { aspectActions, sizeCommand } from './aspectAction'; -import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; -import { addNote, addTitle, type InspectorHost } from './host'; +import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory'; +import { addNameField, addNote, addTitle, type InspectorHost } from './host'; import { buildVariable, kindMenu, type VariableEditing } from './variableRows'; import { listOpener, previewingHandlers, structFields } from './previewHandlers'; import { assetValue } from './valueSpecs'; @@ -49,15 +49,12 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void { const asset = findAsset(host.state.project, assetId); if (!asset) return; addTitle(host, `${asset.type.toUpperCase()} ASSET`); - host.nameField = new TextField(asset.name, { + addNameField(host, asset.name, { onChange: (name) => { if (name.trim()) host.state.execute(new RenameAssetCommand(assetId, name.trim())); else host.nameField?.setValue(asset.name); }, }); - const { row } = labelledRow('Name'); - row.add(host.nameField, 1); - host.body.add(row); buildProps(host, asset, markedAssetSchema(asset)); if (asset.type === 'behavior') buildVariables(host, asset); @@ -100,13 +97,9 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property ), reset: () => { const target = findAsset(host.state.project, asset.id); - if (!target || !isModified(target, def.key)) return []; - return [ - { - label: 'Reset to default', - onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)), - }, - ]; + return actionIf(target && isModified(target, def.key), 'Reset to default', () => + host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)), + ); }, }, ); diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index bd00a0ca..adc2c9a5 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -88,6 +88,9 @@ export interface FieldAction { */ export type FieldActions = () => FieldAction[]; +export const actionIf = (offered: unknown, label: string, onSelect: () => void): FieldAction[] => + offered ? [{ label, onSelect }] : []; + /** One trailing button of a row: its glyph, and what it currently offers. */ export interface FieldActionGroup { glyph: string; diff --git a/src/editor/inspector/frameSection.ts b/src/editor/inspector/frameSection.ts index 156411d1..3f7e6957 100644 --- a/src/editor/inspector/frameSection.ts +++ b/src/editor/inspector/frameSection.ts @@ -6,9 +6,9 @@ import { import type { TextureAsset, TextureFrame } from '../../core/model/Asset'; import { findAssetOf, type Project } from '../../core/model/Project'; import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture'; -import { Button, TextField } from '../../ui'; -import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; -import { addGap, addNote, addTitle, type InspectorHost } from './host'; +import { Button } from '../../ui'; +import { createFieldRow, type FieldHandlers } from './fieldFactory'; +import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; import { previewingHandlers } from './previewHandlers'; const RECT_COMPONENTS = [ @@ -30,7 +30,7 @@ export function buildFrameSection(host: InspectorHost, assetId: string, frameId: if (!asset || !frame) return; addTitle(host, 'TEXTURE FRAME'); - host.nameField = new TextField(frame.name, { + addNameField(host, frame.name, { onChange: (name) => { // The command rejects a blank name but cannot put the old text back into the field, // so an empty one is not sent at all. @@ -38,9 +38,6 @@ export function buildFrameSection(host: InspectorHost, assetId: string, frameId: else host.nameField?.setValue(frame.name); }, }); - const { row } = labelledRow('Name'); - row.add(host.nameField, 1); - host.body.add(row); addNote(host, `Frame of the texture "${asset.name}".`); addTitle(host, 'RECTANGLE (IMAGE PIXELS)'); diff --git a/src/editor/inspector/host.ts b/src/editor/inspector/host.ts index 8af26d1b..4bbf4b0f 100644 --- a/src/editor/inspector/host.ts +++ b/src/editor/inspector/host.ts @@ -1,6 +1,6 @@ -import { Label, type TextField, type VStack } from '../../ui'; +import { Label, TextField, type TextFieldOptions, type VStack } from '../../ui'; import type { EditorState } from '../state'; -import type { FieldContext, FieldRow } from './fieldFactory'; +import { type FieldContext, type FieldRow, labelledRow } from './fieldFactory'; /** What the inspector sections build into: the panel body and the registry of rows. */ export interface InspectorHost extends FieldContext { @@ -27,3 +27,10 @@ export function addNote( export function addGap(host: InspectorHost): void { host.body.add(new Label('')); } + +export function addNameField(host: InspectorHost, value: string, options: TextFieldOptions): void { + host.nameField = new TextField(value, options); + const { row } = labelledRow('Name'); + row.add(host.nameField, 1); + host.body.add(row); +} diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 66552990..44e7d6f6 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -37,12 +37,12 @@ import { deviatesFromTemplate, resolveSceneObjectProps, } from '../../core/properties/resolve'; -import { Button, TextField } from '../../ui'; +import { Button } from '../../ui'; import { createContainer, removeObjects } from '../actions'; import { aspectActions, sizeCommand } from './aspectAction'; import { defSignature } from './assetSection'; -import { createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory'; -import { addGap, addNote, addTitle, type InspectorHost } from './host'; +import { actionIf, createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory'; +import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; import type { ListSlot } from '../state'; import { parentRow } from './parentRow'; import { listOpener, previewingHandlers, structFields } from './previewHandlers'; @@ -115,38 +115,14 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object const target = targetOf(project, holderId, objectId); addTitle(host, `${asset.type.toUpperCase()} OBJECT`); - host.nameField = new TextField(object.name, { + addNameField(host, object.name, { placeholder: asset.name, onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())), }); - const nameRow = labelledRow('Name'); - nameRow.row.add(host.nameField, 1); - host.body.add(nameRow.row); - - const assetRow = labelledRow('Asset'); - assetRow.row.add( - new Button(asset.name, { - variant: 'ghost', - onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }), - }), - 1, - ); - host.body.add(assetRow.row); - + assetLink(host, 'Asset', asset); host.body.add(parentRow(host, view, object, target !== undefined)); - // Content of a placed container: values compare against the template, not just the asset. - if (target) { - const linkRow = labelledRow('Part of'); - linkRow.row.add( - new Button(target.asset.name, { - variant: 'ghost', - onClick: () => host.state.select({ kind: 'asset', assetId: target.asset.id }), - }), - 1, - ); - host.body.add(linkRow.row); - } + if (target) assetLink(host, 'Part of', target.asset); // The master switch, right where it is looked for: above everything type-specific. const resolved = resolveSceneObjectProps(asset, object); @@ -168,25 +144,13 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object write: (ref, value) => ref.setPosition(value), command: (v) => new MoveSceneObjectCommand(holderId, objectId, v), reset: () => - moved() - ? [ - { - label: 'Reset to container', - onSelect: () => - host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)), - }, - ] - : [], + actionIf(moved(), 'Reset to container', () => + host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)), + ), apply: () => - moved() - ? [ - { - label: 'Apply to container', - onSelect: () => - host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)), - }, - ] - : [], + actionIf(moved(), 'Apply to container', () => + host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)), + ), }); } @@ -203,16 +167,12 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object // No apply: the offset never leaves the placed object. reset: () => { const current = currentRef(host.state, holderId, objectId)?.object; - if (!current || current.editorOffset[axis] === 0) return []; - return [ - { - label: 'Reset to 0', - onSelect: () => - host.state.execute( - new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }), - ), - }, - ]; + if (!current) return []; + return actionIf(current.editorOffset[axis] !== 0, 'Reset to 0', () => + host.state.execute( + new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }), + ), + ); }, }); } @@ -232,6 +192,18 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); } +function assetLink(host: InspectorHost, label: string, asset: Asset): void { + const { row } = labelledRow(label); + row.add( + new Button(asset.name, { + variant: 'ghost', + onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }), + }), + 1, + ); + host.body.add(row); +} + /** * What a placed container holds. While it follows its template the content cannot be changed here – * unpacking writes it into the scene as ordinary objects, which cuts the link for good. @@ -318,46 +290,23 @@ function propertyRow( reset: () => { const { template, asset } = bases(); return [ - ...(template - ? [ - { - label: 'Reset to container', - onSelect: () => - host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)), - }, - ] - : []), - ...(asset - ? [ - { - label: 'Reset to asset', - onSelect: () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)), - }, - ] - : []), + ...actionIf(template, 'Reset to container', () => + host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)), + ), + ...actionIf(asset, 'Reset to asset', () => + host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)), + ), ]; }, apply: () => { const { template, applyToAsset } = bases(); return [ - ...(template - ? [ - { - label: 'Apply to container', - onSelect: () => - host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)), - }, - ] - : []), - ...(applyToAsset - ? [ - { - label: 'Apply to asset', - onSelect: () => - host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)), - }, - ] - : []), + ...actionIf(template, 'Apply to container', () => + host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)), + ), + ...actionIf(applyToAsset, 'Apply to asset', () => + host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)), + ), ]; }, }; diff --git a/src/ui/index.ts b/src/ui/index.ts index 3745614f..9bbdb7cd 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -16,7 +16,7 @@ export { type DropSpot, ListView, type ListItem, type PickModifiers } from './wi export { Popup, type PopupOptions } from './widgets/Popup'; export { Menu, type MenuItem, type MenuOptions } from './widgets/Menu'; export { Dialog, type DialogButton } from './widgets/Dialog'; -export { TextField } from './widgets/TextField'; +export { TextField, type TextFieldOptions } from './widgets/TextField'; export { NumberField } from './widgets/NumberField'; export { ColorField } from './widgets/ColorField'; export { ColorPicker } from './widgets/ColorPicker';