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>
194 lines
9.0 KiB
TypeScript
194 lines
9.0 KiB
TypeScript
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();
|
||
}
|
||
}
|