Pick the target of an object variable in the Scene View (#276)
game-maker / gate (push) Successful in 2m32s
game-maker / suite (push) Failing after 10m45s

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:
adminandClaude Opus 5.5 committed 2026-09-30 06:31:41 +02:00
1 parent 42647d309b
commit ded71a92c9
24 files changed
+777 -34

No files matched your search

+47 -11
View File
@@ -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: <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.
* 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;
+2
View File
@@ -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' });