Add a decimals option, fix dropdown overflow and the texture filter

- NumberField takes a decimals option (default 3); frame rects and the
  slice dialog use 0, matching their integer model.
- Button truncates its label to the drawn width; Dropdown reserves the
  chevron slot. NodeView reuses the shared truncate().
- Setting scaleMode leaves the style's shared resource id in place, so
  the old GPU sampler stayed in use until a reload. Call style.update().
- Graph gets a header bar like the Texture tab: GraphEditor is now the
  frame, the former editor is GraphCanvas. A selection the tab does not
  concern no longer unloads the behavior.
- New e2e step 14 guards text overflow across all widgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 15:05:28 +02:00
1 parent 35a1a23af8
commit f16801aef3
22 files changed
+874 -493

No files matched your search

+486
View File
@@ -0,0 +1,486 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import {
AddNodeCommand,
ConnectCommand,
DisconnectCommand,
MoveNodeCommand,
RemoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
edgesInto,
edgesOutOf,
type GraphEdge,
type NodeDef,
nodeDef,
portsCompatible,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
import type { EditorState } from '../state';
import { NodePalette } from './NodePalette';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
const GRID_STEP = 24;
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
const MIN_GRID_STEP = 8;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
/** Stand-in node height for centring the view – real nodes vary with their port count. */
const NODE_HEIGHT_ESTIMATE = 80;
interface PortAnchor {
nodeId: string;
port: string;
isOutput: boolean;
}
/**
* The Graph tab's canvas: the node graph of one behavior asset. Which behavior that is comes
* from the outside (`show`), so the canvas never has an opinion about the selection – the
* GraphEditor around it does.
*
* Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
* the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
* the empty canvas to pan. All edits go through commands (undoable).
*/
export class GraphCanvas extends Widget {
private readonly bg = new Graphics();
private readonly grid = new Graphics();
private readonly clip = new Graphics();
private readonly canvas = new Container();
private readonly edges = new Graphics();
private readonly nodeLayer = new Container();
private readonly tempEdge = new Graphics();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly views = new Map<string, NodeView>();
private assetId: string | null = null;
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: {
nodeId: string;
start: { x: number; y: number };
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
/** Supplies live execution data while playing (null otherwise). */
traceProvider: (assetId: string) => Trace | null = () => null;
timeProvider: () => number = () => 0;
constructor(private readonly state: EditorState) {
super();
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
this.canvas.mask = this.clip;
this.cursor = 'default';
this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('wheel', (e: FederatedWheelEvent) => {
const local = this.toLocal(e.global);
const before = this.screenToGraph(local.x, local.y);
this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
const after = this.screenToGraph(local.x, local.y);
this.pan.x += (after.x - before.x) * this.zoom;
this.pan.y += (after.y - before.y) * this.zoom;
this.applyView();
e.preventDefault();
});
// Only the highlight of the selected node depends on the selection; which graph is shown
// is the editor's decision.
state.on('selection', () => this.redraw());
state.on('project', () => this.sync());
state.on('mode', () => this.redraw());
}
/** The behavior whose graph is shown, if any. */
get currentAssetId(): string | null {
return this.assetId;
}
/** Shows a behavior's graph. Switching refits the view, since the next graph is a new subject. */
show(assetId: string | null): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.sync();
this.needsFit = true;
this.fitView();
}
/** Recentres the graph in the view – the "reset view" the header offers. */
fit(): void {
this.needsFit = true;
this.fitView();
}
/**
* The header's "Add node": the same palette the right-click opens, dropped in the middle of
* the view, since a button press says nothing about where the node should go.
*/
addNodeFrom(anchor: Widget): void {
if (!this.assetId || this.state.mode !== 'edit') return;
const centre = this.screenToGraph(this.w / 2, this.h / 2);
new NodePalette((def: NodeDef) =>
this.addNode(def, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
).openBelow(anchor);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
// --- model binding ----------------------------------------------------------------------
private asset(): BehaviorAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'behavior' ? asset : null;
}
/** Rebuilds node views from the model (cheap: views are reused by node id). */
private sync(): void {
const asset = this.asset();
if (!asset) {
// A behavior that was deleted (or undone away) must not linger as a graph of a thing
// that no longer exists, so the canvas drops it rather than waiting to be told.
this.assetId = null;
for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear();
this.redraw();
return;
}
const seen = new Set<string>();
for (const node of asset.graph.nodes) {
const def = nodeDef(node.type);
if (!def) continue;
seen.add(node.id);
let view = this.views.get(node.id);
if (!view || view.node !== node) {
view?.destroy({ children: true });
view = new NodeView(node, def);
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
this.views.set(node.id, view);
this.nodeLayer.addChild(view);
}
view.sync(node, asset.graph, asset, this.state.project);
}
for (const [id, view] of this.views) {
if (!seen.has(id)) {
view.destroy({ children: true });
this.views.delete(id);
}
}
this.redraw();
}
/** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
private fitView(): void {
if (this.w === 0 || this.h === 0) return;
this.needsFit = false;
const asset = this.asset();
if (!asset || asset.graph.nodes.length === 0) {
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
this.zoom = 1;
} else {
const xs = asset.graph.nodes.map((n) => n.x);
const ys = asset.graph.nodes.map((n) => n.y);
const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2;
const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2;
this.zoom = 1;
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
}
this.applyView();
}
private applyView(): void {
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
this.canvas.scale.set(this.zoom);
this.redraw();
}
private screenToGraph(sx: number, sy: number): { x: number; y: number } {
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
}
// --- pointer -----------------------------------------------------------------------------
private onCanvasPointerDown(e: FederatedPointerEvent): void {
if (e.target !== this) return;
const local = this.toLocal(e.global);
if (e.button === 2) {
if (this.assetId && this.state.mode === 'edit')
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
return;
}
if (e.button === 0 || e.button === 1) {
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
this.state.select({ kind: 'asset', assetId: this.assetId });
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
}
}
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
if (!this.assetId || this.state.mode !== 'edit') return;
e.stopPropagation();
const local = view.toLocal(e.global);
const hit = view.portAt(local.x, local.y);
if (e.button === 2) {
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
else this.openNodeMenu(view, e.global.x, e.global.y);
return;
}
if (e.button !== 0) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = {
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
pointer: this.screenToGraph(p.x, p.y),
};
this.redraw();
return;
}
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
const p = this.toLocal(e.global);
this.dragging = {
nodeId: view.node.id,
start: { x: view.node.x, y: view.node.y },
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
this.nodeLayer.addChild(view); // raise
}
private onPointerMove(e: FederatedPointerEvent): void {
if (this.panning) {
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
this.applyView();
return;
}
const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y);
if (this.dragging) {
const dx = g.x - this.dragging.pointer.x;
const dy = g.y - this.dragging.pointer.y;
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
this.dragging.moved = true;
const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
if (!node) return;
node.x = Math.round(this.dragging.start.x + dx);
node.y = Math.round(this.dragging.start.y + dy);
this.state.preview();
} else if (this.connecting) {
this.connecting.pointer = g;
this.redraw();
}
}
private onPointerUp(e: FederatedPointerEvent): void {
this.panning = null;
const drag = this.dragging;
this.dragging = null;
if (drag && this.assetId) {
const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
if (node && drag.moved) {
// The preview moved the node already; put it back so the command records the start.
const next = { x: node.x, y: node.y };
node.x = drag.start.x;
node.y = drag.start.y;
this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y));
}
}
const connecting = this.connecting;
this.connecting = null;
if (connecting && this.assetId) {
const target = this.portUnder(e.global.x, e.global.y);
if (target && target.isOutput !== connecting.from.isOutput) {
const out = connecting.from.isOutput ? connecting.from : target;
const inp = connecting.from.isOutput ? target : connecting.from;
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
}
}
this.redraw();
}
private portUnder(globalX: number, globalY: number): PortAnchor | null {
for (const view of this.views.values()) {
const local = view.toLocal({ x: globalX, y: globalY });
const hit = view.portAt(local.x, local.y);
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
}
return null;
}
// --- menus -------------------------------------------------------------------------------
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
new NodePalette((def: NodeDef) => this.addNode(def, at)).open(globalX, globalY);
}
private addNode(def: NodeDef, at: { x: number; y: number }): void {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
if (this.state.execute(command))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
}
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
const assetId = this.assetId;
if (!assetId) return;
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
new Menu([
{
label: 'Delete node',
danger: true,
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
},
]).open(globalX, globalY);
}
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
): void {
const asset = this.asset();
if (!asset) return;
const edges = isOutput
? edgesOutOf(asset.graph, view.node.id, port)
: edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
new Menu([
{
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
danger: true,
onSelect: () => {
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
},
},
]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
override layout(): void {
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
if (this.needsFit) this.fitView();
}
override redraw(): void {
const c = Theme.colors;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
this.syncGrid();
const asset = this.asset();
this.canvas.visible = asset !== null;
if (!asset) {
this.hint.text = 'No behavior selected – pick one above, or add a behavior asset.';
return;
}
const playing = this.state.mode === 'play';
// The header names the behavior; the hint only says what the canvas itself is up to.
this.hint.text = asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '';
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
const isHot = (map: Map<string, number> | undefined, id: string) => {
const t = map?.get(id);
return t !== undefined && now - t <= TRACE_FADE;
};
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
for (const view of this.views.values()) {
view.selected = view.node.id === selectedNode;
view.hot = isHot(trace?.nodes, view.node.id);
view.redraw();
}
this.edges.clear();
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id));
this.tempEdge.clear();
if (this.connecting) {
const view = this.views.get(this.connecting.from.nodeId);
const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput);
if (view && p) {
const a = { x: view.x + p.x, y: view.y + p.y };
const b = this.connecting.pointer;
const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a];
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
}
}
}
private drawEdge(edge: GraphEdge, hot: boolean): void {
const from = this.views.get(edge.from.node);
const to = this.views.get(edge.to.node);
const a = from?.portPosition(edge.from.port, true);
const b = to?.portPosition(edge.to.port, false);
if (!from || !to || !a || !b) return;
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
alpha: type === 'exec' || hot ? 1 : 0.85,
});
}
private syncGrid(): void {
this.grid.clear();
const step = GRID_STEP * this.zoom;
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
// marking, so no axis lines.
drawGrid(this.grid, {
originX: this.pan.x,
originY: this.pan.y,
step,
w: this.w,
h: this.h,
alpha: 0.04,
});
}
// --- test hooks -----------------------------------------------------------------------------
/** Global screen position of a port (tests drive connections with it). */
portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null {
const view = this.views.get(nodeId);
const p = view?.portPosition(port, isOutput);
if (!view || !p) return null;
const g = view.toGlobal({ x: p.x, y: p.y });
return { x: g.x, y: g.y };
}
/** Global screen position of a node's title bar centre. */
nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId);
if (!view) return null;
const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 });
return { x: g.x, y: g.y };
}
}
/** Horizontal S-curve between two points, as used by every node editor. */
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
}
+85 -443
View File
@@ -1,474 +1,116 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import {
AddNodeCommand,
ConnectCommand,
DisconnectCommand,
MoveNodeCommand,
RemoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
edgesInto,
edgesOutOf,
type GraphEdge,
type NodeDef,
nodeDef,
portsCompatible,
} from '../../core/graph';
import { Graphics } from 'pixi.js';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
import type { EditorState } from '../state';
import { NodePalette } from './NodePalette';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
import { GraphCanvas } from './GraphCanvas';
const GRID_STEP = 24;
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
const MIN_GRID_STEP = 8;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
/** Stand-in node height for centring the view – real nodes vary with their port count. */
const NODE_HEIGHT_ESTIMATE = 80;
interface PortAnchor {
nodeId: string;
port: string;
isOutput: boolean;
}
/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */
const INFO_WIDTH = 150;
/**
* The node graph editor for one behavior asset. Shows the graph of whatever behavior the
* selection refers to (asset, placed object or node). When nothing or a non-behavior is
* selected the editor is empty – the graph strictly mirrors the selection.
* The Graph tab: a picker for which behavior is being worked on, the actions that act on the
* whole graph, and the canvas the nodes are drawn on.
*
* Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
* the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
* the empty canvas to pan. All edits go through commands (undoable).
* It follows the selection – selecting a behavior asset, a placed behavior or one of its nodes
* shows that graph – but it keeps showing the last behavior when the selection moves elsewhere.
* Otherwise clicking a sprite to check its position would empty the graph you are editing.
*/
export class GraphEditor extends Widget {
private readonly bg = new Graphics();
private readonly grid = new Graphics();
private readonly clip = new Graphics();
private readonly canvas = new Container();
private readonly edges = new Graphics();
private readonly nodeLayer = new Container();
private readonly tempEdge = new Graphics();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly views = new Map<string, NodeView>();
private assetId: string | null = null;
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: {
nodeId: string;
start: { x: number; y: number };
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
/** Supplies live execution data while playing (null otherwise). */
traceProvider: (assetId: string) => Trace | null = () => null;
timeProvider: () => number = () => 0;
export class GraphEditor extends VStack {
/** The canvas itself: the live redraw while playing and the check scripts reach for it. */
readonly canvas: GraphCanvas;
private readonly picker: Dropdown;
private readonly info: Label;
private readonly addButton: Button;
private readonly headerBg = new Graphics();
constructor(private readonly state: EditorState) {
super();
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
this.canvas.mask = this.clip;
this.cursor = 'default';
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + Theme.padding * 2;
header.addChildAt(this.headerBg, 0);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('wheel', (e: FederatedWheelEvent) => {
const local = this.toLocal(e.global);
const before = this.screenToGraph(local.x, local.y);
this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
const after = this.screenToGraph(local.x, local.y);
this.pan.x += (after.x - before.x) * this.zoom;
this.pan.y += (after.y - before.y) * this.zoom;
this.applyView();
e.preventDefault();
});
header.add(new Label('Behavior', { style: 'muted' }));
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
header.add(this.picker);
this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) });
header.add(this.addButton);
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
header.add(new Spacer(), 1);
this.info = new Label('', { style: 'small', align: 'right' });
// Reserved rather than measured: the label is built empty, and a stack sizes it once from
// the text it had at the time – so without a floor the readout is clipped from then on.
this.info.minWidth = INFO_WIDTH;
header.add(this.info);
this.canvas = new GraphCanvas(state);
this.add(header);
this.add(this.canvas, 1);
state.on('selection', () => this.followSelection());
state.on('project', () => this.refresh());
// Adding nodes is an edit, and edits made while playing would be thrown away with the run.
state.on('mode', () => this.refresh());
state.on('load', () => {
this.assetId = null;
this.canvas.show(null);
this.followSelection();
});
state.on('project', () => this.sync());
state.on('mode', () => this.redraw());
this.followSelection();
}
/** The behavior whose graph is shown, if any. */
/** The behavior currently shown – the check scripts read this. */
get currentAssetId(): string | null {
return this.assetId;
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
// --- model binding ----------------------------------------------------------------------
private asset(): BehaviorAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'behavior' ? asset : null;
return this.canvas.currentAssetId;
}
private followSelection(): void {
const wanted = this.state.selectedBehaviorId;
if (wanted && wanted !== this.assetId) {
this.assetId = wanted;
this.sync();
this.needsFit = true;
this.fitView();
} else if (!wanted || !this.asset()) {
this.assetId = null;
this.sync();
} else {
this.redraw();
}
const id = this.state.selectedBehaviorId;
// Null merely means "the selection is about something else"; the tab keeps its subject.
if (id) this.canvas.show(id);
this.refresh();
}
/** Rebuilds node views from the model (cheap: views are reused by node id). */
private sync(): void {
private asset(): BehaviorAsset | null {
const id = this.canvas.currentAssetId;
const asset = id ? findAsset(this.state.project, id) : undefined;
return asset && asset.type === 'behavior' ? asset : null;
}
private behaviors(): BehaviorAsset[] {
return this.state.project.assets
.filter((a): a is BehaviorAsset => a.type === 'behavior')
.sort((a, b) => a.name.localeCompare(b.name));
}
private openPicker(): void {
const current = this.canvas.currentAssetId;
const items: MenuItem[] = this.behaviors().map((asset) => ({
label: asset.id === current ? `● ${asset.name}` : asset.name,
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
}));
if (items.length === 0) items.push({ label: 'No behaviors yet', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.picker);
}
private refresh(): void {
// A deleted behavior leaves the canvas empty; rather than showing nothing at all the tab
// falls back to whatever behavior is left, the way it starts out on the first one.
if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null);
const asset = this.asset();
if (!asset) {
this.assetId = null;
for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear();
this.redraw();
return;
}
const seen = new Set<string>();
for (const node of asset.graph.nodes) {
const def = nodeDef(node.type);
if (!def) continue;
seen.add(node.id);
let view = this.views.get(node.id);
if (!view || view.node !== node) {
view?.destroy({ children: true });
view = new NodeView(node, def);
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
this.views.set(node.id, view);
this.nodeLayer.addChild(view);
}
view.sync(node, asset.graph, asset, this.state.project);
}
for (const [id, view] of this.views) {
if (!seen.has(id)) {
view.destroy({ children: true });
this.views.delete(id);
}
}
this.redraw();
}
/** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
private fitView(): void {
if (this.w === 0 || this.h === 0) return;
this.needsFit = false;
const asset = this.asset();
if (!asset || asset.graph.nodes.length === 0) {
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
this.zoom = 1;
} else {
const xs = asset.graph.nodes.map((n) => n.x);
const ys = asset.graph.nodes.map((n) => n.y);
const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2;
const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2;
this.zoom = 1;
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
}
this.applyView();
}
private applyView(): void {
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
this.canvas.scale.set(this.zoom);
this.redraw();
}
private screenToGraph(sx: number, sy: number): { x: number; y: number } {
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
}
// --- pointer -----------------------------------------------------------------------------
private onCanvasPointerDown(e: FederatedPointerEvent): void {
if (e.target !== this) return;
const local = this.toLocal(e.global);
if (e.button === 2) {
if (this.assetId && this.state.mode === 'edit')
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
return;
}
if (e.button === 0 || e.button === 1) {
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
this.state.select({ kind: 'asset', assetId: this.assetId });
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
}
}
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
if (!this.assetId || this.state.mode !== 'edit') return;
e.stopPropagation();
const local = view.toLocal(e.global);
const hit = view.portAt(local.x, local.y);
if (e.button === 2) {
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
else this.openNodeMenu(view, e.global.x, e.global.y);
return;
}
if (e.button !== 0) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = {
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
pointer: this.screenToGraph(p.x, p.y),
};
this.redraw();
return;
}
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
const p = this.toLocal(e.global);
this.dragging = {
nodeId: view.node.id,
start: { x: view.node.x, y: view.node.y },
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
this.nodeLayer.addChild(view); // raise
}
private onPointerMove(e: FederatedPointerEvent): void {
if (this.panning) {
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
this.applyView();
return;
}
const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y);
if (this.dragging) {
const dx = g.x - this.dragging.pointer.x;
const dy = g.y - this.dragging.pointer.y;
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
this.dragging.moved = true;
const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
if (!node) return;
node.x = Math.round(this.dragging.start.x + dx);
node.y = Math.round(this.dragging.start.y + dy);
this.state.preview();
} else if (this.connecting) {
this.connecting.pointer = g;
this.redraw();
}
}
private onPointerUp(e: FederatedPointerEvent): void {
this.panning = null;
const drag = this.dragging;
this.dragging = null;
if (drag && this.assetId) {
const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
if (node && drag.moved) {
// The preview moved the node already; put it back so the command records the start.
const next = { x: node.x, y: node.y };
node.x = drag.start.x;
node.y = drag.start.y;
this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y));
}
}
const connecting = this.connecting;
this.connecting = null;
if (connecting && this.assetId) {
const target = this.portUnder(e.global.x, e.global.y);
if (target && target.isOutput !== connecting.from.isOutput) {
const out = connecting.from.isOutput ? connecting.from : target;
const inp = connecting.from.isOutput ? target : connecting.from;
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
}
}
this.redraw();
}
private portUnder(globalX: number, globalY: number): PortAnchor | null {
for (const view of this.views.values()) {
const local = view.toLocal({ x: globalX, y: globalY });
const hit = view.portAt(local.x, local.y);
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
}
return null;
}
// --- menus -------------------------------------------------------------------------------
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
new NodePalette((def: NodeDef) => {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
if (this.state.execute(command))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
}).open(globalX, globalY);
}
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
const assetId = this.assetId;
if (!assetId) return;
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
new Menu([
{
label: 'Delete node',
danger: true,
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
},
]).open(globalX, globalY);
}
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
): void {
const asset = this.asset();
if (!asset) return;
const edges = isOutput
? edgesOutOf(asset.graph, view.node.id, port)
: edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
new Menu([
{
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
danger: true,
onSelect: () => {
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
},
},
]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
override layout(): void {
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
if (this.needsFit) this.fitView();
this.picker.setText(asset ? asset.name : 'No behavior');
const nodes = asset?.graph.nodes.length ?? 0;
const links = asset?.graph.edges.length ?? 0;
this.info.setText(
asset ? `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}` : '',
);
this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit');
}
override redraw(): void {
const c = Theme.colors;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
this.syncGrid();
const asset = this.asset();
this.canvas.visible = asset !== null;
if (!asset) {
this.hint.text = 'Select a behavior asset (or a placed behavior) to edit its graph.';
return;
}
const playing = this.state.mode === 'play';
this.hint.text =
asset.graph.nodes.length === 0
? `${asset.name} · right-click to add a node`
: `${asset.name}${playing ? ' · live' : ''}`;
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
const isHot = (map: Map<string, number> | undefined, id: string) => {
const t = map?.get(id);
return t !== undefined && now - t <= TRACE_FADE;
};
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
for (const view of this.views.values()) {
view.selected = view.node.id === selectedNode;
view.hot = isHot(trace?.nodes, view.node.id);
view.redraw();
}
this.edges.clear();
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id));
this.tempEdge.clear();
if (this.connecting) {
const view = this.views.get(this.connecting.from.nodeId);
const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput);
if (view && p) {
const a = { x: view.x + p.x, y: view.y + p.y };
const b = this.connecting.pointer;
const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a];
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
}
}
}
private drawEdge(edge: GraphEdge, hot: boolean): void {
const from = this.views.get(edge.from.node);
const to = this.views.get(edge.to.node);
const a = from?.portPosition(edge.from.port, true);
const b = to?.portPosition(edge.to.port, false);
if (!from || !to || !a || !b) return;
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
alpha: type === 'exec' || hot ? 1 : 0.85,
});
}
private syncGrid(): void {
this.grid.clear();
const step = GRID_STEP * this.zoom;
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
// marking, so no axis lines.
drawGrid(this.grid, {
originX: this.pan.x,
originY: this.pan.y,
step,
w: this.w,
h: this.h,
alpha: 0.04,
});
}
// --- test hooks -----------------------------------------------------------------------------
/** Global screen position of a port (tests drive connections with it). */
portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null {
const view = this.views.get(nodeId);
const p = view?.portPosition(port, isOutput);
if (!view || !p) return null;
const g = view.toGlobal({ x: p.x, y: p.y });
return { x: g.x, y: g.y };
}
/** Global screen position of a node's title bar centre. */
nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId);
if (!view) return null;
const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 });
return { x: g.x, y: g.y };
super.redraw();
this.headerBg.clear();
const h = Theme.controlHeight + Theme.padding * 2;
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
}
}
/** Horizontal S-curve between two points, as used by every node editor. */
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
}
+4 -14
View File
@@ -1,4 +1,4 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import { Graphics, Rectangle, Text } from 'pixi.js';
import {
asString,
edgesInto,
@@ -17,7 +17,7 @@ import type { Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import { assetRefLabel } from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { formatNumber, Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
/**
@@ -75,7 +75,7 @@ export interface PortHit {
/**
* Draws one graph node: a title bar coloured by category, a line per setting and a row per
* port pair (inputs left, outputs right). Everything is derived from the NodeDef; the view
* never interprets what a node does. Positioned in graph coordinates by the GraphEditor.
* never interprets what a node does. Positioned in graph coordinates by the GraphCanvas.
*/
export class NodeView extends Widget {
private readonly box = new Graphics();
@@ -166,7 +166,7 @@ export class NodeView extends Widget {
align: 'left' | 'right' = 'left',
maxWidth = NODE_WIDTH - PAD * 2,
) => {
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
const node = new Text({ text: truncate(text, textStyle(style), maxWidth), style: textStyle(style) });
node.position.set(align === 'left' ? x : x - node.width, y);
this.addChild(node);
this.texts.push(node);
@@ -270,13 +270,3 @@ function formatValue(value: Value): string {
if (typeof value === 'boolean') return value ? 'true' : 'false';
return asString(value);
}
/** Truncates with an ellipsis so the text fits `maxWidth`. */
function fit(text: string, style: TextStyleToken, maxWidth: number): string {
const s = textStyle(style);
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
let cut = text;
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth)
cut = cut.slice(0, -1);
return cut + '…';
}