Files
feuerwerk/src/editor/inspector/Inspector.ts
T
adminandClaude Opus 5 b9f1a9d319
game-maker / suite (push) Successful in 6m13s
A graph that grew past one screen needs somewhere to say what a part of it is for (#38)
The graph could hold any number of nodes and said nothing about which of them
belonged together. Groups and comments are two separate things, as decided in
the issue: a group is a titled box that carries the nodes it encloses, a
comment is free text that carries nothing.

Both live as optional arrays on Graph, not as registry nodes. A comment that
were a real node would run through the interpreter, show up in the drop
palettes and be compiled into a shader — the wrong place for a piece of text.
As arrays on Graph they exist in all four graph kinds at once, and a document
without them means exactly "no groups", so no format bump.

Membership is computed when the box is grabbed, never stored: the center of a
node inside the box travels with it, and a node dragged out is simply out. A
box inside a box travels with the outer one, its own nodes included. A group
is grabbed by its title bar only — its body stays transparent to the pointer,
or a rubber band could never be drawn across the nodes it holds. Deleting a
box deletes the box; the nodes stay where they are, because a box that
silently took ten nodes with it would be the most expensive surprise in the
feature.

The color is a free picker, and the title color follows from it: relative
luminance per WCAG, threshold at 0.179 where the two contrast ratios meet —
not 0.5, which would pick white on mid grey where black reads better.

Resizing snaps the far edge to the grid, not the width, so the corner that is
not being dragged stays put. The minimum size rises in whole steps for that
reason: 80 is no multiple of 24, so a box squeezed to the stop would have sat
off-grid in exactly the mode meant to keep it on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:24:34 +02:00

136 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PropertyBag } from '../../core/model/Properties';
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
import {
buildObjectSection,
buildObjectsSection,
objectSignature,
refreshObjectSection,
} from './objectSection';
/**
* Shows the properties of the current selection. The rows are generated from the PropertySchema
* by one section per selection kind (asset, scene object, graph node, texture frame); the panel
* only decides when a section has to be rebuilt and when its values merely refresh.
*/
export class Inspector extends Panel {
private readonly scroll = new ScrollContainer();
private host: InspectorHost;
private builtFor: Selection = null;
/** Describes the structure the rows were built for; a change means a rebuild. */
private signature = '';
/** Last live refresh (play mode), in ms – the world ticks far more often than the eye needs. */
private lastLiveRefresh = 0;
/** A rebuild that was due while a field in here had focus (see `refreshValues`). */
private rebuildWhenLeft = false;
private rebuilds = 0;
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
liveVariables: (assetId: string) => PropertyBag | null = () => null;
constructor(private readonly state: EditorState) {
super({ title: 'Inspector', padding: 0, gap: 0 });
this.host = this.emptyHost();
this.scroll.setContent(this.host.body);
this.content.add(this.scroll, 1);
state.on('selection', () => this.rebuild());
state.on('load', () => this.rebuild());
state.on('project', () => this.refreshValues());
state.on('runtime', () => this.refreshLive());
ui().focus.changeHandlers.push(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
});
this.rebuild();
}
/** For the check scripts: whether a rebuild is being held back, and how many have run. */
debugInfo(): { rebuilds: number; postponed: boolean } {
return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft };
}
private emptyHost(): InspectorHost {
return {
state: this.state,
// Read through the state on every call: load and play mode replace the project object.
project: () => this.state.project,
liveVariables: (assetId) => this.liveVariables(assetId),
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(),
nameField: null,
};
}
/**
* Play mode: show what the behaviors are doing to the selected object, ~10× a second.
* Skipped while a field in here is being edited so the typed text is not overwritten.
*/
private refreshLive(): void {
const now = performance.now();
if (now - this.lastLiveRefresh < 100) return;
if (ui().focus.isWithin(this)) return;
this.lastLiveRefresh = now;
this.refreshValues();
}
private rebuild(): void {
this.rebuildWhenLeft = false;
this.rebuilds += 1;
this.builtFor = this.state.selection;
this.host.body.destroy({ children: true });
this.host = this.emptyHost();
this.signature = this.currentSignature();
const s = this.state.selection;
if (!s) this.host.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' }));
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
else buildObjectSection(this.host, s.holderId, s.objectId);
this.scroll.setContent(this.host.body);
}
private currentSignature(): string {
const s = this.state.selection;
if (!s) return '';
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
}
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
private refreshValues(): void {
const s = this.state.selection;
if (!sameSelection(s, this.builtFor) || this.currentSignature() !== this.signature) {
// A rebuild throws away the widget being typed in, and a typed value can change the
// structure itself (a field that decides how many ports a node has). So it waits until
// the field is left; values are safe meanwhile, `TextField.setValue` ignores an edit.
if (ui().focus.isWithin(this)) {
this.rebuildWhenLeft = true;
return;
}
this.rebuild();
return;
}
if (!s) return;
const ok =
s.kind === 'asset'
? refreshAssetSection(this.host, s.assetId)
: s.kind === 'node'
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.kind === 'annotation'
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}