Files
feuerwerk/src/editor/inspector/Inspector.ts
T
adminandClaude Opus 5.5 393caadc9d
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m10s
Head the groups of the inspector with bands that fold (#290)
The groups of the inspector had a small grey label as their title, in
the same grid as a property row, so with many rows one group hardly
stood apart from the next. Now every group has a band as its head,
from edge to edge of the panel, and a click on it folds the group.

- A band runs across the padding of its column in the header colour,
  with the title and a mark that says whether the group is open. The
  titles stay as they are, as answered in the issue.
- A click on a band folds its group: the rows go, the band stays and
  the next band moves up against it. A control in the band (Open,
  + Variable, + Property) does its own job and folds nothing.
- A placed object has a group SWITCHES under its name, asset and
  parent: Enabled, Keep on scene load and the switches of a behavior.
  An asset has SWITCHES and PROPERTIES, and a group without rows is
  left out.
- The same bands head the tilemap panel, the Variables tab, the side
  of the Pixel art tab and the shape list of vector art. A list with a
  title (Layers, Frames, Shapes) folds with a click on its header,
  clear of its plus.
- ui/sections.ts holds the rules as pure functions, SectionStack is
  the column and SectionHeader the band. Every title of the inspector
  goes through addTitle or sectionHeader in inspector/host.ts.

Decided here:

- A fold is kept for good, in localStorage of this browser. The issue
  asked whether for the session or for good and the answer left it
  open.
- A fold is remembered by title, per panel. PROPERTIES folded once is
  folded on every object, asset and node. A number in a title does
  not count.
- A behavior asset has all its rows under SWITCHES. A Variables asset
  and a state machine get a group SCOPE.
- A greyed out control in a band takes the click too. Without that the
  click fell through to the band and folded the group.
- The Curve tab has no column of this kind and is unchanged.

Found on the way:

- The inspector of a sprite is taller than the 800 pixel window of
  the checks now. The hooks that hand out a row scroll it into view
  first, and module 28 scrolls before it looks for a row by its text.

Tests:
- Gate: 202 files, 3008 tests (2982 + 26), exit 0
- e2e: no complete run for this state. Modules 28 to 61 passed alone
  (749 checks). A full run was cut off in the accounts module at 1492
  passes without a failure, the modules after it and the smokes are
  missing. The sum of 1639 (1635 + 4) is reckoned, CI measures it.
- 26 unit tests in tests/editor/sections.test.ts
- Four e2e checks with a real mouse, three in module 02 and one in
  module 48
- 25 unit counter-probes all fail tests, run on stein2
- Screenshots of the bands looked at before the last build, none
  after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:08:28 +02:00

194 lines
9.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 type { FederatedPointerEvent } from 'pixi.js';
import { Label, Panel, pressCount, ScrollContainer, SectionStack, Theme, ui, type Widget } from '../../ui';
import { activateAsset } from '../actions';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
import { buildEdgeSection, edgeSignature, refreshEdgeSection } from './edgeSection';
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
import { buildShapeSection, refreshShapeSection, shapeSignature } from './shapeSection';
import {
buildObjectSection,
buildObjectsSection,
objectSignature,
refreshObjectSection,
} from './objectSection';
import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection';
import { INLINE_LIMIT } from '../variables/target';
import { inlineListsIn } from '../variables/VariablesEditor';
import type { InlineList } from '../list/InlineList';
/**
* 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;
private readonly releaseFocusHandler: () => void;
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());
this.releaseFocusHandler = ui().focus.onFocusChange(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
});
this.rebuild();
}
/**
* A link in a reference row (#221): a click pings the asset in the asset panel, a double-click
* opens it. Pinging leaves the selection alone, so this panel keeps showing the object and both
* presses land on the same link.
*/
private followReference(assetId: string, e: FederatedPointerEvent): void {
if (pressCount(e, `inspector-link:${assetId}`) === 2) activateAsset(this.state, assetId);
else this.state.pingAsset(assetId);
}
/** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.releaseFocusHandler();
super.destroy(options);
}
/** 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 };
}
/**
* Scrolls until `widget` is in view, when it is one of the rows in here – the check scripts
* reveal a row before they click it, as a user scrolls to it.
*/
reveal(widget: Widget): void {
let inside = false;
for (let at = widget.parent; at && !inside; at = at.parent) inside = at === this.host.body;
if (!inside) return;
const top = widget.getGlobalPosition().y - this.host.body.getGlobalPosition().y;
this.scroll.ensureVisible(top, widget.h);
}
/** For the check scripts: every list drawn inline in here, at any depth. */
lists(): InlineList[] {
return inlineListsIn(this.host.body);
}
private emptyHost(): InspectorHost {
const body = new SectionStack({ padding: Theme.padding, gap: Theme.gap });
body.onFold = () => this.scroll.relayoutContent();
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),
followReference: (assetId, e) => this.followReference(assetId, e),
listLimit: INLINE_LIMIT,
body,
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 === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId, s.shapeIds);
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
else if (s.kind === 'edge') buildEdgeSection(this.host, s.assetId, s.edgeId, s.edgeIds);
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 === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId, s.shapeIds);
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
if (s.kind === 'edge') return edgeSignature(this.state.project, s.assetId, s.edgeId, s.edgeIds);
return objectSignature(this.state, 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 === 'shape'
? refreshShapeSection(this.host, s.assetId, s.shapeId)
: s.kind === 'annotation'
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
: s.kind === 'track'
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
: s.kind === 'edge'
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}