diff --git a/README.md b/README.md index 5c17601a..cf7fdafb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1591 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1596 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/objectRefs.ts b/src/core/model/objectRefs.ts index 3048820f..59f802bf 100644 --- a/src/core/model/objectRefs.ts +++ b/src/core/model/objectRefs.ts @@ -43,6 +43,8 @@ export function referenceCandidates(fromId: string, ref: string): string[] { export interface ObjectChoice { /** What the reference stores. */ ref: string; + /** The object as the view of the holder knows it – where the eyedropper finds it (#276). */ + objectId: string; label: string; depth: number; } @@ -60,7 +62,7 @@ export function objectChoices(project: Project, holder: ObjectTree, objectId: st for (const { object, depth } of treeOrder({ ...holder, objects })) { if (!object.id.startsWith(scope)) continue; const label = objectName(object, findAsset(project, object.assetId)); - choices.push({ ref: object.id.slice(scope.length), label, depth }); + choices.push({ ref: object.id.slice(scope.length), objectId: object.id, label, depth }); } if (!scope) return choices; // The container's own content sits one level below it in the tree. diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index cd4e4aec..654da743 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -49,6 +49,7 @@ import { GraphEditor } from './graph/GraphEditor'; import { Inspector } from './inspector/Inspector'; import { valuesTitle, VariablesEditor } from './variables/VariablesEditor'; import { valuesKey } from './variables/target'; +import { pickView } from './objectPick'; import { AnimationEditor } from './animation/AnimationEditor'; import { TileEditor } from './tile/TileEditor'; import { AssetPanel } from './panels/AssetPanel'; @@ -506,6 +507,38 @@ export class Editor implements PlayHost { this.showTab('scene'); }); state.on('mode', () => this.editorRun.invalidate()); + state.on('pick', () => this.followPick()); + } + + /** The tab the eyedropper was started from, when it had to leave it for the view it picks in. */ + private pickReturn: { tab: TabId; doc: DocumentId | null } | null = null; + + /** + * Brings up the view the eyedropper picks in (#276) – a field in a Variables tab has none beside + * it – and goes back there once it is taken or cancelled. Dropped, it stays: the user went elsewhere. + */ + private followPick(): void { + const pick = this.state.objectPick; + if (!pick) { + const back = this.pickReturn; + this.pickReturn = null; + if (!back || this.state.pickEnd === 'drop') return; + if (back.doc) this.openDocument(back.doc); + else this.showTab(back.tab); + return; + } + if (this.showsPick()) return; + this.pickReturn ??= { tab: this.tab, doc: this.documents.active }; + if (pickView(this.state, pick.holderId) === 'container') this.openDocument(`asset:${pick.holderId}`); + else this.showTab('scene'); + } + + /** Whether the tab showing is the view the running eyedropper picks in. */ + private showsPick(): boolean { + const pick = this.state.objectPick; + if (!pick) return false; + if (this.tab === 'container') return this.containerEditor.currentAssetId === pick.holderId; + return this.tab === 'scene' && pickView(this.state, pick.holderId) === 'scene'; } /** Ctrl+V lands in whatever the visible tab edits (#240). */ @@ -797,10 +830,15 @@ export class Editor implements PlayHost { this.syncTilemapPanel(); this.tabs.setActive(strip); this.centre.relayout(); + // Hidden, the view was left out when its row was laid out – the eyedropper's bar may have + // come up over it meanwhile – and its pane may come back at the same size and not relayout. + if (tab === 'scene') this.sceneRow.relayout(); // Hidden, it skipped every change; its pane may come back at the same size and not relayout. this.gameView.redraw(); this.viewport.catchUp(); ui().focus.validate(); + // Picking goes on in its own view only; another tab brought up ends it without writing. + if (this.state.objectPick && !this.showsPick()) this.state.endObjectPick('drop'); } /** The tilemap panel belongs to the scene tab and needs a map to paint; otherwise the view gets its room. */ diff --git a/src/editor/container/ContainerEditor.ts b/src/editor/container/ContainerEditor.ts index c30088ce..078c2e2b 100644 --- a/src/editor/container/ContainerEditor.ts +++ b/src/editor/container/ContainerEditor.ts @@ -1,15 +1,24 @@ import type { Asset, ContainerAsset } from '../../core/model/Asset'; import { AssetTabEditor } from '../AssetTabEditor'; import type { EditorState } from '../state'; +import { PickStrip } from '../viewport/PickStrip'; import { ContainerCanvas } from './ContainerCanvas'; /** Room kept for the "N objects" readout, wide enough for a three-digit template. */ const INFO_WIDTH = 110; export class ContainerEditor extends AssetTabEditor { + readonly pickStrip: PickStrip; + constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); - this.mount(new ContainerCanvas(state)); + this.pickStrip = new PickStrip(state, () => this.currentAssetId); + this.mount(new ContainerCanvas(state), [], this.pickStrip); + } + + override showAsset(assetId: string): void { + super.showAsset(assetId); + this.pickStrip.sync(); } /** The pending drop of an asset dragged over the template (test hook). */ diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index 5d6cdc7e..1d0144af 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -81,6 +81,26 @@ export function sceneHooks({ state, editor }: HookContext) { objectRect: (id: string) => viewport.objectRect(id), /** What a click at a screen point picks from in the Scene tab, front to back (#203). */ sceneStack: (x: number, y: number) => viewport.stackInfo(x, y), + /** + * The eyedropper of an object's place (#276): whether it picks, what it fills, its candidate and + * the object outlined as that in the view, the bar over the view with its two buttons, the tab + * showing – and how the last one ended. + */ + objectPick: () => { + const pick = state.objectPick; + const strip = [scenePane.pickStrip, containerEditor.pickStrip].find((s) => shownOnStage(s)); + return { + picking: pick !== null, + name: pick?.name ?? null, + candidate: pick?.candidate ?? null, + outline: viewport.pickOutline() ?? containerEditor.canvas.pickOutline(), + strip: strip + ? { text: strip.shownText, take: centreOf(strip.takeButton), cancel: centreOf(strip.cancelButton) } + : null, + tab: editor.tab, + ended: state.pickEnd, + }; + }, /** The same for the Game tab – through the camera, so what the player sees. */ gameScreen: (id: string) => gameView.objectScreen(id), gameRect: (id: string) => gameView.objectRect(id), diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 339defff..ab17265b 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -38,6 +38,11 @@ export function uiHooks({ app, state, editor }: HookContext) { * fixed offset from the label's own text is the wrong way to click one. */ const fieldOf = (label: string, last = false): { x: number; y: number; left: number } | null => { + const control = controlOf(label, last); + return control && { ...centreOf(control), left: control.getGlobalPosition().x }; + }; + /** The control right of the row label `label`, as `fieldOf` finds it. */ + const controlOf = (label: string, last: boolean): Widget | null => { let found: Text | null = null; const walk = (node: Container): void => { if ((found !== null && !last) || !node.visible) return; @@ -54,8 +59,7 @@ export function uiHooks({ app, state, editor }: HookContext) { const row = owner?.parent; if (!row || !owner) return null; const control = row.children[row.children.indexOf(owner) + 1]; - if (!(control instanceof Widget)) return null; - return { ...centreOf(control), left: control.getGlobalPosition().x }; + return control instanceof Widget ? control : null; }; /** * The visible text of the overlay layer, in drawing order – everything an open popup shows. @@ -145,6 +149,11 @@ export function uiHooks({ app, state, editor }: HookContext) { return (last ? hits.at(-1) : hits[0]) ?? null; }, inspectorField: fieldOf, + /** The eyedropper beside the object field of a row (#276): where to press it, and whether it is lit. */ + inspectorEyedropper: (label: string, last = false) => { + const button = controlOf(label, last)?.children.at(-1); + return button instanceof Button ? { ...centreOf(button), lit: button.toggled } : null; + }, /** * The label of an inspector row as a link (#221): its centre, colour and whether it is underlined * and takes presses, and a text field right of it (value, read-only). Searched in the inspector diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 593bbedd..5191c81b 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -52,6 +52,7 @@ import { import { ROW_ACTION_ICON_SVG } from '../panels/markIcons'; import { InlineList, ListHead } from '../list/InlineList'; import type { ListAccess } from '../list/slot'; +import type { ObjectPicker } from '../objectPick'; /** * What a field needs beyond its own value: the project, to resolve an asset reference to a name @@ -72,6 +73,8 @@ export interface FieldContext { * scene to pick from; without it the place shows "(null)". */ objectChoices?: () => ObjectChoice[]; + /** Picks an object's place in the view instead (#276); without it the dropdown stands alone. */ + objectPicker?: ObjectPicker; } export interface FieldHandlers { @@ -457,8 +460,9 @@ function referenceField( /** * "Target: ▾" – another object of the same scene, or none (#258). Where there is no scene – * on an asset – nothing can be picked, and a graph is what fills the place while the game runs. + * Beside the dropdown the eyedropper picks it in the view (#276); `name` is what its strip calls it. */ -function objectField(ctx: FieldContext, value: PropertyValue, handlers: FieldHandlers): Field { +function objectField(ctx: FieldContext, name: string, value: PropertyValue, handlers: FieldHandlers): Field { const choices = ctx.objectChoices; if (!choices) return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} }; // A list item arrives unwrapped, as the id alone. @@ -481,14 +485,36 @@ function objectField(ctx: FieldContext, value: PropertyValue, handlers: FieldHan // Searchable like the parent picker: it lists every object of the scene. new Menu(items, { searchable: true }).openBelow(button); }; - return { - widget: button, - grow: true, - setValue: (v) => { - current = idOf(v); - button.setText(labelOf()); - }, + const setValue = (v: PropertyValue) => { + current = idOf(v); + button.setText(labelOf()); }; + const picker = ctx.objectPicker; + if (!picker) return { widget: button, grow: true, setValue }; + const eyedropper = new Button('', { + variant: 'ghost', + minWidth: Theme.controlHeight, + icon: ROW_ACTION_ICON_SVG.eyedropper, + toggle: true, + }); + eyedropper.onClick = () => { + picker.toggle(eyedropper, { + field: name, + current, + take: (ref) => handlers.onChange(objectReference(ref)), + ended: () => { + if (!eyedropper.destroyed) eyedropper.setToggled(false); + }, + }); + const picking = picker.picking(eyedropper); + eyedropper.setToggled(picking); + // Enter is the pick's now, not a press of the button that has the focus – this one, say. + if (picking) ui().focus.blur(); + }; + const line = new HStack({ gap: Theme.gap, align: 'center' }); + line.add(button, 1); + line.add(eyedropper); + return { widget: line, grow: true, setValue }; } /** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */ @@ -496,12 +522,16 @@ export function structFieldValue(value: PropertyValue, field: StructFieldDef): P return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field); } -/** The bare widget for a property definition (no label, no reset). */ +/** + * The bare widget for a property definition (no label, no reset). `name` is what the value is + * called where it sits – a struct field's is its bundle's and its own key. + */ export function createField( ctx: FieldContext, def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, + name = def.label, ): Field { switch (def.kind) { case 'number': { @@ -555,7 +585,13 @@ export function createField( const { row } = labelledRow(field.key); const own = field.kind === 'struct' ? nestedStructHandlers(handlers, field.key) : per([field.key], field); - const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), own); + const sub = createField( + ctx, + fieldDef(field), + structFieldValue(value, field), + own, + `${name} · ${field.key}`, + ); addValue(row, sub); fields.set(field.key, sub); stack.add(withBelow(row, sub, 1)); @@ -570,7 +606,7 @@ export function createField( }; } case 'object': - return objectField(ctx, value, handlers); + return objectField(ctx, name, value, handlers); case 'boolean': { const field = new Checkbox(Boolean(value)); field.onChange = handlers.onChange; diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index c62907a1..ccb6f75f 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -63,6 +63,7 @@ import { parentRow } from './parentRow'; import { listSource, previewingHandlers, structFields } from './previewHandlers'; import { overrideValue, tilemapSizeValue } from './valueSpecs'; import { buildVariable } from './variableRows'; +import { objectPicker } from '../objectPick'; import { currentProps, currentRef, @@ -243,6 +244,7 @@ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: st const values = resolveSceneObjectProps(project, asset, object); const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values)); host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId); + host.objectPicker = objectPicker(host.state, holderId, objectId, host.objectChoices); const behavior = asset.type === 'behavior'; if (behavior) { const header = new HStack({ gap: Theme.gap, align: 'center' }); diff --git a/src/editor/list/InlineList.ts b/src/editor/list/InlineList.ts index 22d56667..0af3f260 100644 --- a/src/editor/list/InlineList.ts +++ b/src/editor/list/InlineList.ts @@ -49,7 +49,7 @@ import { structItemOf, } from '../../core/properties/listItems'; import { flatDef, type StructFieldDef } from '../../core/properties/PropertySchema'; -import { valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath'; +import { pathLabel, valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath'; import { addValue, createField, @@ -403,6 +403,7 @@ export class InlineList extends VStack { flatDef(key, kind as FieldKind), untag(value) ?? '', access.handlersAt(path), + [access.def.label, ...pathLabel(path)].join(' · '), ); } diff --git a/src/editor/objectPick.ts b/src/editor/objectPick.ts new file mode 100644 index 00000000..2e9a3a33 --- /dev/null +++ b/src/editor/objectPick.ts @@ -0,0 +1,103 @@ +/** + * The eyedropper of an object's place (#276): instead of the dropdown, a click in the view of the + * holder names the target. A click only makes a candidate – one on the candidate passes on to the + * object behind it, as a click does with the selection – and Enter or Take writes it, one undo step + * like the dropdown. The pick lives in `EditorState`; the view, the strip over it and the editor's + * tabs follow it from there. + */ +import type { ObjectChoice } from '../core/model/objectRefs'; +import { findAsset } from '../core/model/Project'; +import { objectName } from '../core/model/SceneObject'; +import { pickOn } from '../viewport/pick'; +import { shownObject } from './inspector/objectQueries'; +import type { EditorState } from './state'; + +export interface ObjectPick { + /** Whatever started it – its button tells a second press, which cancels, by this. */ + owner: object; + /** The scene or container the pick happens in, shown by its own view. */ + holderId: string; + /** What the strip says is being filled: "Watcher · target". */ + name: string; + /** What the place names now, as it stores it. */ + current: string; + /** What may be picked, asked anew each time: the dropdown's choices. */ + choices: () => ObjectChoice[]; + /** The object picked so far, as the view knows it. */ + candidate: string | null; + /** Writes the place – what picking in the dropdown does. */ + take: (ref: string) => void; + /** Called once when it is over, written or not. */ + ended: () => void; +} + +/** How a pick ended: written, cancelled by the user, or dropped because the editor moved on. */ +export type PickEnd = 'take' | 'cancel' | 'drop'; + +/** What a field hands over to pick for it; the rest is the host's. */ +export interface PickRequest { + /** The field's own name, "target" or "crowd · [2]". */ + field: string; + current: string; + take: (ref: string) => void; + ended: () => void; +} + +/** How a field starts and stops the eyedropper (see `FieldContext.objectPicker`). */ +export interface ObjectPicker { + /** Starts picking for `request`, or – when `owner` is picking already – cancels. */ + toggle(owner: object, request: PickRequest): void; + picking(owner: object): boolean; +} + +/** The eyedropper of the places on one placed object; `choices` is what its dropdowns offer. */ +export function objectPicker( + state: EditorState, + holderId: string, + objectId: string, + choices: () => ObjectChoice[], +): ObjectPicker { + return { + picking: (owner) => state.objectPick?.owner === owner, + toggle: (owner, request) => { + if (state.objectPick?.owner === owner) return state.endObjectPick('cancel'); + if (!pickView(state, holderId)) { + state.setNotice('Open the scene this object is in to pick from it'); + return; + } + const shown = shownObject(state.project, holderId, objectId); + if (!shown) return; + state.startObjectPick({ + owner, + holderId, + name: `${objectName(shown.object, shown.asset)} · ${request.field}`, + current: request.current, + choices, + candidate: choices().find((c) => c.ref === request.current)?.objectId ?? null, + take: request.take, + ended: request.ended, + }); + }, + }; +} + +/** Which view shows `holderId` for picking: the scene tab for the scene open, a container's tab for it. */ +export function pickView(state: EditorState, holderId: string): 'scene' | 'container' | null { + if (state.activeScene?.id === holderId) return 'scene'; + return findAsset(state.project, holderId)?.type === 'container' ? 'container' : null; +} + +/** A click of the eyedropper on what lies under the pointer, front to back. */ +export function pickAt(state: EditorState, stack: readonly string[]): void { + const pick = state.objectPick; + if (!pick) return; + const offered = new Set(pick.choices().map((c) => c.objectId)); + const next = pickOn(stack, (id) => offered.has(id), pick.candidate); + if (next.refused) state.setNotice(`Not something ${pick.name} can name – the dropdown lists what can`); + state.movePick(next.candidate); +} + +/** The candidate as the dropdown names it; empty while there is none. */ +export function candidateLabel(pick: ObjectPick): string { + return pick.choices().find((c) => c.objectId === pick.candidate)?.label ?? ''; +} diff --git a/src/editor/panels/icons/eyedropper.svg b/src/editor/panels/icons/eyedropper.svg new file mode 100644 index 00000000..e11400f2 --- /dev/null +++ b/src/editor/panels/icons/eyedropper.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/editor/panels/markIcons.ts b/src/editor/panels/markIcons.ts index d86bdcfc..a3c213f3 100644 --- a/src/editor/panels/markIcons.ts +++ b/src/editor/panels/markIcons.ts @@ -6,6 +6,7 @@ import type { Kind } from '../../core/properties/kinds'; import apply from './icons/apply.svg?raw'; import arrowIn from './icons/arrow-in.svg?raw'; import arrowOut from './icons/arrow-out.svg?raw'; +import eyedropper from './icons/eyedropper.svg?raw'; import fitHeight from './icons/fit-height.svg?raw'; import fitWidth from './icons/fit-width.svg?raw'; import animation from './icons/kind-animation.svg?raw'; @@ -38,7 +39,7 @@ export const KIND_ICON_SVG: Partial> = { texture, }; -export const ROW_ACTION_ICON_SVG = { apply, reset, fitWidth, fitHeight }; +export const ROW_ACTION_ICON_SVG = { apply, reset, fitWidth, fitHeight, eyedropper }; /** * A behavior variable's two switches: kept from one call to the next (#229), hidden from everything diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 9912a2c8..8b6570c5 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -23,6 +23,11 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): return ui().focus.onGlobalKey((event) => { const mod = event.ctrlKey || event.metaKey; const key = event.key.toLowerCase(); + // The eyedropper's two keys come first (#276): Enter writes its candidate, Escape leaves without. + if (state.objectPick && !mod && (event.key === 'Enter' || event.key === 'Escape')) { + state.endObjectPick(event.key === 'Enter' ? 'take' : 'cancel'); + return true; + } if (event.key === 'Escape' && state.mode === 'play') { actions.stop(); return true; diff --git a/src/editor/state.ts b/src/editor/state.ts index 7e0990be..86720104 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -1,5 +1,6 @@ import type { KeyClipboard } from '../core/commands/animationCommands'; import type { EditorClipboard } from './clipboard'; +import type { ObjectPick, PickEnd } from './objectPick'; import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation'; import { type Command, CommandError } from '../core/commands/Command'; import { CommandStack } from '../core/commands/CommandStack'; @@ -188,6 +189,8 @@ export type EditorEvent = | 'mode' | 'save' | 'notice' + /** The eyedropper of an object's place started, moved its candidate or ended (`objectPick`). */ + | 'pick' /** The running world advanced a frame (play mode): values may have changed without a command. */ | 'runtime'; @@ -230,6 +233,10 @@ export class EditorState { pingedAssetId: string | null = null; /** Objects the scene view is dragging: a running game holds their bodies to the pointer. */ heldObjectIds: ReadonlySet = new Set(); + /** The eyedropper of an object's place while it runs (#276). */ + objectPick: ObjectPick | null = null; + /** How the last one ended – what a listener of the `pick` event finds once it is gone. */ + pickEnd: PickEnd | null = null; /** The real (saved) model and its undo stack while `project` is the play-mode copy. */ private edit: { project: Project; commands: CommandStack } | null = null; @@ -250,6 +257,7 @@ export class EditorState { } loadProject(project: Project, revision: number): void { + this.endObjectPick('drop'); this.edit = null; this.mode = 'edit'; this.paused = false; @@ -270,6 +278,7 @@ export class EditorState { /** Returns the runtime copy; the selection survives because ids are identical. */ enterPlay(): Project { if (this.mode === 'play') return this.project; + this.endObjectPick('drop'); const copy = deserializeProject(serializeProject(this.project), this.project.id); this.edit = { project: this.project, commands: this.commands }; this.project = copy; @@ -290,6 +299,7 @@ export class EditorState { exitPlay(): void { if (this.mode !== 'play') return; + this.endObjectPick('drop'); if (this.edit) { this.project = this.edit.project; this.commands = this.edit.commands; @@ -357,10 +367,12 @@ export class EditorState { } undo(): void { + this.endObjectPick('drop'); this.runEdit(() => this.commands.undo()); } redo(): void { + this.endObjectPick('drop'); this.runEdit(() => this.commands.redo()); } @@ -472,10 +484,44 @@ export class EditorState { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition selection = trackSelection(selection.assetId, selection.keys ?? [], selection.trackId); if (sameSelection(this.selection, selection)) return; + this.endObjectPick('drop'); this.selection = selection; this.emit('selection'); } + /** Starts the eyedropper of an object's place (#276); one running already is dropped for it. */ + startObjectPick(pick: ObjectPick): void { + const before = this.objectPick; + this.objectPick = pick; + this.pickEnd = null; + before?.ended(); + this.emit('pick'); + } + + /** Moves the eyedropper's candidate; nothing is written until `endObjectPick('take')`. */ + movePick(candidate: string | null): void { + const pick = this.objectPick; + if (!pick || pick.candidate === candidate) return; + pick.candidate = candidate; + this.emit('pick'); + } + + /** + * Ends the eyedropper. Only `take` writes, and only a candidate the dropdown still offers and that + * is not the target already – a pick that changes nothing leaves no undo step. + */ + endObjectPick(how: PickEnd): void { + const pick = this.objectPick; + if (!pick) return; + this.objectPick = null; + this.pickEnd = how; + pick.ended(); + this.emit('pick'); + if (how !== 'take') return; + const ref = pick.choices().find((c) => c.objectId === pick.candidate)?.ref; + if (ref !== undefined && ref !== pick.current) pick.take(ref); + } + toggleObject(holderId: string, objectId: string): void { const s = this.selection; const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : []; diff --git a/src/editor/variables/VariablesEditor.ts b/src/editor/variables/VariablesEditor.ts index fdc3d3e0..3174481a 100644 --- a/src/editor/variables/VariablesEditor.ts +++ b/src/editor/variables/VariablesEditor.ts @@ -20,6 +20,7 @@ import { } from '../inspector/objectSection'; import { InlineList, ListHead } from '../list/InlineList'; import { type LiveVariables, resolveList } from '../list/slot'; +import { objectPicker } from '../objectPick'; import type { EditorState, ValuesTarget } from '../state'; /** The asset or placed object whose variables `target` shows, when it still has any to show. */ @@ -141,8 +142,15 @@ export class VariablesEditor extends VStack { private buildList(slot: Extract['slot']): void { const access = () => resolveList(this.state, slot, this.liveVariables); - if (slot.kind === 'object') + if (slot.kind === 'object') { this.host.objectChoices = () => pickableObjects(this.state.project, slot.holderId, slot.objectId); + this.host.objectPicker = objectPicker( + this.state, + slot.holderId, + slot.objectId, + this.host.objectChoices, + ); + } const items = new InlineList(this.host, access); const head = new ListHead(access, (kind) => items.addItem(kind), false); items.head = head; diff --git a/src/editor/viewport/PickStrip.ts b/src/editor/viewport/PickStrip.ts new file mode 100644 index 00000000..4bd0ff56 --- /dev/null +++ b/src/editor/viewport/PickStrip.ts @@ -0,0 +1,46 @@ +import { Button, Label, VStack } from '../../ui'; +import { candidateLabel } from '../objectPick'; +import type { EditorState } from '../state'; +import { TabHeader } from '../TabHeader'; + +/** + * The bar over a view while the eyedropper picks in it (#276): what is being filled, the candidate, + * and the two ways out. Hidden otherwise, and over any view but the one the pick happens in – the + * field it fills may be in a tab that is not showing now. + */ +export class PickStrip extends TabHeader { + private readonly text = new Label('', { style: 'bodyBold' }); + /** Public for the check scripts. */ + readonly takeButton: Button; + readonly cancelButton: Button; + + constructor( + private readonly state: EditorState, + private readonly holderId: () => string | undefined, + ) { + super(); + this.takeButton = new Button('Take', { variant: 'primary', onClick: () => state.endObjectPick('take') }); + this.cancelButton = new Button('Cancel', { onClick: () => state.endObjectPick('cancel') }); + this.add(this.text, 1); + this.add(this.takeButton); + this.add(this.cancelButton); + this.visible = false; + state.on('pick', () => this.sync()); + } + + /** What the bar says, empty while it is hidden (test hook). */ + get shownText(): string { + return this.visible ? this.text.text : ''; + } + + /** Also for a view that changed what it shows: the pick may be in the one it shows now. */ + sync(): void { + const pick = this.state.objectPick; + const shown = pick !== null && pick.holderId === this.holderId(); + if (pick && shown) + this.text.setText(`Target for ${pick.name}: ${candidateLabel(pick) || 'click an object'}`); + if (shown === this.visible) return; + this.visible = shown; + if (this.parent instanceof VStack) this.parent.relayout(); + } +} diff --git a/src/editor/viewport/ScenePane.ts b/src/editor/viewport/ScenePane.ts index 9103a5b7..b7bbf52a 100644 --- a/src/editor/viewport/ScenePane.ts +++ b/src/editor/viewport/ScenePane.ts @@ -3,11 +3,16 @@ import { SceneVisibilityPopup } from '../panels/SceneVisibilityPopup'; import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; import type { EditorState } from '../state'; import { TabHeader } from '../TabHeader'; +import { PickStrip } from './PickStrip'; import type { SceneViewport } from './SceneViewport'; -/** The scene tab: a header with Fit and the eye above the scene view and its tilemap panel. */ +/** + * The scene tab: a header with Fit and the eye above the scene view and its tilemap panel, and the + * eyedropper's bar between them while it picks here. + */ export class ScenePane extends VStack { readonly header = new TabHeader(); + readonly pickStrip: PickStrip; /** Public for the check scripts. */ readonly fitButton: Button; /** What the scene view shows of each type – symbols, names, collider outlines. */ @@ -22,7 +27,9 @@ export class ScenePane extends VStack { }); this.header.add(this.fitButton); this.header.add(this.visibilityButton); + this.pickStrip = new PickStrip(state, () => state.activeScene?.id); this.add(this.header); + this.add(this.pickStrip); this.add(body, 1); } } diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 909c33fb..aa00e633 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -34,6 +34,7 @@ import { collectHolderItems, type DrawItem, type TilemapSolids } from '../../vie import { sortRowOf } from '../../viewport/drawOrder'; import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick'; import { openObjectMenu } from '../objectMenu'; +import { pickAt } from '../objectPick'; import { type LayerFocus, layerAlpha } from '../../viewport/tilemap'; import { shows, type VisibilityPart } from '../../core/model/sceneVisibility'; import { dashedLine } from '../../viewport/symbols'; @@ -149,6 +150,7 @@ export class SceneViewport extends FreeViewport { private marquee: Marquee | null = null; private dropPreview: DropPreview | null = null; private lastGizmos: GizmoInfo[] = []; + private lastPickOutline: string | null = null; /** Ids that got an owner link this frame (test hook). */ private lastLinks: string[] = []; /** Last place the pointer was seen, in global coordinates – where a paste lands. */ @@ -174,6 +176,7 @@ export class SceneViewport extends FreeViewport { this.resetView(); this.redraw(); }); + state.on('pick', () => this.redraw()); // A drag can end anywhere – over another panel, off the canvas – and this view never hears // about it, so the preview would stay on screen without this. onDragChange(() => { @@ -293,6 +296,11 @@ export class SceneViewport extends FreeViewport { this.startPan(e); return; } + if (this.picking()) { + // Empty space holds nothing to pick: a click there keeps the candidate. + if (e.button === 2) this.state.endObjectPick('cancel'); + return; + } if (e.button === 2 && e.target === this) { this.openEmptyMenu(e); return; @@ -471,6 +479,10 @@ export class SceneViewport extends FreeViewport { * layer below a sprite does not put the map in front of it. */ protected override onItemPointerDown(_objectId: string, e: FederatedPointerEvent): void { + if (this.picking()) { + this.pickUnder(e); + return; + } if (e.button === 2) { this.openMenuUnder(e); return; @@ -517,6 +529,26 @@ export class SceneViewport extends FreeViewport { }; } + /** Whether the eyedropper picks in this view (#276) – nothing but panning and zooming does then. */ + private picking(): boolean { + const pick = this.state.objectPick; + return pick !== null && pick.holderId === this.holder()?.id; + } + + /** A press on an object while picking: a click moves the candidate, a right-click cancels, Shift pans. */ + private pickUnder(e: FederatedPointerEvent): void { + if (e.button === 0 && e.shiftKey) this.startPan(e); + else if (e.button === 0) pickAt(this.state, this.stackUnder(e)); + else if (e.button === 2) this.state.endObjectPick('cancel'); + else return; + e.stopPropagation(); + } + + /** The object the eyedropper's candidate outline is drawn round, null for none (test hook). */ + pickOutline(): string | null { + return this.lastPickOutline; + } + /** The hierarchy's menu for what is under the pointer (#204); empty space opens none. */ private openMenuUnder(e: FederatedPointerEvent): void { const source = this.holder(); @@ -638,6 +670,15 @@ export class SceneViewport extends FreeViewport { protected override afterItems(items: DrawItem[], view: View): void { this.placeIcons(items, view.scale); this.drawParentLinks(items, view.scale); + this.syncCursors(); + } + + /** A crosshair over everything while the eyedropper picks here; Pixi shows the cursor of what is hit. */ + private syncCursors(): void { + const picking = this.picking(); + this.cursor = picking ? 'crosshair' : 'default'; + for (const node of [...this.nodes.values(), ...this.icons.values()]) + node.cursor = picking ? 'crosshair' : 'move'; } /** Grid, outlines and rays are drawn in screen pixels: these follow every pan. */ @@ -959,16 +1000,7 @@ export class SceneViewport extends FreeViewport { for (const id of s.objectIds) { const item = items.find((i) => i.id === id); if (!item) continue; - // Two screen pixels outside the footprint, whichever way it is turned. A symbol that is - // only its icon is outlined where the icon stands, which need not be its position, and - // at the icon's size. - const spot = ICON_ONLY.has(item.type) ? this.spots.get(id) : undefined; - const corners = this.screenCorners( - spot - ? iconCorners(item.type, item.props, spot, this.symbolScale(), 2 / view.scale) - : footprintCorners(item.type, item.props, item.m, 2 / view.scale, item.frame), - view, - ); + const corners = this.outlineOf(item, view, 2); // A switched-off object draws nothing, so its outline is the only way to see where it // sits – dimmed, because the empty frame would otherwise read as a drawing that failed. const alpha = item.enabled ? 1 : OFF_GIZMO_ALPHA; @@ -980,11 +1012,48 @@ export class SceneViewport extends FreeViewport { this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha }); } } - this.sizer.draw(this.gizmos, view); - this.corners.draw(this.gizmos, view); + const picking = this.drawPickCandidate(items, view); + if (!picking) { + this.sizer.draw(this.gizmos, view); + this.corners.draw(this.gizmos, view); + } this.drawDropPreview(view); + if (picking) return; this.painter.draw(this.gizmos, items, view); const m = this.marquee; if (m?.active) drawBand(this.gizmos, m.start, m.current); } + + /** + * The eyedropper's candidate, outlined further out than the selection and in its own colour – the + * object with the place stays selected all along. While it picks, no handle or brush is drawn: + * none of them takes a press then. False when it does not pick here. + */ + private drawPickCandidate(items: DrawItem[], view: View): boolean { + this.lastPickOutline = null; + if (!this.picking()) return false; + const id = this.state.objectPick?.candidate; + const item = id ? items.find((i) => i.id === id) : undefined; + if (!item) return true; + this.lastPickOutline = item.id; + this.gizmos + .poly(this.outlineOf(item, view, 5), true) + .stroke({ width: 2, color: Theme.colors.objectPick }); + return true; + } + + /** + * Screen corners `pad` pixels outside the footprint, whichever way it is turned. A symbol that is + * only its icon is outlined where the icon stands, which need not be its position, and at the + * icon's size. + */ + private outlineOf(item: DrawItem, view: View, pad: number): Vec2[] { + const spot = ICON_ONLY.has(item.type) ? this.spots.get(item.id) : undefined; + return this.screenCorners( + spot + ? iconCorners(item.type, item.props, spot, this.symbolScale(), pad / view.scale) + : footprintCorners(item.type, item.props, item.m, pad / view.scale, item.frame), + view, + ); + } } diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 946af131..49d53cc8 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -52,6 +52,8 @@ export const Theme = { /** Particle source icon and owner links; the same orange the graph gives the category. */ particles: 0xc26a1f, marquee: 0x4c8dff, + /** The eyedropper's candidate in the scene view (#276): the colour of an object port. */ + objectPick: 0x56ccf2, /** A value deviating from what it follows: an override on a placed object, a non-default on an asset. */ override: 0xf2c84b, /** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */ diff --git a/src/viewport/pick.ts b/src/viewport/pick.ts index eb6eaf65..9c4d43aa 100644 --- a/src/viewport/pick.ts +++ b/src/viewport/pick.ts @@ -56,6 +56,23 @@ export function pressOn( return { select: null, onClick: next === held ? null : next }; } +/** + * Where a click of the eyedropper moves its candidate (#276): the rule of a plain click, over what + * may be picked alone – a click on the candidate passes on to the next one below, round to the top + * again, a click elsewhere takes the topmost. Empty space, and a spot with nothing that may be + * picked, leave the candidate as it is; `refused` tells the second apart, so the user hears why. + */ +export function pickOn( + stack: readonly string[], + pickable: (id: string) => boolean, + candidate: string | null, +): { candidate: string | null; refused: boolean } { + const offered = stack.filter(pickable); + const press = pressOn(offered, candidate === null ? [] : [candidate], candidate); + if (!press) return { candidate, refused: stack.length > 0 }; + return { candidate: press.select ?? press.onClick ?? candidate, refused: false }; +} + /** * Which object a right-click on `stack` opens the menu for (#204), by the rule a press follows: * a selected one under the pointer keeps the selection, otherwise the topmost is selected first. diff --git a/tests/editor/object-pick.test.ts b/tests/editor/object-pick.test.ts new file mode 100644 index 00000000..86607e0e --- /dev/null +++ b/tests/editor/object-pick.test.ts @@ -0,0 +1,131 @@ +/** + * The eyedropper of an object's place (#276): a click in the view only moves a candidate, Enter or + * Take writes it as one undo step, and every other way out – cancelling, or the editor moving on – + * writes nothing. + */ +import { describe, expect, it } from 'vitest'; +import { RenameSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { objectChoices, objectReference, referencedId } from '../../src/core/model/objectRefs'; +import { findObjectHolder } from '../../src/core/model/Project'; +import { objectPicker, pickAt, pickView } from '../../src/editor/objectPick'; +import { EditorState, objectSelection } from '../../src/editor/state'; +import { makeBehavior, makeSpriteScene, place } from '../helpers'; + +/** A Watcher aimed at a Crate, a Barrel beside them, and the Watcher selected in the editor. */ +function setup() { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'Watcher'); + f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object')); + const watcher = place(f, behavior.id, { x: 0, y: 0 }); + const crate = place(f, f.spriteId, { x: 10, y: 0 }); + const barrel = place(f, f.spriteId, { x: 20, y: 0 }); + f.stack.execute(new RenameSceneObjectCommand(f.sceneId, watcher, 'Watcher')); + f.stack.execute(new RenameSceneObjectCommand(f.sceneId, crate, 'Crate')); + f.stack.execute(new SetOverrideCommand(f.sceneId, watcher, 'target', objectReference(crate))); + f.project.activeSceneId = f.sceneId; + const state = new EditorState(); + state.loadProject(f.project, 1); + state.select(objectSelection(f.sceneId, [watcher])); + const target = () => + referencedId( + findObjectHolder(state.project, f.sceneId)!.objects.find((o) => o.id === watcher)!.overrides.target, + ); + const choices = () => objectChoices(state.project, findObjectHolder(state.project, f.sceneId)!, watcher); + const picker = objectPicker(state, f.sceneId, watcher, choices); + const owner = {}; + let ended = 0; + const start = () => + picker.toggle(owner, { + field: 'target', + current: target(), + take: (ref) => + state.execute(new SetOverrideCommand(f.sceneId, watcher, 'target', objectReference(ref))), + ended: () => ended++, + }); + return { f, state, watcher, crate, barrel, target, picker, owner, start, endedCount: () => ended }; +} + +describe('the eyedropper of an object place (#276)', () => { + it('starts on the current target and names the field it fills', () => { + const s = setup(); + expect(pickView(s.state, s.f.sceneId)).toBe('scene'); + s.start(); + expect(s.state.objectPick?.candidate).toBe(s.crate); + expect(s.state.objectPick?.name).toBe('Watcher · target'); + expect(s.picker.picking(s.owner)).toBe(true); + }); + + it('moves only a candidate on a click, and Enter writes it as one undo step', () => { + const s = setup(); + s.start(); + pickAt(s.state, [s.crate, s.barrel]); + expect(s.state.objectPick?.candidate).toBe(s.barrel); + expect(s.target()).toBe(s.crate); + expect(s.state.commands.canUndo).toBe(false); + + s.state.endObjectPick('take'); + expect(s.target()).toBe(s.barrel); + expect(s.endedCount()).toBe(1); + s.state.undo(); + expect(s.target()).toBe(s.crate); + expect(s.state.commands.canUndo).toBe(false); + }); + + it('leaves no undo step when the candidate is the target already', () => { + const s = setup(); + s.start(); + s.state.endObjectPick('take'); + expect(s.state.pickEnd).toBe('take'); + expect(s.state.commands.canUndo).toBe(false); + }); + + it('writes nothing when cancelled – a second press of its button is that too', () => { + const s = setup(); + s.start(); + pickAt(s.state, [s.barrel]); + s.start(); + expect(s.state.objectPick).toBeNull(); + expect(s.state.pickEnd).toBe('cancel'); + expect(s.target()).toBe(s.crate); + expect(s.endedCount()).toBe(1); + }); + + it('keeps the candidate on empty space and on what it may not name, and says why for the latter', () => { + const s = setup(); + s.start(); + pickAt(s.state, []); + expect(s.state.objectPick?.candidate).toBe(s.crate); + expect(s.state.notice).toBe(''); + pickAt(s.state, ['not-in-this-scene']); + expect(s.state.objectPick?.candidate).toBe(s.crate); + expect(s.state.notice).toContain('Watcher · target'); + }); + + it('is dropped, unwritten, by another selection, an undo and play', () => { + for (const moveOn of [ + (state: EditorState) => state.select(null), + (state: EditorState) => state.undo(), + (state: EditorState) => void state.enterPlay(), + ]) { + const s = setup(); + s.start(); + pickAt(s.state, [s.barrel]); + moveOn(s.state); + expect(s.state.objectPick).toBeNull(); + expect(s.state.pickEnd).toBe('drop'); + expect(s.endedCount()).toBe(1); + s.state.exitPlay(); + expect(s.target()).toBe(s.crate); + expect(s.state.commands.canUndo).toBe(false); + } + }); + + it('does not start for a scene other than the one open', () => { + const s = setup(); + s.state.project.activeSceneId = ''; + s.start(); + expect(s.state.objectPick).toBeNull(); + expect(s.state.notice).toContain('Open the scene'); + }); +}); diff --git a/tests/viewport/pick.test.ts b/tests/viewport/pick.test.ts index e262f92a..1df0abae 100644 --- a/tests/viewport/pick.test.ts +++ b/tests/viewport/pick.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { DrawItem } from '../../src/viewport/items'; -import { menuTargetOf, pressOn, stackAt } from '../../src/viewport/pick'; +import { menuTargetOf, pickOn, pressOn, stackAt } from '../../src/viewport/pick'; /** Only what the pick order reads: the type, the `z`, and a map's layers with their own `z`. */ function item( @@ -102,6 +102,41 @@ describe('what a press picks (#203)', () => { }); }); +describe('where a click of the eyedropper moves its candidate (#276)', () => { + const stack = ['top', 'middle', 'bottom']; + const all = () => true; + + it('takes the topmost when the candidate is not under the pointer', () => { + expect(pickOn(stack, all, null)).toEqual({ candidate: 'top', refused: false }); + expect(pickOn(stack, all, 'elsewhere')).toEqual({ candidate: 'top', refused: false }); + }); + + it('passes a click on the candidate on to the next one below, and round to the top again', () => { + expect(pickOn(stack, all, 'top').candidate).toBe('middle'); + expect(pickOn(stack, all, 'middle').candidate).toBe('bottom'); + expect(pickOn(stack, all, 'bottom').candidate).toBe('top'); + }); + + it('goes over what may be picked only', () => { + const offered = (id: string) => id !== 'middle'; + expect(pickOn(stack, offered, 'top').candidate).toBe('bottom'); + expect(pickOn(['middle', 'bottom'], offered, null).candidate).toBe('bottom'); + }); + + it('keeps the candidate on empty space, without a word', () => { + expect(pickOn([], all, 'top')).toEqual({ candidate: 'top', refused: false }); + expect(pickOn([], all, null)).toEqual({ candidate: null, refused: false }); + }); + + it('keeps the candidate where nothing may be picked, and says so', () => { + expect(pickOn(['middle'], (id) => id !== 'middle', 'top')).toEqual({ candidate: 'top', refused: true }); + }); + + it('stays on the candidate when it is all that may be picked there', () => { + expect(pickOn(['only'], all, 'only').candidate).toBe('only'); + }); +}); + describe('what a right-click opens the menu for (#204)', () => { const stack = ['top', 'middle', 'bottom']; diff --git a/tools/e2e/57-object-references.mjs b/tools/e2e/57-object-references.mjs index 59951517..57eb18cf 100644 --- a/tools/e2e/57-object-references.mjs +++ b/tools/e2e/57-object-references.mjs @@ -4,9 +4,14 @@ * run reads the object itself. Removing that object drops the reference; undo brings it back. On the * behavior asset there is no scene, so the row still says "(null)". * + * The eyedropper beside the dropdown (#276) picks in the scene view instead: over two stacked + * objects a click takes the one in front and a second click the one behind, Enter writes it, Escape, + * a right-click and a second press of the eyedropper leave it as it was. From a list item in a + * Variables tab it goes to the scene view and comes back with Take. + * * Everything it adds carries an `or57` id and goes again at the end. */ -import { MAKE_OBJECT } from './lib/scaffold.mjs'; +import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; export async function objectReferences(t) { const { check, json, evaluate, mutate, click, press, typeText, sleep, shot, select, undo, stop } = t; @@ -120,6 +125,149 @@ export async function objectReferences(t) { JSON.stringify({ dropped, back }), ); + // 57e. The eyedropper (#276): two objects stacked on a lane of their own, the target back to + // None, and a list of one object item for 57g. + const Y = LANES.eyedropper; + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + ${MAKE_OBJECT} + const sprite = p.assets.find((a) => a.type === 'sprite'); + scene.objects.push(make('or57-back', sprite.id, '', ${Y}, 'Back 57')); + scene.objects.push(make('or57-front', sprite.id, '', ${Y}, 'Front 57')); + const aim = p.assets.find((a) => a.id === 'or57-aim'); + aim.variables.push({ key: 'crowd', label: 'crowd', kind: 'list', default: [] }); + aim.props.crowd = []; + const obj = scene.objects.find((o) => o.id === 'or57-obj'); + obj.overrides.target = { $object: '' }; + obj.overrides.crowd = [{ $object: '' }]; + `); + await select({ kind: 'object', holderId: sceneId, objectId: 'or57-obj' }); + await evaluate(`__gm.lookAt(0, ${Y})`); + await sleep(200); + const pick = () => json('__gm.objectPick()'); + const eyedropper = () => json("__gm.inspectorEyedropper('target')"); + const inInspector = async (text) => { + const box = await json('__gm.inspectorRect()'); + const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []; + return hits.some((p) => p.x >= box.x && p.x <= box.x + box.w && p.y >= box.y && p.y <= box.y + box.h); + }; + const spot = await json("__gm.objectScreen('or57-front')"); + const stack = await json(`__gm.sceneStack(${spot.x}, ${spot.y})`); + + await press_(await eyedropper(), 'the eyedropper of target'); + const started = await pick(); + const lit = (await eyedropper())?.lit; + check( + 'the eyedropper starts in the scene view, lit, and the bar over it names the field', + started.picking && + started.tab === 'scene' && + lit === true && + started.candidate === null && + started.strip?.text === 'Target for Aim 57 · target: click an object', + JSON.stringify({ started, lit }), + ); + + await click(spot.x, spot.y); + const first = await pick(); + await click(spot.x, spot.y); + const second = await pick(); + await shot('03-eyedropper'); + const empty = await json('__gm.emptySpot()'); + await click(empty.x, empty.y); + const afterEmpty = await pick(); + check( + 'a click takes the object in front, a second one the object behind, empty space keeps it, nothing is written', + JSON.stringify(stack) === JSON.stringify(['or57-front', 'or57-back']) && + first.candidate === 'or57-front' && + second.candidate === 'or57-back' && + second.outline === 'or57-back' && + second.strip?.text === 'Target for Aim 57 · target: Back 57' && + afterEmpty.candidate === 'or57-back' && + afterEmpty.picking && + JSON.stringify(await target()) === JSON.stringify({ $object: '' }), + JSON.stringify({ stack, first, second, afterEmpty }), + ); + + await press('Enter'); + const taken = await pick(); + const written = await target(); + const shown = await inInspector('Back 57'); + const litAfter = (await eyedropper())?.lit; + await undo(); + const undone = await target(); + check( + 'Enter writes the candidate, the row shows it, the eyedropper goes off, one undo takes it back', + !taken.picking && + taken.ended === 'take' && + taken.strip === null && + JSON.stringify(written) === JSON.stringify({ $object: 'or57-back' }) && + shown && + litAfter === false && + JSON.stringify(undone) === JSON.stringify({ $object: '' }), + JSON.stringify({ taken, written, shown, litAfter, undone }), + ); + + // 57f. Escape, a right-click in the view and a second press of the eyedropper leave it as it was. + const cancels = []; + for (const [how, leave] of [ + ['Escape', () => press('Escape')], + ['right-click', () => click(spot.x, spot.y, 'right')], + ['eyedropper', async () => press_(await eyedropper(), 'the eyedropper, again')], + ]) { + await press_(await eyedropper(), 'the eyedropper of target'); + await click(spot.x, spot.y); + const before = (await pick()).candidate; + await leave(); + const after = await pick(); + const menu = (await json('__gm.overlayTexts()')) ?? []; + cancels.push({ how, before, picking: after.picking, ended: after.ended, menu, target: await target() }); + } + check( + 'Escape, a right-click and a second press of the eyedropper end it without writing', + cancels.every( + (c) => + c.before === 'or57-front' && + !c.picking && + c.ended === 'cancel' && + c.menu.length === 0 && + JSON.stringify(c.target) === JSON.stringify({ $object: '' }), + ), + JSON.stringify(cancels), + ); + + // 57g. A list item in a Variables tab: the eyedropper goes to the scene view, Take writes the item + // and brings the tab back. + await evaluate( + `__gm.state.openValues({ kind: 'list', slot: { kind: 'object', holderId: ${JSON.stringify(sceneId)}, objectId: 'or57-obj', key: 'crowd' } })`, + ); + await sleep(200); + const tabShown = (await pick()).tab; + const inspectorLeft = (await json('__gm.inspectorRect()')).x; + const inTab = ((await json("__gm.locateIcons('eyedropper')")) ?? []).filter((p) => p.x < inspectorLeft); + await press_(inTab[0], 'the eyedropper of the list item'); + const fromList = await pick(); + await click(spot.x, spot.y); + const listCandidate = (await pick()).candidate; + const take = (await pick()).strip?.take; + await press_(take, 'Take'); + const backIn = await pick(); + const crowd = await json( + "__gm.state.activeScene.objects.find((o) => o.id === 'or57-obj')?.overrides.crowd ?? null", + ); + check( + 'from a list item in a Variables tab it picks in the scene view, and Take writes it and goes back', + tabShown === 'variables' && + inTab.length === 1 && + fromList.tab === 'scene' && + fromList.strip?.text === 'Target for Aim 57 · crowd · [0]: click an object' && + listCandidate === 'or57-front' && + backIn.ended === 'take' && + backIn.tab === 'variables' && + JSON.stringify(crowd) === JSON.stringify([{ $object: 'or57-front' }]), + JSON.stringify({ tabShown, inTab, fromList, listCandidate, backIn, crowd }), + ); + // Clean up. await evaluate('__gm.state.select(null)'); await mutate(` diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 3aeedd5e..fc8a6cfb 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -27,6 +27,7 @@ export const LANES = { symbols: -4700, vectorArt: -5100, pixelArt: -5400, + eyedropper: -5800, }; /**