From 631cf54c721b18346d9330da9a890d35283daab3 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 11 Sep 2026 07:27:29 +0200 Subject: [PATCH] Give every behavior variable one row shape on asset and instance buildStructBlock only covered structs, so a placed instance drew numbers, text, bools and lists through createFieldRow on a 64 px name column while its struct block sat on the asset's grid below it. variableRows.ts now builds the row for every variable kind on both sides; the edit option decides what the owner may change (asset: rename, x, item kind; instance: revert and push). Label and Button take a fixed width so the mark column no longer shifts names when a list shows its item-kind picker. The two structFields calls that became dead with 9f48257 are gone. The e2e steps locate the Enabled toggle through __gm.inspectorField instead of clicking a fixed offset from its label. Co-Authored-By: Claude Opus 5 --- README.md | 16 +- src/editor/debugHook.ts | 5 +- src/editor/inspector/assetSection.ts | 204 +++------------------ src/editor/inspector/fieldFactory.ts | 10 +- src/editor/inspector/objectSection.ts | 47 ++--- src/editor/inspector/structBlock.ts | 145 --------------- src/editor/inspector/variableRows.ts | 255 ++++++++++++++++++++++++++ src/ui/widgets/Button.ts | 10 +- src/ui/widgets/Label.ts | 6 + tests/editor/struct-block.test.ts | 85 --------- tests/editor/variable-rows.test.ts | 146 +++++++++++++++ tools/e2e/03-play.mjs | 15 +- tools/e2e/09-containers.mjs | 8 +- tools/e2e/10-text-and-enabled.mjs | 5 +- tools/e2e/11-menus.mjs | 5 +- tools/e2e/21-list.mjs | 9 + tools/e2e/22-struct.mjs | 16 ++ 17 files changed, 515 insertions(+), 472 deletions(-) delete mode 100644 src/editor/inspector/structBlock.ts create mode 100644 src/editor/inspector/variableRows.ts delete mode 100644 tests/editor/struct-block.test.ts create mode 100644 tests/editor/variable-rows.test.ts diff --git a/README.md b/README.md index 669b36b9..d36e8830 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,9 @@ shown on the node and editable in the inspector. because no tab may reach into the `Editor` to switch itself on. An **unconnected `list` input** of a node is the same button and the same tab: its inline value lives in the very same params bag a setting does, and the slot stops resolving the moment an edge feeds the port. What a list - variable holds is picked from its **kind mark** (`≡#`, `≡Aa`, `≡⊞`), which doubles as the - chooser; changing it runs the declared default, the behavior's own value and every override + variable holds is picked from its **kind mark** (`≡#`, `≡Aa`, `≡⊞`), which on the asset doubles + as the chooser – a click opens the menu, with no chevron, since the mark has to stay as narrow as + its column; changing it runs the declared default, the behavior's own value and every override through the new declaration in one command. A list of structs then declares its item shape with the same `+ Field` control a struct variable has – names and kinds only, since the values are edited row by row in the tab. A **struct** variable is the other way round: few fields, so they fit under @@ -120,10 +121,13 @@ shown on the node and editable in the inspector. has, one level down and with a value column – and a field may itself be a **list**, which is the one count button that opens the tab one level deeper. Adding, renaming or removing a field reaches the declaration, the behavior's own value **and every instance override** in one command, so no - stored value keeps a shape its declaration no longer promises. A placed instance draws the very - same block (`buildStructBlock`), minus the renaming and the `+ Field` / `×` buttons: it may - override the values, not declare them. Its ↺ / ↑ sit on the head row, because an override covers - the whole bundle rather than a single field. + stored value keeps a shape its declaration no longer promises. A placed instance draws every + variable, whatever its kind, with the very same rows (`buildVariable`), minus the renaming, the + kind chooser and the `+ Field` / `×` buttons: it may override the values, not declare them. A + struct's ↺ / ↑ sit on its head row, because an override covers the whole bundle rather than a + single field. All of it sits on **one column grid** with the rest of the inspector: mark (28 px) + plus name (90 px) is exactly as wide as a plain row's label, a nested field's name gives up what + its indent takes, and names are cut with `…` rather than pushing their value out of line. - **Owner** = parent: a placed behavior is usually parented to the object it controls (inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index a2a3158f..0c4058a0 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -56,7 +56,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E * *minimum* width, so a label wider than the column pushes its control right – which is why a * fixed offset from the label's own text is the wrong way to click one. */ - const fieldOf = (label: string): { x: number; y: number } | null => { + const fieldOf = (label: string): { x: number; y: number; left: number } | null => { let found: Text | null = null; const walk = (node: Container): void => { if (found !== null || !node.visible) return; @@ -73,7 +73,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const row = owner?.parent; if (!row || !owner) return null; const control = row.children[row.children.indexOf(owner) + 1]; - return control instanceof Widget ? centreOf(control) : null; + if (!(control instanceof Widget)) return null; + return { ...centreOf(control), left: control.getGlobalPosition().x }; }; /** * The visible text of the overlay layer, in drawing order – everything an open popup shows. diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 60835da3..6bb69c1e 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -15,15 +15,10 @@ import { } from '../../core/commands/variableCommands'; import type { Command } from '../../core/commands/Command'; import type { Asset, BehaviorAsset } from '../../core/model/Asset'; -import type { PropertyValue } from '../../core/model/Properties'; import { findAsset, type Project } from '../../core/model/Project'; import { - FIELD_KINDS, - LIST_ITEM_KINDS, - type ListItemKind, type PropertyDef, schemaForAsset, - type StructDef, type StructFieldDef, type StructFieldKind, VARIABLE_KINDS, @@ -31,25 +26,12 @@ import { } from '../../core/properties/PropertySchema'; import { markedAssetSchema } from '../../core/properties/deviation'; import { isModified, resolveAssetProps } from '../../core/properties/resolve'; -import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; +import { Dropdown, HStack, Label, Menu, TextField, Theme } from '../../ui'; import { aspectActions, sizeCommand } from './aspectAction'; -import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; -import type { ListDef } from '../list/slot'; +import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; -import { - buildStructBlock, - FIELD_INDENT, - FIELD_KIND_LABELS, - FIELD_KIND_MARKS, - kindMark as glyphMark, -} from './structBlock'; -import { - listOpener, - type PreviewedValue, - previewingHandlers, - structFields, - structFieldsOf, -} from './previewHandlers'; +import { buildVariable, type VariableEditing } from './variableRows'; +import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers'; const VARIABLE_KIND_LABELS: Record = { number: 'Number', @@ -58,33 +40,6 @@ const VARIABLE_KIND_LABELS: Record = { list: 'List', struct: 'Struct', }; -// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the -// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in. -const VARIABLE_KIND_MARKS: Partial> = { - number: '#', - boolean: '✓', - text: 'Aa', - list: '≡', - struct: '⊞', -}; - -/** The mark a list wears for what it holds – `≡` plus the item's own mark. */ -const LIST_ITEM_MARKS: Record = { - number: '#', - boolean: '✓', - text: 'Aa', - color: '◧', - struct: '⊞', -}; - -const LIST_ITEM_LABELS: Record = { - number: 'Numbers', - boolean: 'Booleans', - text: 'Texts', - color: 'Colors', - struct: 'Structs', -}; - /** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */ const newField = (key: string, kind: StructFieldKind): StructFieldDef => kind === 'list' ? { key, kind, items: 'number' } : { key, kind }; @@ -99,13 +54,19 @@ export function assetSignature(project: Project, assetId: string): string { } export function defSignature(def: PropertyDef): string { - const declared = def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : []; - const fields = declared.map((f) => `${f.key}:${f.kind}`).join('+'); + const fields = declaredFields(def) + .map((f) => `${f.key}:${f.kind}`) + .join('+'); // A list's item kind decides whether it has field rows at all, so it is structure too. const items = def.kind === 'list' ? `/${def.items}` : ''; return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`; } +/** The fields a struct declares, or the item shape of a list of structs. */ +function declaredFields(def: PropertyDef): readonly StructFieldDef[] { + return def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : []; +} + /** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */ export function buildAssetSection(host: InspectorHost, assetId: string): void { const asset = findAsset(host.state.project, assetId); @@ -199,7 +160,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): }; } -/** Variables of a behavior: kind mark, name, default value, remove – plus an "add" menu. */ +/** Variables of a behavior, each with its declaration editable – plus an "add" menu. */ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { const header = new HStack({ gap: Theme.gap, align: 'center' }); header.add(new Label('VARIABLES', { style: 'title' }), 1); @@ -220,133 +181,18 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { if (asset.variables.length === 0) addNote(host, 'No variables yet.'); const values = resolveAssetProps(asset); + const run = (command: Command): void => void host.state.execute(command); for (const def of asset.variables) { - if (def.kind === 'struct') { - buildStructVariable(host, asset, def, values[def.key] ?? def.default); - continue; - } - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.rowHeight; - row.add(def.kind === 'list' ? itemKindPicker(host, asset, def) : kindMark(def)); - row.add( - new TextField(def.key, { - width: 90, - onChange: (next) => { - host.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim())); - }, - }), - ); - const field = createField(host, def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def)); - row.add(field.widget, field.grow ? 1 : 0); - if (!field.grow) row.add(new Spacer(), 1); - row.add( - new Button('×', { - variant: 'ghost', - width: Theme.controlHeight, - onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)), - }), - ); - host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} }); - host.body.add(row); - if (def.kind === 'list' && def.items === 'struct') buildItemFields(host, asset, def); + const edit: VariableEditing = { + rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)), + remove: () => run(new RemoveVariableCommand(asset.id, def.key)), + setItems: (items) => run(new SetListItemsCommand(asset.id, def.key, items)), + renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)), + removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)), + addField: (kind) => + run(new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(declaredFields(def)), kind))), + }; + const value = values[def.key] ?? def.default; + host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit })); } } - -function kindMark(def: PropertyDef): Label { - return glyphMark(VARIABLE_KIND_MARKS[def.kind] ?? '?'); -} - -/** - * The kind mark of a list doubles as the chooser for what it holds – there is no room for a - * column of its own, and the mark is the one place that already says it. Changing it runs every - * value through the new declaration, so a list of numbers becomes a list of texts and back. - */ -function itemKindPicker(host: InspectorHost, asset: BehaviorAsset, def: ListDef): Dropdown { - // Wide enough that the widest mark (`≡Aa`) is not truncated away behind the chevron. - const picker = new Dropdown(`≡${LIST_ITEM_MARKS[def.items]}`, { variant: 'ghost', width: 68 }); - picker.onClick = () => { - new Menu( - LIST_ITEM_KINDS.filter((items) => items !== def.items).map((items) => ({ - label: LIST_ITEM_LABELS[items], - onSelect: () => host.state.execute(new SetListItemsCommand(asset.id, def.key, items)), - })), - ).openBelow(picker); - }; - return picker; -} - -/** - * The shape of the items of a list of structs: names and kinds only (primitives, see `ListFieldDef`). - * Their values are edited in the List tab, one row per item, which is why there is no value column. - */ -function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef): void { - for (const field of def.fields ?? []) { - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.rowHeight; - row.add(new Spacer(FIELD_INDENT)); - row.add(glyphMark(FIELD_KIND_MARKS[field.kind])); - row.add( - new TextField(field.key, { - width: 90, - onChange: (next) => - host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())), - }), - ); - row.add(new Spacer(), 1); - row.add( - new Button('×', { - variant: 'ghost', - width: Theme.controlHeight, - onClick: () => host.state.execute(new RemoveStructFieldCommand(asset.id, def.key, field.key)), - }), - ); - host.body.add(row); - } - const foot = new HStack({ gap: Theme.gap, align: 'center' }); - foot.minHeight = Theme.rowHeight; - foot.add(new Spacer(FIELD_INDENT)); - foot.add(new Spacer(), 1); - const add = new Dropdown('+ Field', { variant: 'ghost' }); - add.onClick = () => { - new Menu( - FIELD_KINDS.map((kind) => ({ - label: FIELD_KIND_LABELS[kind], - onSelect: () => - host.state.execute( - new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields ?? []), kind }), - ), - })), - ).openBelow(add); - }; - foot.add(add); - host.body.add(foot); -} - -/** A struct variable, with the shape editable: this is the asset that declares it. */ -function buildStructVariable( - host: InspectorHost, - asset: BehaviorAsset, - def: StructDef, - value: PropertyValue, -): void { - const perField = structFieldsOf(host, def, assetSpec(host, asset.id, def.key), (field) => ({ - kind: 'asset', - assetId: asset.id, - key: def.key, - field, - })); - const run = (command: Command): boolean => host.state.execute(command); - host.fields.set( - def.key, - buildStructBlock(host, def, value, perField, { - edit: { - renameStruct: (next) => run(new RenameVariableCommand(asset.id, def.key, next)), - removeStruct: () => run(new RemoveVariableCommand(asset.id, def.key)), - renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)), - removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)), - addField: (kind) => - run(new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(def.fields), kind))), - }, - }), - ); -} diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 4f426168..628fc02c 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -66,8 +66,14 @@ export interface FieldRow { setModified(modified: boolean): void; } -/** Width of the label column every inspector row aligns to. */ -const FIELD_LABEL_WIDTH = 64; +/** + * The inspector's one column grid. A variable row is mark + name, a plain row a label spanning + * both, so every value starts at the same x. The mark column holds `≡Aa` (24 px measured). + */ +export const MARK_WIDTH = 28; +export const NAME_WIDTH = 90; +export const FIELD_INDENT = 12; +const FIELD_LABEL_WIDTH = MARK_WIDTH + Theme.gap + NAME_WIDTH; /** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */ export interface FieldAction { diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index a28fd9f2..c21ea564 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -45,14 +45,8 @@ import { createFieldRow, type FieldRow, labelledRow, type RowActions } from './f import { addGap, addNote, addTitle, type InspectorHost } from './host'; import type { ListSlot } from '../state'; import { parentRow } from './parentRow'; -import { - listOpener, - type PreviewedValue, - previewingHandlers, - structFields, - structFieldsOf, -} from './previewHandlers'; -import { buildStructBlock } from './structBlock'; +import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers'; +import { buildVariable } from './variableRows'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; @@ -231,7 +225,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object 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); + for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior'); markObjectRows(host, object, asset, target); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); @@ -312,6 +306,7 @@ function propertyRow( owner: Asset, def: PropertyDef, values: Record, + variable = false, ): FieldRow { const bases = () => deviations(host.state, holderId, objectId, def.key); const overrideSpec: PreviewedValue = { @@ -387,30 +382,18 @@ function propertyRow( }, }; - // A struct is a block of rows, which only it can add to the body; the same one the asset shows, - // minus the renaming and the field buttons – a placed object overrides values, not the shape. - const row = - def.kind === 'struct' - ? buildStructBlock( - host, - def, - values[def.key] ?? def.default, - structFieldsOf(host, def, overrideSpec, fieldSlot), - { actions: rowActions }, - ) - : createFieldRow( - host, - def, - values[def.key] ?? def.default, - { - ...previewingHandlers(host, overrideSpec), - ...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), - ...structFields(host, def, overrideSpec, fieldSlot), - }, - rowActions, - ); + const value = values[def.key] ?? def.default; + const handlers = { + ...previewingHandlers(host, overrideSpec), + ...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), + ...structFields(host, def, overrideSpec, fieldSlot), + }; + // A variable reads exactly like it does on its asset, which adds its own rows to the body. + const row = variable + ? buildVariable(host, def, value, handlers, { actions: rowActions }) + : createFieldRow(host, def, value, handlers, rowActions); host.fields.set(def.key, row); - if (def.kind !== 'struct') host.body.add(row.row); + if (!variable) host.body.add(row.row); return row; } diff --git a/src/editor/inspector/structBlock.ts b/src/editor/inspector/structBlock.ts deleted file mode 100644 index 2d058843..00000000 --- a/src/editor/inspector/structBlock.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { PropertyValue } from '../../core/model/Properties'; -import { - STRUCT_FIELD_KINDS, - type StructDef, - type StructFieldDef, - type StructFieldKind, -} from '../../core/properties/PropertySchema'; -import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; -import { - attachRowActions, - createField, - type FieldHandlers, - fieldDef, - type FieldRow, - type RowActions, - structFieldValue, -} from './fieldFactory'; -import { addNote, type InspectorHost } from './host'; - -export const FIELD_INDENT = 12; -const NAME_WIDTH = 90; -const MARK_WIDTH = 18; - -export const STRUCT_MARK = '⊞'; - -export const FIELD_KIND_MARKS: Record = { - number: '#', - boolean: '✓', - text: 'Aa', - color: '◧', - list: '≡', -}; - -export const FIELD_KIND_LABELS: Record = { - number: 'Number', - boolean: 'Boolean', - text: 'Text', - color: 'Color', - list: 'List', -}; - -export function kindMark(glyph: string): Label { - const mark = new Label(glyph, { style: 'small', align: 'center' }); - mark.minWidth = MARK_WIDTH; - return mark; -} - -/** Changing the shape itself – offered only where it is declared, which is the asset. */ -export interface StructShapeEditing { - renameStruct: (next: string) => void; - removeStruct: () => void; - renameField: (field: StructFieldDef, next: string) => void; - removeField: (field: StructFieldDef) => void; - addField: (kind: StructFieldKind) => void; -} - -export interface StructBlockOptions { - edit?: StructShapeEditing; - /** ↺/↑ on the head row: an override covers the whole bundle, so its fields share them. */ - actions?: RowActions; -} - -/** - * A struct is a block rather than a row: a head naming the bundle, then one row per field carrying - * its mark, its name and its value. Both places that show one build it here, so a placed object - * reads exactly like the asset that declares it – minus `edit`, since a shape belongs to the asset. - * - * Adds its rows to the body itself (a caller cannot add a block), and returns the head as the row. - */ -export function buildStructBlock( - host: InspectorHost, - def: StructDef, - value: PropertyValue, - handlers: (field: StructFieldDef) => FieldHandlers, - options: StructBlockOptions = {}, -): FieldRow { - const { edit, actions } = options; - const head = new HStack({ gap: Theme.gap, align: 'center' }); - head.minHeight = Theme.rowHeight; - head.add(kindMark(STRUCT_MARK)); - const nameLabel = edit ? null : name(def.key); - head.add( - nameLabel ?? - new TextField(def.key, { width: NAME_WIDTH, onChange: (next) => edit?.renameStruct(next.trim()) }), - ); - head.add(new Spacer(), 1); - if (edit) { - const add = new Dropdown('+ Field', { variant: 'ghost' }); - add.onClick = () => { - const items = STRUCT_FIELD_KINDS.map((kind) => ({ - label: FIELD_KIND_LABELS[kind], - onSelect: () => edit.addField(kind), - })); - new Menu(items).openBelow(add); - }; - head.add(add); - head.add(remove(() => edit.removeStruct())); - } - const refreshActions = actions ? attachRowActions(head, actions) : () => {}; - host.body.add(head); - - if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); - const setters = new Map void>(); - for (const field of def.fields) { - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.rowHeight; - row.add(new Spacer(FIELD_INDENT)); - row.add(kindMark(FIELD_KIND_MARKS[field.kind])); - row.add( - edit - ? new TextField(field.key, { - width: NAME_WIDTH, - onChange: (next) => edit.renameField(field, next.trim()), - }) - : name(field.key), - ); - const item = createField(host, fieldDef(field), structFieldValue(value, field), handlers(field)); - row.add(item.widget, item.grow ? 1 : 0); - if (!item.grow) row.add(new Spacer(), 1); - if (edit) row.add(remove(() => edit.removeField(field))); - setters.set(field.key, item.setValue); - host.body.add(row); - } - - return { - row: head, - setValue: (next) => { - for (const field of def.fields) setters.get(field.key)?.(structFieldValue(next, field)); - }, - setModified: (modified) => { - nameLabel?.setStyle(modified ? 'override' : 'muted'); - refreshActions(); - }, - }; -} - -function name(text: string): Label { - const label = new Label(text, { style: 'muted' }); - label.minWidth = NAME_WIDTH; - return label; -} - -function remove(onClick: () => void): Button { - return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick }); -} diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts new file mode 100644 index 00000000..ad3b60c3 --- /dev/null +++ b/src/editor/inspector/variableRows.ts @@ -0,0 +1,255 @@ +import type { PropertyValue } from '../../core/model/Properties'; +import { + FIELD_KINDS, + LIST_ITEM_KINDS, + type ListItemKind, + type PropertyDef, + STRUCT_FIELD_KINDS, + type StructDef, + type StructFieldDef, + type StructFieldKind, +} from '../../core/properties/PropertySchema'; +import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui'; +import type { ListDef } from '../list/slot'; +import { + attachRowActions, + createField, + FIELD_INDENT, + type FieldHandlers, + fieldDef, + type FieldRow, + MARK_WIDTH, + NAME_WIDTH, + type RowActions, + structFieldValue, +} from './fieldFactory'; +import { addNote, type InspectorHost } from './host'; + +// Keyed by the wide `PropertyDef['kind']` because variables are `PropertyDef`s, but only the +// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in. +const VARIABLE_KIND_MARKS: Partial> = { + number: '#', + boolean: '✓', + text: 'Aa', + struct: '⊞', +}; + +const LIST_ITEM_MARKS: Record = { + number: '#', + boolean: '✓', + text: 'Aa', + color: '◧', + struct: '⊞', +}; + +const LIST_ITEM_LABELS: Record = { + number: 'Numbers', + boolean: 'Booleans', + text: 'Texts', + color: 'Colors', + struct: 'Structs', +}; + +const FIELD_KIND_MARKS: Record = { + number: '#', + boolean: '✓', + text: 'Aa', + color: '◧', + list: '≡', +}; + +const FIELD_KIND_LABELS: Record = { + number: 'Number', + boolean: 'Boolean', + text: 'Text', + color: 'Color', + list: 'List', +}; + +/** Changing a declaration – offered only on the asset that declares it. */ +export interface VariableEditing { + rename: (next: string) => void; + remove: () => void; + setItems: (items: ListItemKind) => void; + renameField: (field: StructFieldDef, next: string) => void; + removeField: (field: StructFieldDef) => void; + addField: (kind: StructFieldKind) => void; +} + +export interface VariableOptions { + edit?: VariableEditing; + /** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */ + actions?: RowActions; +} + +const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} }; + +/** + * One variable, drawn the same on its asset and on a placed object – minus `edit` there, since the + * shape belongs to the asset. Adds its rows to the body itself and returns the first. + */ +export function buildVariable( + host: InspectorHost, + def: PropertyDef, + value: PropertyValue, + handlers: FieldHandlers, + options: VariableOptions = {}, +): FieldRow { + const { edit, actions } = options; + const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next))); + const head = gridRow(0, markOf(def, edit), name); + let setValue: (next: PropertyValue) => void = () => {}; + if (def.kind === 'struct') { + head.add(new Spacer(), 1); + if (edit) head.add(addFieldMenu(STRUCT_FIELD_KINDS, edit)); + } else { + setValue = addValue(head, createField(host, def, value, handlers)); + } + if (edit) head.add(removeButton(edit.remove)); + const refreshActions = actions ? attachRowActions(head, actions) : () => {}; + host.body.add(head); + + if (def.kind === 'struct') setValue = structFieldRows(host, def, value, handlers, edit); + if (def.kind === 'list' && def.items === 'struct' && edit) itemShapeRows(host, def, edit); + + return { + row: head, + setValue: (next) => setValue(next), + setModified: (modified) => { + if (name instanceof Label) name.setStyle(modified ? 'override' : 'muted'); + refreshActions(); + }, + }; +} + +/** One row per field, each with its own value; returns the setter that pushes a bundle into them. */ +function structFieldRows( + host: InspectorHost, + def: StructDef, + value: PropertyValue, + handlers: FieldHandlers, + edit: VariableEditing | undefined, +): (next: PropertyValue) => void { + if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); + const setters = new Map void>(); + for (const field of def.fields) { + const row = fieldRow(field, edit); + const item = createField( + host, + fieldDef(field), + structFieldValue(value, field), + handlers.structFieldHandlers?.(field) ?? READ_ONLY, + ); + setters.set(field.key, addValue(row, item)); + if (edit) row.add(removeButton(() => edit.removeField(field))); + host.body.add(row); + } + return (next) => { + for (const field of def.fields) setters.get(field.key)?.(structFieldValue(next, field)); + }; +} + +/** + * The item shape of a list of structs: names and kinds only (primitives, see `ListFieldDef`). The + * values are edited in the List tab, one row per item, which is why there is no value column. + */ +function itemShapeRows(host: InspectorHost, def: ListDef, edit: VariableEditing): void { + for (const field of def.fields ?? []) { + const row = fieldRow(field, edit); + row.add(new Spacer(), 1); + row.add(removeButton(() => edit.removeField(field))); + host.body.add(row); + } + const foot = new HStack({ gap: Theme.gap, align: 'center' }); + foot.minHeight = Theme.rowHeight; + foot.add(new Spacer(), 1); + foot.add(addFieldMenu(FIELD_KINDS, edit)); + host.body.add(foot); +} + +function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack { + const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next))); + return gridRow(1, markLabel(FIELD_KIND_MARKS[field.kind]), name); +} + +/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */ +function gridRow(depth: number, mark: Widget, name: Widget): HStack { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + if (depth > 0) row.add(new Spacer(FIELD_INDENT)); + row.add(mark); + row.add(name); + return row; +} + +function nameWidth(depth: number): number { + return NAME_WIDTH - depth * (FIELD_INDENT + Theme.gap); +} + +/** A text field where the name may be changed, else a label whose text sits where the field's would. */ +function nameCell(text: string, depth: number, rename: ((next: string) => void) | undefined): Widget { + const width = nameWidth(depth); + return rename + ? new TextField(text, { width, onChange: (next) => rename(next.trim()) }) + : new Label(text, { style: 'muted', width, padding: Theme.fieldPadding }); +} + +function addValue( + row: HStack, + field: { widget: Widget; grow: boolean; setValue: (v: PropertyValue) => void }, +): (v: PropertyValue) => void { + row.add(field.widget, field.grow ? 1 : 0); + if (!field.grow) row.add(new Spacer(), 1); + return field.setValue; +} + +function markOf(def: PropertyDef, edit: VariableEditing | undefined): Widget { + if (def.kind !== 'list') return markLabel(VARIABLE_KIND_MARKS[def.kind] ?? '?'); + const glyph = `≡${LIST_ITEM_MARKS[def.items]}`; + return edit ? itemKindPicker(glyph, def.items, edit) : markLabel(glyph); +} + +function markLabel(glyph: string): Label { + return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH }); +} + +/** + * A kind mark that can be clicked. The label may take the button's whole width: the column is + * sized for its widest glyph, and the usual inset would cut `≡Aa` to `≡A…`. + */ +class MarkButton extends Button { + protected override labelSlot(): number { + return this.w; + } +} + +/** + * On the asset, the mark of a list doubles as the chooser for what it holds – it is the one place + * that already says it. Changing it runs every value through the new declaration. + */ +function itemKindPicker(glyph: string, current: ListItemKind, edit: VariableEditing): Button { + const picker = new MarkButton(glyph, { variant: 'ghost', width: MARK_WIDTH, style: 'small' }); + picker.onClick = () => { + new Menu( + LIST_ITEM_KINDS.filter((items) => items !== current).map((items) => ({ + label: LIST_ITEM_LABELS[items], + onSelect: () => edit.setItems(items), + })), + ).openBelow(picker); + }; + return picker; +} + +function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing): Dropdown { + const add = new Dropdown('+ Field', { variant: 'ghost' }); + add.onClick = () => { + new Menu( + kinds.map((kind) => ({ label: FIELD_KIND_LABELS[kind], onSelect: () => edit.addField(kind) })), + ).openBelow(add); + }; + return add; +} + +function removeButton(onClick: () => void): Button { + return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick }); +} diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index fadaac5c..acae2b73 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -1,7 +1,7 @@ import { type FederatedPointerEvent, Graphics, Text, CanvasTextMetrics } from 'pixi.js'; import { drawControlBox } from '../draw'; import { centreY } from '../math'; -import { Theme, textStyle } from '../Theme'; +import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { type Size, Widget } from '../Widget'; import { truncate } from './Label'; @@ -12,6 +12,8 @@ export interface ButtonOptions { /** Floor only – for a button whose label changes between forms and must grow with them. */ minWidth?: number; align?: 'center' | 'left'; + /** Text style of the label when the button is not on an accent fill. */ + style?: TextStyleToken; onClick?: () => void; } @@ -26,6 +28,7 @@ export class Button extends Widget { variant: NonNullable; protected readonly align: NonNullable; protected readonly fixedWidth: number; + private readonly style: TextStyleToken; onClick?: () => void; constructor(text: string, options: ButtonOptions = {}) { @@ -39,7 +42,8 @@ export class Button extends Widget { this.fixedWidth = options.width ?? 0; this.minWidth = this.fixedWidth || (options.minWidth ?? 0); this.fullText = text; - this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') }); + this.style = options.style ?? 'body'; + this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : this.style) }); this.addChild(this.bg, this.labelText); // Leaving while pressed cancels the click: a release elsewhere must not activate. @@ -148,7 +152,7 @@ export class Button extends Widget { this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke }); } // Style first: it decides the glyph widths that the truncation then measures against. - this.labelText.style = textStyle(this.onAccent ? 'accent' : 'body'); + this.labelText.style = textStyle(this.onAccent ? 'accent' : this.style); const label = this.labelText; label.text = truncate(this.fullText, label.style, this.labelSlot()); const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2; diff --git a/src/ui/widgets/Label.ts b/src/ui/widgets/Label.ts index 1094f38e..90a007c6 100644 --- a/src/ui/widgets/Label.ts +++ b/src/ui/widgets/Label.ts @@ -7,6 +7,8 @@ export interface LabelOptions { style?: TextStyleToken; align?: 'left' | 'center' | 'right'; padding?: number; + /** Fixed width – a column that must not grow with a long text; the text is truncated instead. */ + width?: number; } /** Single-line text that truncates with an ellipsis when narrower than its content. */ @@ -15,6 +17,7 @@ export class Label extends Widget { private fullText: string; private readonly align: NonNullable; private readonly padding: number; + private readonly fixedWidth: number; constructor(text = '', options: LabelOptions = {}) { super(); @@ -22,6 +25,8 @@ export class Label extends Widget { this.fullText = text; this.align = options.align ?? 'left'; this.padding = options.padding ?? 0; + this.fixedWidth = options.width ?? 0; + this.minWidth = this.fixedWidth; this.textNode = new Text({ text, style: textStyle(options.style ?? 'body') }); this.addChild(this.textNode); this.minHeight = Theme.rowHeight; @@ -45,6 +50,7 @@ export class Label extends Widget { } override measure(): Size { + if (this.fixedWidth) return { w: this.fixedWidth, h: this.minHeight }; const metrics = CanvasTextMetrics.measureText(this.fullText, this.textNode.style); return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), diff --git a/tests/editor/struct-block.test.ts b/tests/editor/struct-block.test.ts deleted file mode 100644 index 1b6a74e8..00000000 --- a/tests/editor/struct-block.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * A struct is drawn as a block – a head naming the bundle, a row per field – in both places that - * show one. The asset that declares it may rename and add fields; a placed object may not, so - * there the names are labels. `NumberField extends TextField`, so counting editable widgets by - * type would count the value fields too: the tests below go by which widget carries the name. - */ -import { describe, expect, it } from 'vitest'; -import { createEmptyProject } from '../../src/core/model/Project'; -import type { StructDef } from '../../src/core/properties/PropertySchema'; -import { Label, TextField, VStack, type Widget } from '../../src/ui'; -import type { InspectorHost } from '../../src/editor/inspector/host'; -import { buildStructBlock } from '../../src/editor/inspector/structBlock'; -import { EditorState } from '../../src/editor/state'; - -const DEF: StructDef = { - key: 'piece', - label: 'piece', - kind: 'struct', - default: { x: 0, tag: '' }, - fields: [ - { key: 'x', kind: 'number' }, - { key: 'tag', kind: 'text' }, - ], -}; - -const VALUE = { x: 30, tag: 'tee' }; - -function makeHost(): InspectorHost { - const state = new EditorState(); - state.loadProject(createEmptyProject('p', 'P'), 1); - return { state, body: new VStack(), fields: new Map(), nameField: null, project: () => state.project }; -} - -const noHandlers = () => ({ onInput: () => {}, onChange: () => {} }); - -const rows = (host: InspectorHost): Widget[][] => - host.body.children.map((row) => ('children' in row ? (row.children as Widget[]) : [])); - -/** Every widget of the block that carries `text` as its own content, name or value. */ -const carrying = (host: InspectorHost, text: string): Widget[] => - rows(host) - .flat() - .filter((w) => (w instanceof Label && w.text === text) || (w instanceof TextField && w.value === text)); - -const noop = { renameStruct: () => {}, removeStruct: () => {}, renameField: () => {}, removeField: () => {} }; - -describe('a struct block on a placed object', () => { - it('is a head row plus one row per field', () => { - const host = makeHost(); - buildStructBlock(host, DEF, VALUE, noHandlers); - expect(host.body.children.length).toBe(1 + DEF.fields.length); - }); - - it('names the bundle and its fields with labels, so they cannot be edited', () => { - const host = makeHost(); - buildStructBlock(host, DEF, VALUE, noHandlers); - for (const name of ['piece', 'x', 'tag']) { - const [widget] = carrying(host, name); - expect(widget, name).toBeInstanceOf(Label); - } - }); - - it('still shows the values in editable fields', () => { - const host = makeHost(); - buildStructBlock(host, DEF, VALUE, noHandlers); - expect(carrying(host, 'tee')[0]).toBeInstanceOf(TextField); - }); -}); - -describe('the same block on the asset that declares it', () => { - it('names the bundle and its fields with text fields, so they can be renamed', () => { - const host = makeHost(); - buildStructBlock(host, DEF, VALUE, noHandlers, { edit: { ...noop, addField: () => {} } }); - for (const name of ['piece', 'x', 'tag']) { - const [widget] = carrying(host, name); - expect(widget, name).toBeInstanceOf(TextField); - } - }); - - it('keeps the same row count as the placed one', () => { - const host = makeHost(); - buildStructBlock(host, DEF, VALUE, noHandlers, { edit: { ...noop, addField: () => {} } }); - expect(host.body.children.length).toBe(1 + DEF.fields.length); - }); -}); diff --git a/tests/editor/variable-rows.test.ts b/tests/editor/variable-rows.test.ts new file mode 100644 index 00000000..a28baffb --- /dev/null +++ b/tests/editor/variable-rows.test.ts @@ -0,0 +1,146 @@ +/** + * A variable is drawn by one builder on the asset that declares it and on a placed object, and + * every row of the inspector shares one column grid. The asset may rename and reshape; a placed + * object may not, so there the names are labels. `NumberField extends TextField`, so counting + * editable widgets by type would count the value fields too: the tests go by the name's widget. + */ +import { describe, expect, it } from 'vitest'; +import { createEmptyProject } from '../../src/core/model/Project'; +import type { PropertyDef, StructDef } from '../../src/core/properties/PropertySchema'; +import { HStack, Label, TextField, Theme, VStack, type Widget } from '../../src/ui'; +import { type FieldHandlers, labelledRow } from '../../src/editor/inspector/fieldFactory'; +import type { InspectorHost } from '../../src/editor/inspector/host'; +import { buildVariable, type VariableEditing } from '../../src/editor/inspector/variableRows'; +import { EditorState } from '../../src/editor/state'; + +const STRUCT: StructDef = { + key: 'piece', + label: 'piece', + kind: 'struct', + default: { x: 0, tag: '' }, + fields: [ + { key: 'x', kind: 'number' }, + { key: 'tag', kind: 'text' }, + ], +}; + +const VALUE = { x: 30, tag: 'tee' }; + +const PLAIN: PropertyDef[] = [ + { key: 'speed', label: 'speed', kind: 'number', default: 0 }, + { key: 'title', label: 'title', kind: 'text', default: '' }, + { key: 'alive', label: 'alive', kind: 'boolean', default: true }, + { key: 'cells', label: 'cells', kind: 'list', default: [], items: 'text' }, + { key: 'a_rather_long_variable_name', label: 'a_rather_long_variable_name', kind: 'number', default: 0 }, +]; + +function makeHost(): InspectorHost { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + return { state, body: new VStack(), fields: new Map(), nameField: null, project: () => state.project }; +} + +const noHandlers = (): FieldHandlers => ({ onInput: () => {}, onChange: () => {} }); +const withFields: FieldHandlers = { ...noHandlers(), structFieldHandlers: noHandlers }; + +const EDIT: VariableEditing = { + rename: () => {}, + remove: () => {}, + setItems: () => {}, + renameField: () => {}, + removeField: () => {}, + addField: () => {}, +}; + +const rows = (host: InspectorHost): Widget[][] => + host.body.children.map((row) => ('children' in row ? (row.children as Widget[]) : [])); + +/** Every widget of the body that carries `text` as its own content, name or value. */ +const carrying = (host: InspectorHost, text: string): Widget[] => + rows(host) + .flat() + .filter((w) => (w instanceof Label && w.text === text) || (w instanceof TextField && w.value === text)); + +const isName = (w: Widget, name: string) => + (w instanceof Label && w.text === name) || (w instanceof TextField && w.value === name); + +/** Where the widget after the name starts – summed from the measured cells before it. */ +function valueX(host: InspectorHost, name: string): number { + for (const row of host.body.children) { + if (!(row instanceof HStack)) continue; + const items = row.items; + const at = items.findIndex((w) => isName(w, name)); + if (at < 0) continue; + return items.slice(0, at + 1).reduce((x, w) => x + w.measure().w + row.gap, row.padding); + } + throw new Error(`no row named ${name}`); +} + +function buildAll(edit?: VariableEditing): InspectorHost { + const host = makeHost(); + for (const def of PLAIN) buildVariable(host, def, def.default, noHandlers(), { edit }); + buildVariable(host, STRUCT, VALUE, withFields, { edit }); + return host; +} + +const NAMES = [...PLAIN.map((d) => d.key), 'x', 'tag']; + +describe('the column grid', () => { + it('starts every value at the same x on a placed object, whatever the kind or depth', () => { + const host = buildAll(); + const xs = NAMES.map((name) => valueX(host, name)); + expect(new Set(xs), JSON.stringify(xs)).toEqual(new Set([xs[0]])); + }); + + it('and at that same x on the asset, where names are fields and the list mark is a picker', () => { + const placed = valueX(buildAll(), 'speed'); + const host = buildAll(EDIT); + const xs = NAMES.map((name) => valueX(host, name)); + expect(xs, JSON.stringify(xs)).toEqual(NAMES.map(() => placed)); + }); + + it('which is where a plain labelled row starts its value too', () => { + const { label } = labelledRow('Enabled'); + expect(label.minWidth + Theme.gap).toBe(valueX(buildAll(), 'speed')); + }); +}); + +describe('a struct on a placed object', () => { + it('is a head row plus one row per field', () => { + const host = makeHost(); + buildVariable(host, STRUCT, VALUE, withFields); + expect(host.body.children.length).toBe(1 + STRUCT.fields.length); + }); + + it('names the bundle and its fields with labels, so they cannot be edited', () => { + const host = makeHost(); + buildVariable(host, STRUCT, VALUE, withFields); + for (const name of ['piece', 'x', 'tag']) { + const [widget] = carrying(host, name); + expect(widget, name).toBeInstanceOf(Label); + } + }); + + it('still shows the values in editable fields', () => { + const host = makeHost(); + buildVariable(host, STRUCT, VALUE, withFields); + expect(carrying(host, 'tee')[0]).toBeInstanceOf(TextField); + }); +}); + +describe('the same struct on the asset that declares it', () => { + it('names the bundle and its fields with text fields, so they can be renamed', () => { + const host = makeHost(); + buildVariable(host, STRUCT, VALUE, withFields, { edit: EDIT }); + for (const name of ['piece', 'x', 'tag']) { + const [widget] = carrying(host, name); + expect(widget, name).toBeInstanceOf(TextField); + } + }); + + it('keeps the same row count as the placed one', () => { + const host = makeHost(); + buildVariable(host, STRUCT, VALUE, withFields, { edit: EDIT }); + expect(host.body.children.length).toBe(1 + STRUCT.fields.length); + }); +}); diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index 286a816f..92c0a2de 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -16,7 +16,6 @@ export async function play(t) { click, clickText, dragAsset, - locate, press, holdKey, undo, @@ -183,9 +182,9 @@ export async function play(t) { // of those left the first in hierarchy order takes the view. The second camera is parked far // from the origin, so "seen through it" is provable – both crates fall outside its frame. await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); - const enabledRow = await locate('Enabled'); - // The checkbox sits right of the 64 px label column; locate() gives the label's text centre. - await click(enabledRow.x + 55, enabledRow.y); + // Found through its row, not an offset from the label: the label column's width is layout. + const enabledRow = await json("__gm.inspectorField('Enabled')"); + await click(enabledRow.x, enabledRow.y); await sleep(150); check( 'the camera has the enabled switch like any other object', @@ -221,8 +220,8 @@ export async function play(t) { await stop(); await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); - const switchBackOn = await locate('Enabled'); - await click(switchBackOn.x + 55, switchBackOn.y); + const switchBackOn = await json("__gm.inspectorField('Enabled')"); + await click(switchBackOn.x, switchBackOn.y); await sleep(150); await play(); info = await gameInfo(); @@ -279,8 +278,8 @@ export async function play(t) { // Switching the only camera off leaves nothing to preview – and says so, live on the tab. await evaluate(`__gm.showTab('game')`); await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); - const previewSwitch = await locate('Enabled'); - await click(previewSwitch.x + 55, previewSwitch.y); + const previewSwitch = await json("__gm.inspectorField('Enabled')"); + await click(previewSwitch.x, previewSwitch.y); await sleep(200); info = await gameInfo(); check( diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs index a2c8d00a..fadd970d 100644 --- a/tools/e2e/09-containers.mjs +++ b/tools/e2e/09-containers.mjs @@ -324,8 +324,8 @@ export async function containers(t) { (await noticeAfter()).includes('container template'), await noticeAfter(), ); - const parentRow = await locate('Parent'); - await click(parentRow.x + 55, parentRow.y); + const parentRow = await json("__gm.inspectorField('Parent')"); + await click(parentRow.x, parentRow.y); await sleep(150); check( 'the Parent picker does not even open for resolved content', @@ -393,8 +393,8 @@ export async function containers(t) { await selectObject(redC); // Unpacking keeps the derived ids, so an id that merely *looks* like content of a template must // not freeze the picker any more – the inspector has to read the holder, not the id. - const unpackedParentRow = await locate('Parent'); - await click(unpackedParentRow.x + 55, unpackedParentRow.y); + const unpackedParentRow = await json("__gm.inspectorField('Parent')"); + await click(unpackedParentRow.x, unpackedParentRow.y); await sleep(150); check( 'unpacked content offers the Parent picker like any ordinary object', diff --git a/tools/e2e/10-text-and-enabled.mjs b/tools/e2e/10-text-and-enabled.mjs index 8e7db2dc..69368a6d 100644 --- a/tools/e2e/10-text-and-enabled.mjs +++ b/tools/e2e/10-text-and-enabled.mjs @@ -37,14 +37,13 @@ export async function textAndEnabled(t) { // 17a. The enabled switch: every object has it, switching it off is a normal override, and a // switched-off object disappears from the scene view too (it stays in the hierarchy). - const enabledRow = await evaluate(`__gm.locate('Enabled')`); + const enabledRow = await json("__gm.inspectorField('Enabled')"); check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow)); const visibleBefore = (await json('__gm.viewportInfo()')).visibleItems; const gizmoOf = async (id) => (await json('__gm.gizmos()')).find((g) => g.id === id) ?? null; const gizmoOn = await gizmoOf('otext'); check('a selected object is framed at full strength', gizmoOn?.alpha === 1, JSON.stringify(gizmoOn)); - // The checkbox sits right of the 64 px label column; locate() gives the label's text centre. - await click(enabledRow.x + 55, enabledRow.y); + await click(enabledRow.x, enabledRow.y); await sleep(100); const textObject = await object('otext'); check( diff --git a/tools/e2e/11-menus.mjs b/tools/e2e/11-menus.mjs index c1ceed79..7c0e7b4c 100644 --- a/tools/e2e/11-menus.mjs +++ b/tools/e2e/11-menus.mjs @@ -26,9 +26,8 @@ export async function menus(t) { // 18. A picker with 40+ entries is capped in height and stays fully on screen instead of // running off the bottom edge. - // The dropdown sits right of the 64 px label column; locate() gives the label's text centre. - const parentRow = await locate('Parent'); - await click(parentRow.x + 55, parentRow.y); + const parentRow = await json("__gm.inspectorField('Parent')"); + await click(parentRow.x, parentRow.y); const rect = await json('__gm.overlayRect()'); const screenHeight = await json('window.innerHeight'); check( diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs index 79bfb6e3..80358c00 100644 --- a/tools/e2e/21-list.mjs +++ b/tools/e2e/21-list.mjs @@ -122,6 +122,15 @@ export async function listTab(t) { listy.variables[0].items === 'number' && JSON.stringify(listy.props.nums) === '[1,2]', JSON.stringify(listy.props.nums), ); + // The picker sits in the mark column, so it may not push the value off the inspector's grid. + const lefts = await json( + `[__gm.inspectorField('Name')?.left ?? null, __gm.inspectorField('nums')?.left ?? null]`, + ); + check( + 'the picking mark keeps the list on the column every row shares', + lefts[0] !== null && lefts[0] === lefts[1], + JSON.stringify(lefts), + ); // 7. A list whose target is gone empties the tab rather than showing a stale one. await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })"); diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs index ed200cfc..18b37caf 100644 --- a/tools/e2e/22-struct.mjs +++ b/tools/e2e/22-struct.mjs @@ -8,6 +8,10 @@ export async function structVariable(t) { /** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */ const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`); const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece"); + /** Left edge of each row's control – one column grid means one number, in the browser's own font. */ + const leftsOf = (labels) => + json(`[${labels.map((l) => `__gm.inspectorField(${JSON.stringify(l)})?.left ?? null`).join(',')}]`); + const oneColumn = (xs) => xs.every((x) => x !== null && x === xs[0]); // A throwaway behavior carrying one struct variable; removed again at the end of the step. await mutate(` @@ -39,6 +43,12 @@ export async function structVariable(t) { check('a struct variable carries the add-a-field control', (await find('+ Field')) !== null); check('and a row for each declared field', (await find('x')) !== null && (await find('tag')) !== null); check('whose value is shown, not a summary of the bundle', (await find('30.000')) !== null); + const assetLefts = await leftsOf(['Name', 'Run in editor', 'x', 'tag']); + check( + 'a field value starts on the same column as every other row', + oneColumn(assetLefts), + JSON.stringify(assetLefts), + ); await shot('01-struct-rows'); // 2. Writing one field keeps the others. Two edits in a row without a rebuild in between is @@ -183,6 +193,12 @@ export async function structVariable(t) { JSON.stringify({ fieldMark, fieldName, fieldValue }), ); check('the shape itself cannot be changed from here', (await find('+ Field')) === null); + const placedLefts = await leftsOf(['Name', 'Run in editor', 'Offset X', 'column', 'tag']); + check( + 'and its values share the column of position, offset and the switches', + oneColumn(placedLefts) && placedLefts[0] === assetLefts[0], + JSON.stringify({ placedLefts, asset: assetLefts[0] }), + ); // Typing over a name has to do nothing: a Label is not a TextField, and the declaration is // the asset's. Plain letters are safe here – the only shortcuts are modified ones and Delete.