Pick the target of an object variable in the Scene View (#276)
Choosing the target of an object variable meant finding its name in a dropdown. With many objects, or several with the same sprite, the name alone does not say which one it is. The eyedropper lets you click the object where you see it instead. - Every object dropdown gets an eyedropper button next to it. This covers variables, struct fields and list entries, in the Inspector and in the Variables tab. - The button opens the Scene View or the container tab the object lives in. A strip above the view names the field and the current candidate and offers Take and Cancel. The cursor is a crosshair. - A click picks a candidate and writes nothing yet. The candidate gets its own cyan frame, the selection stays on the object with the variable. - Clicking follows the selection rule. `pickOn` in `src/viewport/pick.ts` uses `pressOn` and only counts objects the dropdown offers. A click on empty space keeps the candidate. - Enter or Take writes through the same handlers as the dropdown. That is one undo step, or none if nothing changes. - Esc, Cancel, a right click in the view or a second click on the button cancel without writing. So do a tab switch, another selection, undo, redo, play and loading a project. - After Take or Cancel the editor goes back to the tab the pick came from. - While the eyedropper runs, the view does not drag, marquee, paint, show handles or open its context menu. Panning and zooming stay. Found on the way: - Started from the Variables tab, the click missed the object. The Scene View is hidden while that tab is open, and the layout skips hidden children. The strip appeared then, the view moved down by 32 px but kept its old height. `Editor.display()` now lays the row out again after showing it. Tests (on melektaus-atx): - unit pick and object-pick: 27 passed, 13 new - gate: 196 files, 2844 tests, CHECK EXIT 0. Two more come from svg-icons, which checks every icon twice. - e2e 57: ALL PASSED, 12 checks, 5 new (57e to 57g) - Counter-checks work. Without the layout fix 57g fails. Picking objects the dropdown does not offer fails 2 tests. Writing on cancel fails 2 more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
42647d309b
commit
ded71a92c9
24 files changed
+777
-34
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function referenceCandidates(fromId: string, ref: string): string[] {
|
|||||||
export interface ObjectChoice {
|
export interface ObjectChoice {
|
||||||
/** What the reference stores. */
|
/** What the reference stores. */
|
||||||
ref: string;
|
ref: string;
|
||||||
|
/** The object as the view of the holder knows it – where the eyedropper finds it (#276). */
|
||||||
|
objectId: string;
|
||||||
label: string;
|
label: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
@@ -60,7 +62,7 @@ export function objectChoices(project: Project, holder: ObjectTree, objectId: st
|
|||||||
for (const { object, depth } of treeOrder({ ...holder, objects })) {
|
for (const { object, depth } of treeOrder({ ...holder, objects })) {
|
||||||
if (!object.id.startsWith(scope)) continue;
|
if (!object.id.startsWith(scope)) continue;
|
||||||
const label = objectName(object, findAsset(project, object.assetId));
|
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;
|
if (!scope) return choices;
|
||||||
// The container's own content sits one level below it in the tree.
|
// The container's own content sits one level below it in the tree.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { GraphEditor } from './graph/GraphEditor';
|
|||||||
import { Inspector } from './inspector/Inspector';
|
import { Inspector } from './inspector/Inspector';
|
||||||
import { valuesTitle, VariablesEditor } from './variables/VariablesEditor';
|
import { valuesTitle, VariablesEditor } from './variables/VariablesEditor';
|
||||||
import { valuesKey } from './variables/target';
|
import { valuesKey } from './variables/target';
|
||||||
|
import { pickView } from './objectPick';
|
||||||
import { AnimationEditor } from './animation/AnimationEditor';
|
import { AnimationEditor } from './animation/AnimationEditor';
|
||||||
import { TileEditor } from './tile/TileEditor';
|
import { TileEditor } from './tile/TileEditor';
|
||||||
import { AssetPanel } from './panels/AssetPanel';
|
import { AssetPanel } from './panels/AssetPanel';
|
||||||
@@ -506,6 +507,38 @@ export class Editor implements PlayHost {
|
|||||||
this.showTab('scene');
|
this.showTab('scene');
|
||||||
});
|
});
|
||||||
state.on('mode', () => this.editorRun.invalidate());
|
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). */
|
/** Ctrl+V lands in whatever the visible tab edits (#240). */
|
||||||
@@ -797,10 +830,15 @@ export class Editor implements PlayHost {
|
|||||||
this.syncTilemapPanel();
|
this.syncTilemapPanel();
|
||||||
this.tabs.setActive(strip);
|
this.tabs.setActive(strip);
|
||||||
this.centre.relayout();
|
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.
|
// Hidden, it skipped every change; its pane may come back at the same size and not relayout.
|
||||||
this.gameView.redraw();
|
this.gameView.redraw();
|
||||||
this.viewport.catchUp();
|
this.viewport.catchUp();
|
||||||
ui().focus.validate();
|
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. */
|
/** The tilemap panel belongs to the scene tab and needs a map to paint; otherwise the view gets its room. */
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import type { Asset, ContainerAsset } from '../../core/model/Asset';
|
import type { Asset, ContainerAsset } from '../../core/model/Asset';
|
||||||
import { AssetTabEditor } from '../AssetTabEditor';
|
import { AssetTabEditor } from '../AssetTabEditor';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
|
import { PickStrip } from '../viewport/PickStrip';
|
||||||
import { ContainerCanvas } from './ContainerCanvas';
|
import { ContainerCanvas } from './ContainerCanvas';
|
||||||
|
|
||||||
/** Room kept for the "N objects" readout, wide enough for a three-digit template. */
|
/** Room kept for the "N objects" readout, wide enough for a three-digit template. */
|
||||||
const INFO_WIDTH = 110;
|
const INFO_WIDTH = 110;
|
||||||
|
|
||||||
export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCanvas> {
|
export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCanvas> {
|
||||||
|
readonly pickStrip: PickStrip;
|
||||||
|
|
||||||
constructor(state: EditorState) {
|
constructor(state: EditorState) {
|
||||||
super(state, { infoWidth: INFO_WIDTH });
|
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). */
|
/** The pending drop of an asset dragged over the template (test hook). */
|
||||||
|
|||||||
@@ -81,6 +81,26 @@ export function sceneHooks({ state, editor }: HookContext) {
|
|||||||
objectRect: (id: string) => viewport.objectRect(id),
|
objectRect: (id: string) => viewport.objectRect(id),
|
||||||
/** What a click at a screen point picks from in the Scene tab, front to back (#203). */
|
/** 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),
|
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. */
|
/** The same for the Game tab – through the camera, so what the player sees. */
|
||||||
gameScreen: (id: string) => gameView.objectScreen(id),
|
gameScreen: (id: string) => gameView.objectScreen(id),
|
||||||
gameRect: (id: string) => gameView.objectRect(id),
|
gameRect: (id: string) => gameView.objectRect(id),
|
||||||
|
|||||||
@@ -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.
|
* 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 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;
|
let found: Text | null = null;
|
||||||
const walk = (node: Container): void => {
|
const walk = (node: Container): void => {
|
||||||
if ((found !== null && !last) || !node.visible) return;
|
if ((found !== null && !last) || !node.visible) return;
|
||||||
@@ -54,8 +59,7 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
const row = owner?.parent;
|
const row = owner?.parent;
|
||||||
if (!row || !owner) return null;
|
if (!row || !owner) return null;
|
||||||
const control = row.children[row.children.indexOf(owner) + 1];
|
const control = row.children[row.children.indexOf(owner) + 1];
|
||||||
if (!(control instanceof Widget)) return null;
|
return control instanceof Widget ? control : null;
|
||||||
return { ...centreOf(control), left: control.getGlobalPosition().x };
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* The visible text of the overlay layer, in drawing order – everything an open popup shows.
|
* 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;
|
return (last ? hits.at(-1) : hits[0]) ?? null;
|
||||||
},
|
},
|
||||||
inspectorField: fieldOf,
|
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
|
* 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
|
* and takes presses, and a text field right of it (value, read-only). Searched in the inspector
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
import { ROW_ACTION_ICON_SVG } from '../panels/markIcons';
|
import { ROW_ACTION_ICON_SVG } from '../panels/markIcons';
|
||||||
import { InlineList, ListHead } from '../list/InlineList';
|
import { InlineList, ListHead } from '../list/InlineList';
|
||||||
import type { ListAccess } from '../list/slot';
|
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
|
* 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)".
|
* scene to pick from; without it the place shows "(null)".
|
||||||
*/
|
*/
|
||||||
objectChoices?: () => ObjectChoice[];
|
objectChoices?: () => ObjectChoice[];
|
||||||
|
/** Picks an object's place in the view instead (#276); without it the dropdown stands alone. */
|
||||||
|
objectPicker?: ObjectPicker;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldHandlers {
|
export interface FieldHandlers {
|
||||||
@@ -457,8 +460,9 @@ function referenceField(
|
|||||||
/**
|
/**
|
||||||
* "Target: <name> ▾" – another object of the same scene, or none (#258). Where there is no scene –
|
* "Target: <name> ▾" – 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.
|
* 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;
|
const choices = ctx.objectChoices;
|
||||||
if (!choices) return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} };
|
if (!choices) return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} };
|
||||||
// A list item arrives unwrapped, as the id alone.
|
// 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.
|
// Searchable like the parent picker: it lists every object of the scene.
|
||||||
new Menu(items, { searchable: true }).openBelow(button);
|
new Menu(items, { searchable: true }).openBelow(button);
|
||||||
};
|
};
|
||||||
return {
|
const setValue = (v: PropertyValue) => {
|
||||||
widget: button,
|
current = idOf(v);
|
||||||
grow: true,
|
button.setText(labelOf());
|
||||||
setValue: (v) => {
|
|
||||||
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. */
|
/** 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);
|
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(
|
export function createField(
|
||||||
ctx: FieldContext,
|
ctx: FieldContext,
|
||||||
def: PropertyDef,
|
def: PropertyDef,
|
||||||
value: PropertyValue,
|
value: PropertyValue,
|
||||||
handlers: FieldHandlers,
|
handlers: FieldHandlers,
|
||||||
|
name = def.label,
|
||||||
): Field {
|
): Field {
|
||||||
switch (def.kind) {
|
switch (def.kind) {
|
||||||
case 'number': {
|
case 'number': {
|
||||||
@@ -555,7 +585,13 @@ export function createField(
|
|||||||
const { row } = labelledRow(field.key);
|
const { row } = labelledRow(field.key);
|
||||||
const own =
|
const own =
|
||||||
field.kind === 'struct' ? nestedStructHandlers(handlers, field.key) : per([field.key], field);
|
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);
|
addValue(row, sub);
|
||||||
fields.set(field.key, sub);
|
fields.set(field.key, sub);
|
||||||
stack.add(withBelow(row, sub, 1));
|
stack.add(withBelow(row, sub, 1));
|
||||||
@@ -570,7 +606,7 @@ export function createField(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'object':
|
case 'object':
|
||||||
return objectField(ctx, value, handlers);
|
return objectField(ctx, name, value, handlers);
|
||||||
case 'boolean': {
|
case 'boolean': {
|
||||||
const field = new Checkbox(Boolean(value));
|
const field = new Checkbox(Boolean(value));
|
||||||
field.onChange = handlers.onChange;
|
field.onChange = handlers.onChange;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { parentRow } from './parentRow';
|
|||||||
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
||||||
import { overrideValue, tilemapSizeValue } from './valueSpecs';
|
import { overrideValue, tilemapSizeValue } from './valueSpecs';
|
||||||
import { buildVariable } from './variableRows';
|
import { buildVariable } from './variableRows';
|
||||||
|
import { objectPicker } from '../objectPick';
|
||||||
import {
|
import {
|
||||||
currentProps,
|
currentProps,
|
||||||
currentRef,
|
currentRef,
|
||||||
@@ -243,6 +244,7 @@ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: st
|
|||||||
const values = resolveSceneObjectProps(project, asset, object);
|
const values = resolveSceneObjectProps(project, asset, object);
|
||||||
const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values));
|
const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values));
|
||||||
host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId);
|
host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId);
|
||||||
|
host.objectPicker = objectPicker(host.state, holderId, objectId, host.objectChoices);
|
||||||
const behavior = asset.type === 'behavior';
|
const behavior = asset.type === 'behavior';
|
||||||
if (behavior) {
|
if (behavior) {
|
||||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
structItemOf,
|
structItemOf,
|
||||||
} from '../../core/properties/listItems';
|
} from '../../core/properties/listItems';
|
||||||
import { flatDef, type StructFieldDef } from '../../core/properties/PropertySchema';
|
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 {
|
import {
|
||||||
addValue,
|
addValue,
|
||||||
createField,
|
createField,
|
||||||
@@ -403,6 +403,7 @@ export class InlineList extends VStack {
|
|||||||
flatDef(key, kind as FieldKind),
|
flatDef(key, kind as FieldKind),
|
||||||
untag(value) ?? '',
|
untag(value) ?? '',
|
||||||
access.handlersAt(path),
|
access.handlersAt(path),
|
||||||
|
[access.def.label, ...pathLabel(path)].join(' · '),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="eyedropper" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
|
<!-- An eyedropper, bulb up right and tip down left: an object's place picked in the view (#276). -->
|
||||||
|
<path d="M1.6 -5.2 L3.4 -7 C4.2 -7.8 5.6 -7.8 6.5 -6.9 L6.9 -6.5 C7.8 -5.6 7.8 -4.2 7 -3.4 L5.2 -1.6 Z" fill="currentColor" />
|
||||||
|
<path d="M0.2 -5.2 L5.2 -0.2" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M2.2 -2.2 L-4.6 4.6" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M-4.6 4.6 L-6.6 6.6" fill="none" stroke="currentColor" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 602 B |
@@ -6,6 +6,7 @@ import type { Kind } from '../../core/properties/kinds';
|
|||||||
import apply from './icons/apply.svg?raw';
|
import apply from './icons/apply.svg?raw';
|
||||||
import arrowIn from './icons/arrow-in.svg?raw';
|
import arrowIn from './icons/arrow-in.svg?raw';
|
||||||
import arrowOut from './icons/arrow-out.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 fitHeight from './icons/fit-height.svg?raw';
|
||||||
import fitWidth from './icons/fit-width.svg?raw';
|
import fitWidth from './icons/fit-width.svg?raw';
|
||||||
import animation from './icons/kind-animation.svg?raw';
|
import animation from './icons/kind-animation.svg?raw';
|
||||||
@@ -38,7 +39,7 @@ export const KIND_ICON_SVG: Partial<Record<Kind, string>> = {
|
|||||||
texture,
|
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
|
* A behavior variable's two switches: kept from one call to the next (#229), hidden from everything
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
|
|||||||
return ui().focus.onGlobalKey((event) => {
|
return ui().focus.onGlobalKey((event) => {
|
||||||
const mod = event.ctrlKey || event.metaKey;
|
const mod = event.ctrlKey || event.metaKey;
|
||||||
const key = event.key.toLowerCase();
|
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') {
|
if (event.key === 'Escape' && state.mode === 'play') {
|
||||||
actions.stop();
|
actions.stop();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { KeyClipboard } from '../core/commands/animationCommands';
|
import type { KeyClipboard } from '../core/commands/animationCommands';
|
||||||
import type { EditorClipboard } from './clipboard';
|
import type { EditorClipboard } from './clipboard';
|
||||||
|
import type { ObjectPick, PickEnd } from './objectPick';
|
||||||
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
|
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
|
||||||
import { type Command, CommandError } from '../core/commands/Command';
|
import { type Command, CommandError } from '../core/commands/Command';
|
||||||
import { CommandStack } from '../core/commands/CommandStack';
|
import { CommandStack } from '../core/commands/CommandStack';
|
||||||
@@ -188,6 +189,8 @@ export type EditorEvent =
|
|||||||
| 'mode'
|
| 'mode'
|
||||||
| 'save'
|
| 'save'
|
||||||
| 'notice'
|
| '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. */
|
/** The running world advanced a frame (play mode): values may have changed without a command. */
|
||||||
| 'runtime';
|
| 'runtime';
|
||||||
|
|
||||||
@@ -230,6 +233,10 @@ export class EditorState {
|
|||||||
pingedAssetId: string | null = null;
|
pingedAssetId: string | null = null;
|
||||||
/** Objects the scene view is dragging: a running game holds their bodies to the pointer. */
|
/** Objects the scene view is dragging: a running game holds their bodies to the pointer. */
|
||||||
heldObjectIds: ReadonlySet<string> = new Set();
|
heldObjectIds: ReadonlySet<string> = 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. */
|
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
|
||||||
private edit: { project: Project; commands: CommandStack } | null = null;
|
private edit: { project: Project; commands: CommandStack } | null = null;
|
||||||
|
|
||||||
@@ -250,6 +257,7 @@ export class EditorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadProject(project: Project, revision: number): void {
|
loadProject(project: Project, revision: number): void {
|
||||||
|
this.endObjectPick('drop');
|
||||||
this.edit = null;
|
this.edit = null;
|
||||||
this.mode = 'edit';
|
this.mode = 'edit';
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
@@ -270,6 +278,7 @@ export class EditorState {
|
|||||||
/** Returns the runtime copy; the selection survives because ids are identical. */
|
/** Returns the runtime copy; the selection survives because ids are identical. */
|
||||||
enterPlay(): Project {
|
enterPlay(): Project {
|
||||||
if (this.mode === 'play') return this.project;
|
if (this.mode === 'play') return this.project;
|
||||||
|
this.endObjectPick('drop');
|
||||||
const copy = deserializeProject(serializeProject(this.project), this.project.id);
|
const copy = deserializeProject(serializeProject(this.project), this.project.id);
|
||||||
this.edit = { project: this.project, commands: this.commands };
|
this.edit = { project: this.project, commands: this.commands };
|
||||||
this.project = copy;
|
this.project = copy;
|
||||||
@@ -290,6 +299,7 @@ export class EditorState {
|
|||||||
|
|
||||||
exitPlay(): void {
|
exitPlay(): void {
|
||||||
if (this.mode !== 'play') return;
|
if (this.mode !== 'play') return;
|
||||||
|
this.endObjectPick('drop');
|
||||||
if (this.edit) {
|
if (this.edit) {
|
||||||
this.project = this.edit.project;
|
this.project = this.edit.project;
|
||||||
this.commands = this.edit.commands;
|
this.commands = this.edit.commands;
|
||||||
@@ -357,10 +367,12 @@ export class EditorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
|
this.endObjectPick('drop');
|
||||||
this.runEdit(() => this.commands.undo());
|
this.runEdit(() => this.commands.undo());
|
||||||
}
|
}
|
||||||
|
|
||||||
redo(): void {
|
redo(): void {
|
||||||
|
this.endObjectPick('drop');
|
||||||
this.runEdit(() => this.commands.redo());
|
this.runEdit(() => this.commands.redo());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,10 +484,44 @@ export class EditorState {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
selection = trackSelection(selection.assetId, selection.keys ?? [], selection.trackId);
|
selection = trackSelection(selection.assetId, selection.keys ?? [], selection.trackId);
|
||||||
if (sameSelection(this.selection, selection)) return;
|
if (sameSelection(this.selection, selection)) return;
|
||||||
|
this.endObjectPick('drop');
|
||||||
this.selection = selection;
|
this.selection = selection;
|
||||||
this.emit('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 {
|
toggleObject(holderId: string, objectId: string): void {
|
||||||
const s = this.selection;
|
const s = this.selection;
|
||||||
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
|
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '../inspector/objectSection';
|
} from '../inspector/objectSection';
|
||||||
import { InlineList, ListHead } from '../list/InlineList';
|
import { InlineList, ListHead } from '../list/InlineList';
|
||||||
import { type LiveVariables, resolveList } from '../list/slot';
|
import { type LiveVariables, resolveList } from '../list/slot';
|
||||||
|
import { objectPicker } from '../objectPick';
|
||||||
import type { EditorState, ValuesTarget } from '../state';
|
import type { EditorState, ValuesTarget } from '../state';
|
||||||
|
|
||||||
/** The asset or placed object whose variables `target` shows, when it still has any to show. */
|
/** 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<ValuesTarget, { kind: 'list' }>['slot']): void {
|
private buildList(slot: Extract<ValuesTarget, { kind: 'list' }>['slot']): void {
|
||||||
const access = () => resolveList(this.state, slot, this.liveVariables);
|
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.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 items = new InlineList(this.host, access);
|
||||||
const head = new ListHead(access, (kind) => items.addItem(kind), false);
|
const head = new ListHead(access, (kind) => items.addItem(kind), false);
|
||||||
items.head = head;
|
items.head = head;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,16 @@ import { SceneVisibilityPopup } from '../panels/SceneVisibilityPopup';
|
|||||||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { TabHeader } from '../TabHeader';
|
import { TabHeader } from '../TabHeader';
|
||||||
|
import { PickStrip } from './PickStrip';
|
||||||
import type { SceneViewport } from './SceneViewport';
|
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 {
|
export class ScenePane extends VStack {
|
||||||
readonly header = new TabHeader();
|
readonly header = new TabHeader();
|
||||||
|
readonly pickStrip: PickStrip;
|
||||||
/** Public for the check scripts. */
|
/** Public for the check scripts. */
|
||||||
readonly fitButton: Button;
|
readonly fitButton: Button;
|
||||||
/** What the scene view shows of each type – symbols, names, collider outlines. */
|
/** 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.fitButton);
|
||||||
this.header.add(this.visibilityButton);
|
this.header.add(this.visibilityButton);
|
||||||
|
this.pickStrip = new PickStrip(state, () => state.activeScene?.id);
|
||||||
this.add(this.header);
|
this.add(this.header);
|
||||||
|
this.add(this.pickStrip);
|
||||||
this.add(body, 1);
|
this.add(body, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ import { collectHolderItems, type DrawItem, type TilemapSolids } from '../../vie
|
|||||||
import { sortRowOf } from '../../viewport/drawOrder';
|
import { sortRowOf } from '../../viewport/drawOrder';
|
||||||
import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick';
|
import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick';
|
||||||
import { openObjectMenu } from '../objectMenu';
|
import { openObjectMenu } from '../objectMenu';
|
||||||
|
import { pickAt } from '../objectPick';
|
||||||
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
|
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
|
||||||
import { shows, type VisibilityPart } from '../../core/model/sceneVisibility';
|
import { shows, type VisibilityPart } from '../../core/model/sceneVisibility';
|
||||||
import { dashedLine } from '../../viewport/symbols';
|
import { dashedLine } from '../../viewport/symbols';
|
||||||
@@ -149,6 +150,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
private marquee: Marquee | null = null;
|
private marquee: Marquee | null = null;
|
||||||
private dropPreview: DropPreview | null = null;
|
private dropPreview: DropPreview | null = null;
|
||||||
private lastGizmos: GizmoInfo[] = [];
|
private lastGizmos: GizmoInfo[] = [];
|
||||||
|
private lastPickOutline: string | null = null;
|
||||||
/** Ids that got an owner link this frame (test hook). */
|
/** Ids that got an owner link this frame (test hook). */
|
||||||
private lastLinks: string[] = [];
|
private lastLinks: string[] = [];
|
||||||
/** Last place the pointer was seen, in global coordinates – where a paste lands. */
|
/** 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.resetView();
|
||||||
this.redraw();
|
this.redraw();
|
||||||
});
|
});
|
||||||
|
state.on('pick', () => this.redraw());
|
||||||
// A drag can end anywhere – over another panel, off the canvas – and this view never hears
|
// 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.
|
// about it, so the preview would stay on screen without this.
|
||||||
onDragChange(() => {
|
onDragChange(() => {
|
||||||
@@ -293,6 +296,11 @@ export class SceneViewport extends FreeViewport {
|
|||||||
this.startPan(e);
|
this.startPan(e);
|
||||||
return;
|
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) {
|
if (e.button === 2 && e.target === this) {
|
||||||
this.openEmptyMenu(e);
|
this.openEmptyMenu(e);
|
||||||
return;
|
return;
|
||||||
@@ -471,6 +479,10 @@ export class SceneViewport extends FreeViewport {
|
|||||||
* layer below a sprite does not put the map in front of it.
|
* layer below a sprite does not put the map in front of it.
|
||||||
*/
|
*/
|
||||||
protected override onItemPointerDown(_objectId: string, e: FederatedPointerEvent): void {
|
protected override onItemPointerDown(_objectId: string, e: FederatedPointerEvent): void {
|
||||||
|
if (this.picking()) {
|
||||||
|
this.pickUnder(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
this.openMenuUnder(e);
|
this.openMenuUnder(e);
|
||||||
return;
|
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. */
|
/** The hierarchy's menu for what is under the pointer (#204); empty space opens none. */
|
||||||
private openMenuUnder(e: FederatedPointerEvent): void {
|
private openMenuUnder(e: FederatedPointerEvent): void {
|
||||||
const source = this.holder();
|
const source = this.holder();
|
||||||
@@ -638,6 +670,15 @@ export class SceneViewport extends FreeViewport {
|
|||||||
protected override afterItems(items: DrawItem[], view: View): void {
|
protected override afterItems(items: DrawItem[], view: View): void {
|
||||||
this.placeIcons(items, view.scale);
|
this.placeIcons(items, view.scale);
|
||||||
this.drawParentLinks(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. */
|
/** 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) {
|
for (const id of s.objectIds) {
|
||||||
const item = items.find((i) => i.id === id);
|
const item = items.find((i) => i.id === id);
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
// Two screen pixels outside the footprint, whichever way it is turned. A symbol that is
|
const corners = this.outlineOf(item, view, 2);
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
// A switched-off object draws nothing, so its outline is the only way to see where it
|
// 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.
|
// sits – dimmed, because the empty frame would otherwise read as a drawing that failed.
|
||||||
const alpha = item.enabled ? 1 : OFF_GIZMO_ALPHA;
|
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.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.sizer.draw(this.gizmos, view);
|
const picking = this.drawPickCandidate(items, view);
|
||||||
this.corners.draw(this.gizmos, view);
|
if (!picking) {
|
||||||
|
this.sizer.draw(this.gizmos, view);
|
||||||
|
this.corners.draw(this.gizmos, view);
|
||||||
|
}
|
||||||
this.drawDropPreview(view);
|
this.drawDropPreview(view);
|
||||||
|
if (picking) return;
|
||||||
this.painter.draw(this.gizmos, items, view);
|
this.painter.draw(this.gizmos, items, view);
|
||||||
const m = this.marquee;
|
const m = this.marquee;
|
||||||
if (m?.active) drawBand(this.gizmos, m.start, m.current);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,8 @@ export const Theme = {
|
|||||||
/** Particle source icon and owner links; the same orange the graph gives the category. */
|
/** Particle source icon and owner links; the same orange the graph gives the category. */
|
||||||
particles: 0xc26a1f,
|
particles: 0xc26a1f,
|
||||||
marquee: 0x4c8dff,
|
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. */
|
/** A value deviating from what it follows: an override on a placed object, a non-default on an asset. */
|
||||||
override: 0xf2c84b,
|
override: 0xf2c84b,
|
||||||
/** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
|
/** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
|
||||||
|
|||||||
@@ -56,6 +56,23 @@ export function pressOn(
|
|||||||
return { select: null, onClick: next === held ? null : next };
|
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:
|
* 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.
|
* a selected one under the pointer keeps the selection, otherwise the topmost is selected first.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { DrawItem } from '../../src/viewport/items';
|
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`. */
|
/** Only what the pick order reads: the type, the `z`, and a map's layers with their own `z`. */
|
||||||
function item(
|
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)', () => {
|
describe('what a right-click opens the menu for (#204)', () => {
|
||||||
const stack = ['top', 'middle', 'bottom'];
|
const stack = ['top', 'middle', 'bottom'];
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,14 @@
|
|||||||
* run reads the object itself. Removing that object drops the reference; undo brings it back. On the
|
* 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)".
|
* 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.
|
* 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) {
|
export async function objectReferences(t) {
|
||||||
const { check, json, evaluate, mutate, click, press, typeText, sleep, shot, select, undo, stop } = 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 }),
|
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.
|
// Clean up.
|
||||||
await evaluate('__gm.state.select(null)');
|
await evaluate('__gm.state.select(null)');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const LANES = {
|
|||||||
symbols: -4700,
|
symbols: -4700,
|
||||||
vectorArt: -5100,
|
vectorArt: -5100,
|
||||||
pixelArt: -5400,
|
pixelArt: -5400,
|
||||||
|
eyedropper: -5800,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user