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>
208 lines
8.0 KiB
TypeScript
208 lines
8.0 KiB
TypeScript
/**
|
||
* The Variables tab (#252): every variable of a behavior, a Variables asset or a placed behavior,
|
||
* with the very rows the inspector draws for them – or one list, as deep inside them as it sits.
|
||
* One editor for every such tab, pointed at the target of the one showing, like the asset editors.
|
||
*/
|
||
import type { Container } from 'pixi.js';
|
||
import { isVariableOwner } from '../../core/model/Asset';
|
||
import { findObject } from '../../core/model/hierarchy';
|
||
import { findAsset } from '../../core/model/Project';
|
||
import { objectName } from '../../core/model/SceneObject';
|
||
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
|
||
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
|
||
import type { InspectorHost } from '../inspector/host';
|
||
import { viewOf } from '../inspector/objectQueries';
|
||
import {
|
||
buildOwnRows,
|
||
objectSignature,
|
||
pickableObjects,
|
||
refreshObjectSection,
|
||
} 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. */
|
||
function ownerOf(state: EditorState, target: Extract<ValuesTarget, { kind: 'variables' }>) {
|
||
const { owner } = target;
|
||
if (owner.kind === 'asset') {
|
||
const asset = findAsset(state.project, owner.assetId);
|
||
return isVariableOwner(asset) ? { asset, name: asset.name } : null;
|
||
}
|
||
const view = viewOf(state.project, owner.holderId);
|
||
const object = view && findObject(view, owner.objectId);
|
||
const asset = object && findAsset(state.project, object.assetId);
|
||
return object && asset?.type === 'behavior' ? { asset, name: objectName(object, asset) } : null;
|
||
}
|
||
|
||
/** What the tab of `target` says, or null once there is nothing left to show – the tab closes then. */
|
||
export function valuesTitle(state: EditorState, target: ValuesTarget, live?: LiveVariables): string | null {
|
||
if (target.kind === 'list') return resolveList(state, target.slot, live)?.title ?? null;
|
||
const owner = ownerOf(state, target);
|
||
if (!owner) return null;
|
||
return `${owner.name} · ${owner.asset.type === 'struct' ? 'Fields' : 'Variables'}`;
|
||
}
|
||
|
||
export class VariablesEditor extends VStack {
|
||
private readonly title = new Label('', { style: 'bodyBold' });
|
||
private readonly scroll = new ScrollContainer();
|
||
private host: InspectorHost;
|
||
private target: ValuesTarget | null = null;
|
||
private signature = '';
|
||
private lastLiveRefresh = 0;
|
||
private rebuildWhenLeft = false;
|
||
private readonly releaseFocusHandler: () => void;
|
||
/** The list a list target draws, and its head. */
|
||
private list: { head: ListHead; items: InlineList } | null = null;
|
||
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
|
||
liveVariables: LiveVariables = () => null;
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super({ gap: 0 });
|
||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||
header.minHeight = Theme.controlHeight + 2 * Theme.padding;
|
||
header.add(this.title, 1);
|
||
this.add(header);
|
||
this.host = this.emptyHost();
|
||
this.scroll.setContent(this.host.body);
|
||
this.add(this.scroll, 1);
|
||
state.on('project', () => this.refresh());
|
||
state.on('runtime', () => this.refreshLive());
|
||
this.releaseFocusHandler = ui().focus.onFocusChange(() => {
|
||
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
|
||
});
|
||
}
|
||
|
||
/** Signing out destroys the tab while the page stays up (#177), so the handler goes with it. */
|
||
override destroy(options?: Parameters<VStack['destroy']>[0]): void {
|
||
this.releaseFocusHandler();
|
||
super.destroy(options);
|
||
}
|
||
|
||
/** Points the tab at another target; the one it showed keeps nothing, every edit is in the model. */
|
||
show(target: ValuesTarget): void {
|
||
this.target = target;
|
||
this.rebuild();
|
||
}
|
||
|
||
/** For the check scripts: what the tab shows, and the lists drawn in it. */
|
||
debugInfo(): { title: string; target: ValuesTarget | null; rows: string[] } {
|
||
return {
|
||
title: this.title.text,
|
||
target: this.target,
|
||
rows: [...this.host.fields.keys()],
|
||
};
|
||
}
|
||
|
||
/** Every list drawn in the tab, at any depth. */
|
||
lists(): InlineList[] {
|
||
return inlineListsIn(this.host.body);
|
||
}
|
||
|
||
private emptyHost(): InspectorHost {
|
||
return {
|
||
state: this.state,
|
||
project: () => this.state.project,
|
||
liveVariables: (assetId) => this.liveVariables(assetId),
|
||
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
|
||
fields: new Map(),
|
||
nameField: null,
|
||
};
|
||
}
|
||
|
||
private currentSignature(): string {
|
||
const target = this.target;
|
||
if (!target) return '';
|
||
if (target.kind === 'list') return resolveList(this.state, target.slot) ? 'list' : 'missing';
|
||
const { owner } = target;
|
||
if (!ownerOf(this.state, target)) return 'missing';
|
||
return owner.kind === 'asset'
|
||
? assetSignature(this.state.project, owner.assetId)
|
||
: objectSignature(this.state, owner.holderId, [owner.objectId], owner.objectId);
|
||
}
|
||
|
||
private rebuild(): void {
|
||
this.rebuildWhenLeft = false;
|
||
this.host.body.destroy({ children: true });
|
||
this.host = this.emptyHost();
|
||
this.list = null;
|
||
this.signature = this.currentSignature();
|
||
const target = this.target;
|
||
this.title.setText((target && valuesTitle(this.state, target, this.liveVariables)) ?? '');
|
||
if (target?.kind === 'list') this.buildList(target.slot);
|
||
else if (target) {
|
||
const owner = ownerOf(this.state, target);
|
||
if (owner && target.owner.kind === 'asset') buildVariables(this.host, owner.asset, true);
|
||
else if (target.owner.kind === 'object')
|
||
buildOwnRows(this.host, target.owner.holderId, target.owner.objectId, true);
|
||
}
|
||
this.scroll.setContent(this.host.body);
|
||
}
|
||
|
||
private buildList(slot: Extract<ValuesTarget, { kind: 'list' }>['slot']): void {
|
||
const access = () => resolveList(this.state, slot, this.liveVariables);
|
||
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;
|
||
this.host.body.add(head);
|
||
this.host.body.add(items);
|
||
this.list = { head, items };
|
||
}
|
||
|
||
/** Play mode: follow what the behaviors write, ~10× a second, but never under a field being typed in. */
|
||
private refreshLive(): void {
|
||
if (!this.visible) return;
|
||
const now = performance.now();
|
||
if (now - this.lastLiveRefresh < 100) return;
|
||
if (ui().focus.isWithin(this)) return;
|
||
this.lastLiveRefresh = now;
|
||
this.refresh();
|
||
}
|
||
|
||
private refresh(): void {
|
||
const target = this.target;
|
||
if (!target) return;
|
||
this.title.setText(valuesTitle(this.state, target, this.liveVariables) ?? '');
|
||
if (this.currentSignature() !== this.signature) {
|
||
if (ui().focus.isWithin(this)) {
|
||
this.rebuildWhenLeft = true;
|
||
return;
|
||
}
|
||
this.rebuild();
|
||
return;
|
||
}
|
||
if (target.kind === 'list') {
|
||
this.list?.head.setCount(this.list.items.access()?.value.length ?? 0);
|
||
this.list?.items.refresh();
|
||
return;
|
||
}
|
||
const { owner } = target;
|
||
const ok =
|
||
owner.kind === 'asset'
|
||
? refreshAssetSection(this.host, owner.assetId)
|
||
: refreshObjectSection(this.host, owner.holderId, owner.objectId);
|
||
if (!ok) this.rebuild();
|
||
}
|
||
}
|
||
|
||
/** Every inline list below `root`, outermost first – what the check scripts pick one from. */
|
||
export function inlineListsIn(root: Container): InlineList[] {
|
||
const found: InlineList[] = [];
|
||
const walk = (node: Container): void => {
|
||
if (node instanceof InlineList) found.push(node);
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(root);
|
||
return found;
|
||
}
|