Consolidate duplicated editor code, trim comments, refresh README

Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 11:00:30 +02:00
1 parent 9212e61c4c
commit 8f92a06c84
96 files changed
+999 -1261

No files matched your search

+2 -6
View File
@@ -30,12 +30,8 @@ export interface AssetTabOptions {
}
/**
* The Graph, Texture and Container tabs. Each follows the selection but keeps showing its last
* asset when the selection moves elsewhere – clicking a sprite must not empty the tab being
* worked in. "Nothing shown" is `''` throughout.
*
* A subclass finishes construction by calling `mount()`: the header needs the canvas (its Fit
* button) and the canvas needs `state`, so the base cannot build one before the other exists.
* The Graph, Texture and Container tabs; each keeps its last asset when the selection moves elsewhere.
* A subclass must call `mount()`: header and canvas depend on each other, so the base cannot build them.
*/
export abstract class AssetTabEditor<
A extends Asset,
-1
View File
@@ -239,7 +239,6 @@ export class Editor implements PlayHost {
this.editorRun.tick(dt);
}
/** Refreshes the toolbar readout twice a second. */
private measureFps(dt: number): void {
this.fpsFrames += 1;
this.fpsElapsed += dt;
+4 -14
View File
@@ -1,13 +1,6 @@
/**
* Keeps the behaviors switched to "run in editor" running while the project is *not* playing.
*
* The one thing that makes this different from Play: the world is built from the saved model, not
* from a copy. What such a behavior moves or writes is a real edit – it is saved and it is not on
* the undo stack.
*/
/** Runs "run in editor" behaviors outside play mode, on a world built from the saved model, not a copy. */
import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem';
import { Input } from '../runtime/systems/Input';
import { type ModelWrite, World } from '../runtime/World';
import type { EditorState } from './state';
@@ -96,10 +89,7 @@ export class EditorRun {
// Baking a container's content into the scene would be an edit nobody asked for, so it is off
// here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`).
const fresh = World.instantiate(project, scene.id, { bakeContainers: false });
const collisions = new CollisionSystem();
this.behaviors = new BehaviorSystem(project, this.input, collisions, (e) => runsInEditor(e.props));
fresh.addSystem(collisions);
fresh.addSystem(this.behaviors);
this.behaviors = addGameSystems(fresh, project, this.input, (e) => runsInEditor(e.props));
this.world = fresh;
this.hadFocus = this.hooks.focused();
this.input.attach(this.hooks.focused);
@@ -116,7 +106,7 @@ export class EditorRun {
return true;
}
if (!world.stopRequested) return false;
const error = world.log.find((line) => line.startsWith('Error in behavior'));
const error = behaviorError(world);
this.stop();
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.');
return true;
+8 -8
View File
@@ -4,8 +4,12 @@
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
*/
import { worldPositionOf } from '../core/graph';
import { BehaviorSystem, type Trace } from '../runtime/systems/BehaviorSystem';
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
import {
addGameSystems,
type BehaviorSystem,
behaviorError,
type Trace,
} from '../runtime/systems/BehaviorSystem';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import { ui } from '../ui';
@@ -83,11 +87,7 @@ export class PlaySession {
const world = World.instantiate(runtime, scene.id);
this.world = world;
this.input.attach();
// Collisions are detected first so behaviors see this frame's enter/stay/exit events.
const collisions = new CollisionSystem();
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
world.addSystem(collisions);
world.addSystem(this.behaviors);
this.behaviors = addGameSystems(world, runtime, this.input);
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(this.playNotice());
@@ -111,7 +111,7 @@ export class PlaySession {
this.host.gameView.setWorld(null);
this.state.exitPlay();
this.host.leaveGame();
const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
const lastError = world && behaviorError(world);
this.state.setNotice(lastError ?? '');
}
+16 -5
View File
@@ -4,11 +4,13 @@
*/
import { DeleteAssetCommand } from '../core/commands/assetCommands';
import { AddColliderCommand } from '../core/commands/colliderCommands';
import { CompoundCommand } from '../core/commands/CompoundCommand';
import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { findAsset } from '../core/model/Project';
import { isLiveInstance, isTemplateContent } from '../core/model/instance';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import type { SceneObject } from '../core/model/SceneObject';
import { textureRefIds } from '../core/model/texture';
import { findPropertyUsages, findUsages } from '../core/model/usages';
import { Dialog } from '../ui';
@@ -22,6 +24,17 @@ export function createContainer(state: EditorState, holderId: string, objectIds:
return true;
}
/** The test `AddColliderCommand` makes, so no place offers what it would refuse. */
export function canAddCollider(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
const type = findAsset(project, object.assetId)?.type;
return (
type !== undefined &&
type !== 'collider' &&
!isTemplateContent(holder, object.id) &&
!isLiveInstance(project, holder, object)
);
}
/** Gives the object a collider child sized like it; selects the collider. */
export function addCollider(state: EditorState, holderId: string, objectId: string): boolean {
const command = new AddColliderCommand(holderId, objectId);
@@ -38,9 +51,7 @@ export function removeObjects(state: EditorState, holderId: string, objectIds: r
/** Deletes graph nodes as one undo step, however many were selected. */
export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean {
const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
if (commands.length === 0) return false;
if (commands.length === 1) return state.execute(commands[0]!);
return state.execute(new CompoundCommand(`Delete ${commands.length} nodes`, commands));
return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands));
}
/** Asks before deleting an asset, spelling out what goes with it. */
+2 -6
View File
@@ -171,9 +171,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
viewportInfo: () => viewport.debugInfo(),
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
dropPreview: () => viewport.dropInfo(),
/** An asset row by id – two assets can share a name with the container picker's label. */
/** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */
dragPayload: () => activeDrag(),
/** An asset row by id – two assets can share a name with the container picker's label. */
assetRow: (id: string) => assets.rowGlobal(id),
assetRowColor: (id: string) => assets.rowColor(id),
gameInfo: () => gameView.debugInfo(),
@@ -254,11 +254,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
},
/**
* What the GPU was actually told about a picture's filter, next to what the model asked for.
* The two can disagree – that is the difference between a wrong value and a value that never
* reached the sampler – and only the driver can be asked for the second one.
*/
/** The filter the GPU sampler actually got, next to the model's – only the driver knows the former. */
textureFilter: (file: string) => {
const texture = textureLibrary.peek(file);
if (!texture) return null;
+1 -6
View File
@@ -1,9 +1,4 @@
/**
* Geometry of the graph view: which nodes a rubber band caught, what view frames them all, and
* where a drag puts them. All three are decisions about rectangles, so they live here rather than
* in the canvas – the "fit" that left nodes hanging out of the view was a sum that no test could
* reach.
*/
/** Geometry of the graph view (rubber band, fit, drag), kept out of the canvas so tests can reach it. */
import { snapToStep } from '../ui/math';
+172
View File
@@ -0,0 +1,172 @@
import { Container, Graphics, Text } from 'pixi.js';
import { type GraphEdge, portsCompatible } from '../../core/graph';
import { Theme, textStyle, truncate } from '../../ui';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { type NodeView, portColor } from './NodeView';
/** How far a folded edge's stub reaches out of its port. */
const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
export interface FoldMark {
edgeIds: string[];
/** The nodes at the other ends, deduped – clicking selects them all. */
others: string[];
isOutput: boolean;
x: number;
y: number;
text: string;
}
/** The curves between nodes and the marks of the hidden ones, in graph coordinates. */
export class EdgeLayer extends Container {
private readonly curves = new Graphics();
private readonly chips = new Graphics();
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
private readonly labelLayer = new Container();
private readonly labels: Text[] = [];
private labelCount = 0;
private ends: FoldMarkEnd[] = [];
private marks: FoldMark[] = [];
/** Edges the last draw really stroked as a curve – a hidden one is missing from it. */
private readonly stroked = new Set<string>();
constructor() {
super();
this.addChild(this.curves, this.chips, this.labelLayer);
}
draw(
edges: readonly GraphEdge[],
views: ReadonlyMap<string, NodeView>,
isHot: (edgeId: string) => boolean,
selected: Set<string>,
): void {
this.curves.clear();
this.chips.clear();
this.marks = [];
this.ends = [];
this.labelCount = 0;
this.stroked.clear();
for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected);
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
for (let i = this.labelCount; i < this.labels.length; i++) this.labels[i]!.visible = false;
}
markAt(g: { x: number; y: number }): FoldMark | null {
return foldMarkAt(this.marks, g, FOLD_MARK_RADIUS);
}
drawnMarks(): readonly FoldMark[] {
return this.marks;
}
wasStroked(edgeId: string): boolean {
return this.stroked.has(edgeId);
}
private drawEdge(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
hot: boolean,
selected: Set<string>,
): void {
const from = views.get(edge.from.node);
const to = 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);
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
const start = { x: from.x + a.x, y: from.y + a.y };
const end = { x: to.x + b.x, y: to.y + b.y };
if (edge.folded) {
this.addFoldEnd(edge, views, start, true, to.def.label, color, hot);
this.addFoldEnd(edge, views, end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color,
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
});
this.stroked.add(edge.id);
}
private addFoldEnd(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
port: { x: number; y: number },
isOutput: boolean,
otherLabel: string,
color: number,
hot: boolean,
): void {
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
this.ends.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput, views.get(side.node)?.collapsed),
other,
otherLabel,
isOutput,
portX: port.x,
portY: port.y,
color,
hot,
});
}
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
private drawFoldMarks(): void {
for (const mark of buildFoldMarks(this.ends, FOLD_STUB)) {
const { x, y, color, isOutput } = mark;
this.curves.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
this.curves.circle(x, y, radius).fill(color);
this.curves.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
const label = this.nextLabel();
label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH);
const gap = radius + 4;
const lx = isOutput ? x + gap : x - gap - label.width;
label.position.set(lx, y - label.height / 2);
// Without a plate behind it the name blurs into whatever curves pass behind the mark.
this.chips
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
.fill({ color: Theme.colors.node, alpha: 0.85 });
this.marks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
}
}
private nextLabel(): Text {
let label = this.labels[this.labelCount];
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
this.labels.push(label);
this.labelLayer.addChild(label);
}
label.visible = true;
this.labelCount++;
return label;
}
}
/** Horizontal S-curve between two points. */
export 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);
}
+81 -326
View File
@@ -1,13 +1,11 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { textOf } from '../../core/model/Properties';
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { oneStep } from '../../core/commands/CompoundCommand';
import {
AddNodeCommand,
CollapseNodesCommand,
ConnectCommand,
DisconnectCommand,
FoldEdgeCommand,
MoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
@@ -19,12 +17,10 @@ import {
edgesInto,
edgesOutOf,
firstPortFor,
type GraphEdge,
type GraphNode,
type BehaviorLookup,
type PortSide,
type PortType,
portsCompatible,
portTypeOf,
resolveDef,
RUN_BEHAVIOR,
@@ -37,34 +33,24 @@ import {
activeDrag,
clamp,
type DragPayload,
drawBand,
drawGrid,
endDrag,
Menu,
type MenuItem,
onDragChange,
pressCount,
type Size,
Theme,
textStyle,
truncate,
Widget,
zoomFactor,
} from '../../ui';
import { removeNodes } from '../actions';
import { type EditorState, nodeSelection } from '../state';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing';
import { bezier, EdgeLayer } from './EdgeLayer';
import { openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */
const GRID_STEP = 24;
@@ -78,21 +64,6 @@ const NODE_HEIGHT_ESTIMATE = 80;
const FIT_MARGIN = 24;
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
const DRAG_THRESHOLD = 3;
/** How far a folded edge's stub reaches out of its port. */
const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
interface FoldMark {
edgeIds: string[];
/** The nodes at the other ends, deduped – clicking selects them all. */
others: string[];
isOutput: boolean;
x: number;
y: number;
text: string;
}
/**
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
@@ -103,16 +74,7 @@ export class GraphCanvas extends Widget {
private readonly grid = new Graphics();
private readonly clip = new Graphics();
private readonly canvas = new Container();
private readonly edges = new Graphics();
private readonly markChips = new Graphics();
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
private readonly markLayer = new Container();
private readonly markLabels: Text[] = [];
private markLabelCount = 0;
private foldMarks: FoldMark[] = [];
private foldEnds: FoldMarkEnd[] = [];
/** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */
private readonly strokedEdges = new Set<string>();
private readonly edgeLayer = new EdgeLayer();
private readonly nodeLayer = new Container();
private readonly tempEdge = new Graphics();
/** Drawn in screen space, so the band keeps a hairline border at any zoom. */
@@ -153,7 +115,7 @@ export class GraphCanvas extends Widget {
constructor(private readonly state: EditorState) {
super();
this.canvas.addChild(this.edges, this.markChips, this.markLayer, this.nodeLayer, this.tempEdge);
this.canvas.addChild(this.edgeLayer, this.nodeLayer, this.tempEdge);
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
this.canvas.mask = this.clip;
this.cursor = 'default';
@@ -320,22 +282,25 @@ export class GraphCanvas extends Widget {
private onCanvasPointerDown(e: FederatedPointerEvent): void {
// The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason.
if (e.button === 1) {
const startPan = () => {
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
};
if (e.button === 1) {
startPan();
e.stopPropagation();
return;
}
if (e.target !== this) return;
// Shift-drag on empty space pans as well, as it does in the scene view.
if (e.button === 0 && e.shiftKey) {
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
startPan();
return;
}
const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y);
const mark = this.state.mode === 'edit' ? this.markAt(g) : null;
const mark = this.state.mode === 'edit' ? this.edgeLayer.markAt(g) : null;
if (e.button === 2) {
if (mark) this.openMarkMenu(mark, e.global.x, e.global.y);
if (mark) this.openLinks((g) => g.edges.filter((it) => mark.edgeIds.includes(it.id)), e.global);
else if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, g);
return;
}
@@ -358,8 +323,15 @@ export class GraphCanvas extends Widget {
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);
const { id } = view.node;
if (hit) {
const key = hit.port.key;
this.openLinks((g) => (hit.isOutput ? edgesOutOf(g, id, key) : edgesInto(g, id, key)), e.global);
} else {
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id]));
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y);
}
return;
}
if (e.button !== 0) return;
@@ -519,8 +491,7 @@ export class GraphCanvas extends Widget {
node.y = item.start.y;
moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y));
}
if (moves.length === 1) this.state.execute(moves[0]!);
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move nodes', moves));
if (moves.length > 0) this.state.execute(oneStep('Move nodes', moves));
}
const connecting = this.connecting;
const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null;
@@ -550,15 +521,7 @@ export class GraphCanvas extends Widget {
}
private connect(from: PortSide, to: PortSide): void {
if (!this.assetId) return;
const [out, inp] = from.isOutput ? [from, to] : [to, from];
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
if (this.assetId) this.state.execute(connectSides(this.assetId, from, to));
}
/**
@@ -639,15 +602,13 @@ export class GraphCanvas extends Widget {
return asset ? paletteEntries(this.state.project, asset) : [];
}
private newNode(entry: PaletteEntry, at: { x: number; y: number }): AddNodeCommand {
return new AddNodeCommand(this.assetId, entry.def.type, Math.round(at.x), Math.round(at.y), entry.params);
}
private addNode(entry: PaletteEntry, at: { x: number; y: number }): void {
if (!this.assetId) return;
const command = new AddNodeCommand(
this.assetId,
entry.def.type,
Math.round(at.x),
Math.round(at.y),
entry.params,
);
const command = this.newNode(entry, at);
if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id]));
}
@@ -659,124 +620,23 @@ export class GraphCanvas extends Widget {
at: { x: number; y: number },
): void {
if (!this.assetId) return;
const add = new AddNodeCommand(
this.assetId,
entry.def.type,
Math.round(at.x),
Math.round(at.y),
entry.params,
);
const add = this.newNode(entry, at);
const port = firstPortFor(entry.def, fromType, from.isOutput);
const to = { nodeId: add.node.id, port: port ?? '', isOutput: !from.isOutput };
const [out, inp] = from.isOutput ? [from, to] : [to, from];
const commands: Command[] = [add];
if (port)
commands.push(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
connectSides(this.assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput }),
);
if (this.state.execute(new CompoundCommand(add.label, commands)))
if (this.state.execute(oneStep(add.label, commands)))
this.state.select(nodeSelection(this.assetId, [add.node.id]));
}
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
const assetId = this.assetId;
if (!assetId) return;
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
if (!this.state.selectedNodeIds.includes(view.node.id))
this.state.select(nodeSelection(assetId, [view.node.id]));
const ids = this.state.selectedNodeIds;
const nodes = this.selectedNodes();
const expanded = nodes.filter((n) => !n.collapsed).map((n) => n.id);
const collapsed = nodes.filter((n) => n.collapsed).map((n) => n.id);
const fold = (list: string[], verb: string, to: boolean): MenuItem[] =>
list.length === 0
? []
: [
{
label: list.length > 1 ? `${verb} ${list.length} nodes` : `${verb} node`,
onSelect: () => this.state.execute(new CollapseNodesCommand(assetId, list, to)),
},
];
new Menu([
...fold(expanded, 'Collapse', true),
...fold(collapsed, 'Expand', false),
{
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
danger: true,
onSelect: () => removeNodes(this.state, assetId, ids),
},
]).open(globalX, globalY);
}
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
private openLinks(
pick: (graph: BehaviorAsset['graph']) => BehaviorAsset['graph']['edges'],
at: { x: number; y: 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;
const shown = edges.filter((e) => !e.folded);
const hidden = edges.filter((e) => e.folded);
const fold = (list: GraphEdge[], folded: boolean) => () => {
const commands = list.map((e) => new FoldEdgeCommand(asset.id, e.id, folded));
this.state.execute(
commands.length === 1
? commands[0]!
: new CompoundCommand(folded ? 'Hide links' : 'Show links', commands),
);
};
const items: MenuItem[] = [];
if (shown.length > 0)
items.push({
label: shown.length === 1 ? 'Hide link' : `Hide ${shown.length} links`,
onSelect: fold(shown, true),
});
if (hidden.length > 0)
items.push({
label: hidden.length === 1 ? 'Show link' : `Show ${hidden.length} links`,
onSelect: fold(hidden, false),
});
items.push({
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));
},
});
new Menu(items).open(globalX, globalY);
}
/** The mark is the only handle a hidden edge has, so it carries the way back. */
private openMarkMenu(mark: FoldMark, globalX: number, globalY: number): void {
const assetId = this.assetId;
if (!assetId) return;
const ids = mark.edgeIds;
const n = ids.length;
const all = (title: string, make: (edgeId: string) => Command) => () => {
const commands = ids.map(make);
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand(title, commands));
};
new Menu([
{
label: n === 1 ? 'Show link' : `Show ${n} links`,
onSelect: all('Show links', (id) => new FoldEdgeCommand(assetId, id, false)),
},
{
label: n === 1 ? 'Disconnect' : `Disconnect ${n} links`,
danger: true,
onSelect: all('Disconnect links', (id) => new DisconnectCommand(assetId, id)),
},
]).open(globalX, globalY);
const graph = this.asset()?.graph;
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
}
override layout(): void {
@@ -814,43 +674,29 @@ export class GraphCanvas extends Widget {
view.redraw();
}
this.edges.clear();
this.markChips.clear();
this.foldMarks = [];
this.markLabelCount = 0;
this.strokedEdges.clear();
this.foldEnds = [];
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes);
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
for (let i = this.markLabelCount; i < this.markLabels.length; i++) this.markLabels[i]!.visible = false;
this.edgeLayer.draw(asset.graph.edges, this.views, (id) => isHot(trace?.edges, id), selectedNodes);
this.tempEdge.clear();
this.tempEdgeShown = false;
const drag = this.connecting ?? this.pendingDrop;
if (drag) {
const view = this.views.get(drag.from.nodeId);
const p = view?.portPosition(drag.from.port, drag.from.isOutput);
if (view && p) {
const a = { x: view.x + p.x, y: view.y + p.y };
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer;
const [from, to] = drag.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 });
this.tempEdgeShown = true;
}
const a = drag && this.anchorPoint(drag.from);
if (drag && a) {
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer;
const [from, to] = drag.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 });
this.tempEdgeShown = true;
}
this.band.clear();
const m = this.marquee;
if (m?.active) {
const a = this.graphToScreen(m.start.x, m.start.y);
const b = this.graphToScreen(m.current.x, m.current.y);
this.band
.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y))
.fill({ color: c.marquee, alpha: 0.12 })
.stroke({ width: 1, color: c.marquee });
drawBand(
this.band,
this.graphToScreen(m.start.x, m.start.y),
this.graphToScreen(m.current.x, m.current.y),
);
}
const drop = this.incoming;
if (drop) {
@@ -871,93 +717,6 @@ export class GraphCanvas extends Widget {
return view && p ? { x: view.x + p.x, y: view.y + p.y } : null;
}
private drawEdge(edge: GraphEdge, hot: boolean, selected: Set<string>): 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);
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
const start = { x: from.x + a.x, y: from.y + a.y };
const end = { x: to.x + b.x, y: to.y + b.y };
if (edge.folded) {
this.addFoldMark(edge, start, true, to.def.label, color, hot);
this.addFoldMark(edge, end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
bezier(this.edges, start.x, start.y, end.x, end.y);
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color,
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
});
this.strokedEdges.add(edge.id);
}
/** Records one end of a hidden link; `drawFoldMarks` groups them per port afterwards. */
private addFoldMark(
edge: GraphEdge,
port: { x: number; y: number },
isOutput: boolean,
otherLabel: string,
color: number,
hot: boolean,
): void {
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
this.foldEnds.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput, this.views.get(side.node)?.collapsed),
other,
otherLabel,
isOutput,
portX: port.x,
portY: port.y,
color,
hot,
});
}
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
private drawFoldMarks(): void {
for (const mark of buildFoldMarks(this.foldEnds, FOLD_STUB)) {
const { x, y, color, isOutput } = mark;
this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
this.edges.circle(x, y, radius).fill(color);
this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
const label = this.markLabel();
label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH);
const gap = radius + 4;
const lx = isOutput ? x + gap : x - gap - label.width;
label.position.set(lx, y - label.height / 2);
// Without a plate behind it the name blurs into whatever curves pass behind the mark.
this.markChips
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
.fill({ color: Theme.colors.node, alpha: 0.85 });
this.foldMarks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
}
}
private markLabel(): Text {
let label = this.markLabels[this.markLabelCount];
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
this.markLabels.push(label);
this.markLayer.addChild(label);
}
label.visible = true;
this.markLabelCount++;
return label;
}
private markAt(g: { x: number; y: number }): FoldMark | null {
return foldMarkAt(this.foldMarks, g, FOLD_MARK_RADIUS);
}
private syncGrid(): void {
this.grid.clear();
const step = GRID_STEP * this.zoom;
@@ -992,14 +751,15 @@ export class GraphCanvas extends Widget {
return this.tempEdgeShown;
}
/** Whether the last redraw drew this edge as a curve – the claim "hidden" is about exactly this. */
/** Whether the last redraw stroked this edge as a curve. */
edgeDrawn(edgeId: string): boolean {
return this.strokedEdges.has(edgeId);
return this.edgeLayer.wasStroked(edgeId);
}
/** Every mark the last redraw drew – its length is the claim "one per port, not one per link". */
foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] {
return this.foldMarks.map((m) => ({ links: m.edgeIds.length, label: m.text, isOutput: m.isOutput }));
return this.edgeLayer
.drawnMarks()
.map((m) => ({ links: m.edgeIds.length, label: m.text, isOutput: m.isOutput }));
}
/** Global screen position of a folded edge's mark at one end, and what that mark stands for. */
@@ -1007,41 +767,24 @@ export class GraphCanvas extends Widget {
edgeId: string,
isOutput: boolean,
): { x: number; y: number; links: number; label: string } | null {
const mark = this.foldMarks.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput);
const mark = this.edgeLayer
.drawnMarks()
.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput);
if (!mark) return null;
const g = this.canvas.toGlobal({ x: mark.x, y: mark.y });
return { x: g.x, y: g.y, links: mark.edgeIds.length, label: mark.text };
}
/** A node's drawn rectangle on screen, and the canvas' own – "fit" is a claim about both. */
nodeRect(nodeId: string): { x: number; y: number; w: number; h: number } | null {
nodeRect(nodeId: string): Rect | null {
const view = this.views.get(nodeId);
if (!view) return null;
const topLeft = view.toGlobal({ x: 0, y: 0 });
const bottomRight = view.toGlobal({ x: view.w, y: view.h });
return {
x: topLeft.x,
y: topLeft.y,
w: bottomRight.x - topLeft.x,
h: bottomRight.y - topLeft.y,
};
return view ? globalRect(view, view.w, view.h) : null;
}
canvasRect(): { x: number; y: number; w: number; h: number } {
const topLeft = this.toGlobal({ x: 0, y: 0 });
const bottomRight = this.toGlobal({ x: this.w, y: this.h });
return {
x: topLeft.x,
y: topLeft.y,
w: bottomRight.x - topLeft.x,
h: bottomRight.y - topLeft.y,
};
canvasRect(): Rect {
return globalRect(this, this.w, this.h);
}
/**
* The title a node is drawn with. Worth reaching for from outside because a nested-behavior node
* takes its title from the behavior it runs – "which node is this" is otherwise only a guess.
*/
/** A nested-behavior node takes its title from the behavior it runs. */
nodeLabel(nodeId: string): string | null {
return this.views.get(nodeId)?.def.label ?? null;
}
@@ -1069,8 +812,20 @@ export class GraphCanvas extends Widget {
}
}
/** Horizontal S-curve between two points. */
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);
type Rect = { x: number; y: number; w: number; h: number };
function globalRect(target: Container, w: number, h: number): Rect {
const topLeft = target.toGlobal({ x: 0, y: 0 });
const bottomRight = target.toGlobal({ x: w, y: h });
return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y };
}
/** A drag may start at either end; the command wants the output first. */
function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand {
const [out, inp] = a.isOutput ? [a, b] : [b, a];
return new ConnectCommand(
assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
);
}
+2 -4
View File
@@ -10,10 +10,8 @@ export function foldedEdgeShown(edge: GraphEdge, selected: ReadonlySet<string>,
}
/**
* Which mark a folded edge end belongs to. One port, one mark: drawn per edge instead, several
* hidden links out of the same port stack their stub, dot and name on the very same pixels.
* The separator is NUL: a printable one merges node "a b" + port "c" with node "a" + port "b c".
* A collapsed node draws all its ports on one point per side, so there it is one mark per side.
* One mark per port (per side on a collapsed node), so hidden links of one port don't stack on the same
* pixels. The separator is NUL: a printable one merges node "a b" + port "c" with node "a" + port "b c".
*/
export function foldMarkKey(
side: { node: string; port: string },
+68
View File
@@ -0,0 +1,68 @@
import type { Command } from '../../core/commands/Command';
import { oneStep } from '../../core/commands/CompoundCommand';
import { CollapseNodesCommand, DisconnectCommand, FoldEdgeCommand } from '../../core/commands/graphCommands';
import type { GraphEdge, GraphNode } from '../../core/graph';
import { Menu, type MenuItem } from '../../ui';
import { removeNodes } from '../actions';
import type { EditorState } from '../state';
/** For the selected nodes; a selection mixing both states offers both directions. */
export function openNodeMenu(
state: EditorState,
assetId: string,
nodes: readonly GraphNode[],
x: number,
y: number,
): void {
const ids = state.selectedNodeIds;
const fold = (list: readonly GraphNode[], verb: string, to: boolean): MenuItem[] => {
if (list.length === 0) return [];
const listIds = list.map((n) => n.id);
const label = list.length > 1 ? `${verb} ${list.length} nodes` : `${verb} node`;
return [{ label, onSelect: () => state.execute(new CollapseNodesCommand(assetId, listIds, to)) }];
};
const expanded = nodes.filter((n) => !n.collapsed);
const collapsed = nodes.filter((n) => n.collapsed);
new Menu([
...fold(expanded, 'Collapse', true),
...fold(collapsed, 'Expand', false),
{
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
danger: true,
onSelect: () => removeNodes(state, assetId, ids),
},
]).open(x, y);
}
/**
* The links at one port, or behind one fold mark – the mark is the only handle a hidden link has,
* so it carries the way back. Every entry is one undo step, however many links it covers.
*/
export function openLinkMenu(
state: EditorState,
assetId: string,
edges: readonly GraphEdge[],
x: number,
y: number,
): void {
if (edges.length === 0) return;
const item = (
verb: string,
single: string,
list: readonly GraphEdge[],
make: (edgeId: string) => Command,
danger = false,
): MenuItem[] => {
if (list.length === 0) return [];
const label = list.length === 1 ? single : `${verb} ${list.length} links`;
const commands = () => list.map((e) => make(e.id));
return [{ label, danger, onSelect: () => state.execute(oneStep(`${verb} links`, commands())) }];
};
const shown = edges.filter((e) => !e.folded);
const hidden = edges.filter((e) => e.folded);
new Menu([
...item('Hide', 'Hide link', shown, (id) => new FoldEdgeCommand(assetId, id, true)),
...item('Show', 'Show link', hidden, (id) => new FoldEdgeCommand(assetId, id, false)),
...item('Disconnect', 'Disconnect', edges, (id) => new DisconnectCommand(assetId, id), true),
]).open(x, y);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { oneStep } from '../../core/commands/CompoundCommand';
import type { Asset } from '../../core/model/Asset';
import type { PropertyBag } from '../../core/model/Properties';
import {
@@ -23,7 +23,7 @@ export type SizeValues = Partial<Record<SizeAxis, number>>;
/** One undo step whether the action wrote one side or both. */
export function sizeCommand(values: SizeValues, make: (axis: SizeAxis, value: number) => Command): Command {
const commands = (Object.entries(values) as [SizeAxis, number][]).map(([axis, value]) => make(axis, value));
return commands.length === 1 ? commands[0]! : new CompoundCommand('Set size', commands);
return oneStep('Set size', commands);
}
/** The pixel size as the schema would store it – null where a min/max would change a side. */
+14 -30
View File
@@ -4,6 +4,7 @@ import {
SetAssetPropertyCommand,
} from '../../core/commands/assetCommands';
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
import { structShapeOf } from '../../core/graph';
import {
AddStructFieldCommand,
AddVariableCommand,
@@ -22,7 +23,6 @@ import {
type StructFieldDef,
type StructFieldKind,
VARIABLE_KINDS,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
@@ -30,16 +30,10 @@ import { Dropdown, HStack, Label, Menu, TextField, Theme } from '../../ui';
import { aspectActions, sizeCommand } from './aspectAction';
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { buildVariable, type VariableEditing } from './variableRows';
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers';
import { buildVariable, KIND_LABELS, type VariableEditing } from './variableRows';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { assetValue } from './valueSpecs';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
list: 'List',
struct: 'Struct',
};
/** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */
const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
kind === 'list' ? { key, kind, items: 'number' } : { key, kind };
@@ -54,18 +48,13 @@ export function assetSignature(project: Project, assetId: string): string {
}
export function defSignature(def: PropertyDef): string {
const fields = declaredFields(def)
.map((f) => `${f.key}:${f.kind}`)
.join('+');
const fields = (structShapeOf(def) ?? []).map((f) => `${f.key}:${f.kind}`).join('+');
// A list's item kind decides whether it has field rows at all, so it is structure too.
const items = def.kind === 'list' ? `/${def.items}` : '';
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
}
/** The fields a struct declares, or the item shape of a list of structs. */
function declaredFields(def: PropertyDef): readonly StructFieldDef[] {
return def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : [];
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
export function buildAssetSection(host: InspectorHost, assetId: string): void {
@@ -139,20 +128,9 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
}
}
/** Where one property of an asset lives. Shared, so a whole row and a single struct field agree on it. */
function assetSpec(host: InspectorHost, assetId: string, key: string): PreviewedValue<Asset> {
return {
target: () => findAsset(host.state.project, assetId),
read: (asset) => asset.props[key],
write: (asset, value) => (asset.props[key] = value),
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
commit: (value) => new SetAssetPropertyCommand(assetId, key, value),
};
}
/** Previews straight into the asset while editing; the command records the value from before the preview. */
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
const spec = assetSpec(host, assetId, def.key);
const spec = assetValue(host.state, assetId, def.key);
return {
...previewingHandlers<Asset>(host, spec),
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
@@ -167,7 +145,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const add = new Dropdown('+ Variable', { variant: 'ghost' });
add.onClick = () => {
const items = VARIABLE_KINDS.map((kind) => ({
label: VARIABLE_KIND_LABELS[kind],
label: KIND_LABELS[kind],
onSelect: () => {
const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior')
@@ -190,7 +168,13 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
addField: (kind) =>
run(new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(declaredFields(def)), kind))),
run(
new AddStructFieldCommand(
asset.id,
def.key,
newField(nextFieldName(structShapeOf(def) ?? []), kind),
),
),
};
const value = values[def.key] ?? def.default;
host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit }));
+19 -20
View File
@@ -110,10 +110,7 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
return { row, label };
}
/**
* Adds the trailing ↺/↑/extra buttons to a row and returns the refresh that re-evaluates them.
* Separate from `createFieldRow` because a struct block wears the same buttons on its head row.
*/
/** Adds the trailing ↺/↑/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */
export function attachRowActions(row: HStack, rowActions: RowActions): () => void {
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
const addActions = (glyph: string, available: FieldActions): void => {
@@ -157,13 +154,8 @@ export function attachRowActions(row: HStack, rowActions: RowActions): () => voi
}
/**
* Maps one PropertySchema entry to a labelled row containing the matching widget.
* The inspector is entirely generated through this function: adding a new property
* kind means adding a case here, adding a property means adding a schema line.
*
* A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to
* its source, `extra` adds row-specific ones. Each group is asked on every refresh: no entry
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
* Maps one schema entry to a labelled row. Action groups are asked again on each refresh: none means
* no button, one a button, several a menu.
*/
export function createFieldRow(
ctx: FieldContext,
@@ -174,8 +166,7 @@ export function createFieldRow(
): FieldRow {
const { row, label } = labelledRow(def.label);
const field = createField(ctx, def, value, handlers);
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
addValue(row, field);
const refreshActions = attachRowActions(row, rowActions);
@@ -198,6 +189,19 @@ interface Field {
parts?: ReadonlyMap<string, Widget>;
}
/** A widget that does not grow gets a spacer after it, so whatever follows sits at the row's end. */
export function addValue(row: HStack, field: Field): (value: PropertyValue) => void {
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
return field.setValue;
}
export function removeButton(onClick: () => void): Button {
return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick });
}
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
switch (kind) {
@@ -263,18 +267,13 @@ export function createField(
case 'struct': {
// A bundle has few fields, so unlike a list they fit under each other right here. Editing
// one writes the whole bundle, which is why the handlers come per field from the caller.
const per = handlers.structFieldHandlers;
if (!per) {
const summary = new Label(textOf(value), { style: 'muted' });
return { widget: summary, grow: true, setValue: (v) => summary.setText(textOf(v)) };
}
const per = handlers.structFieldHandlers ?? (() => READ_ONLY);
const stack = new VStack({ gap: Theme.gap });
const fields = new Map<string, Field>();
for (const field of def.fields) {
const { row } = labelledRow(field.key);
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
row.add(sub.widget, sub.grow ? 1 : 0);
if (!sub.grow) row.add(new Spacer(), 1);
addValue(row, sub);
fields.set(field.key, sub);
stack.add(row);
}
+2 -3
View File
@@ -4,7 +4,7 @@ import {
SetFrameRectCommand,
} from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import { findAssetOf, type Project } from '../../core/model/Project';
import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture';
import { Button, TextField } from '../../ui';
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
@@ -121,6 +121,5 @@ function currentFrame(project: Project, assetId: string, frameId: string): Textu
}
function currentTexture(project: Project, assetId: string): TextureAsset | undefined {
const asset = findAsset(project, assetId);
return asset?.type === 'texture' ? asset : undefined;
return findAssetOf(project, assetId, 'texture');
}
+8 -26
View File
@@ -1,8 +1,7 @@
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import { RemoveNodeCommand } from '../../core/commands/graphCommands';
import {
callProblem,
declaresVariablePorts,
type Graph,
edgesInto,
type GraphNode,
inlineInputDef,
@@ -12,18 +11,19 @@ import {
} from '../../core/graph';
import type { Asset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAsset, type Project } from '../../core/model/Project';
import { findAssetOf, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection, buildVariablePortSection } from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers';
import { currentNode, nodeParamValue } from './valueSpecs';
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
const asset = findAsset(project, assetId);
if (!asset || asset.type !== 'behavior') return 'missing';
const asset = findAssetOf(project, assetId, 'behavior');
if (!asset) return 'missing';
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const lookup = behaviorLookup(project);
@@ -41,8 +41,8 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
/** Description, settings (params) and the inline values of unconnected data inputs. */
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
const asset = findAsset(host.state.project, assetId);
if (!asset || asset.type !== 'behavior') return;
const asset = findAssetOf(host.state.project, assetId, 'behavior');
if (!asset) return;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
const def = node && resolveDef(node, asset.graph, behaviorLookup(host.state.project));
if (!node || !def) return;
@@ -126,25 +126,7 @@ function paramHandlers(
): FieldHandlers {
const key = def.key;
return {
...previewingHandlers<GraphNode>(host, {
target: () => currentNode(host.state.project, assetId, nodeId)?.node,
read: (node) => node.params[key],
write: (node, value) => (node.params[key] = value),
remove: (node) => delete node.params[key],
commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value),
}),
...previewingHandlers<GraphNode>(host, nodeParamValue(host.state, assetId, nodeId, key)),
...listOpener(host.state, def, () => ({ kind: 'node', assetId, nodeId, key })),
};
}
/** The node together with the graph it sits in, because its ports are derived from both. */
function currentNode(
project: Project,
assetId: string,
nodeId: string,
): { node: GraphNode; graph: Graph } | undefined {
const asset = findAsset(project, assetId);
if (asset?.type !== 'behavior') return undefined;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
return node && { node, graph: asset.graph };
}
+8 -17
View File
@@ -38,14 +38,15 @@ import {
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { addCollider, canAddCollider, createContainer, removeObjects } from '../actions';
import { aspectActions, sizeCommand } from './aspectAction';
import { defSignature } from './assetSection';
import { createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import type { ListSlot } from '../state';
import { parentRow } from './parentRow';
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { overrideValue } from './valueSpecs';
import { buildVariable } from './variableRows';
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
@@ -230,7 +231,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
if (asset.type !== 'collider' && !target) {
const stored = findObjectHolder(project, holderId);
if (stored && canAddCollider(project, stored, object)) {
addGap(host);
host.body.add(
new Button('+ Add collider', {
@@ -292,12 +294,8 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje
}
/**
* One editable property of a placed object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards).
*
* Inside a container a value has two bases – the template it follows and, one step further up,
* the asset. Reset always goes to the nearest one, so content of a placed container offers only
* "Reset to container"; "Apply to asset" still reaches past it, dropping the template's override.
* One editable property of a placed object. Reset always goes to the nearest base (the template inside
* a container), while "Apply to asset" reaches past it, dropping the template's override.
*/
function propertyRow(
host: InspectorHost,
@@ -309,14 +307,7 @@ function propertyRow(
variable = false,
): FieldRow {
const bases = () => deviations(host.state, holderId, objectId, def.key);
const overrideSpec: PreviewedValue<ObjectRef> = {
target: () => currentRef(host.state, holderId, objectId),
read: (ref) => ref.object.overrides[def.key],
current: () => currentProps(host.state, holderId, objectId)?.[def.key],
write: (ref, value) => ref.setOverride(def.key, value),
remove: (ref) => ref.setOverride(def.key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
};
const overrideSpec = overrideValue(host.state, holderId, objectId, def.key);
const fieldSlot = (field: string): ListSlot => ({
kind: 'object',
holderId,
+35 -57
View File
@@ -10,7 +10,6 @@ import {
BEHAVIOR_START,
DECLARED_PORT_KINDS,
type DeclaredPort,
type DeclaredPortKind,
type GraphNode,
isTerminalNode,
missingFieldPorts,
@@ -18,26 +17,20 @@ import {
variablePortKind,
} from '../../core/graph';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, TextField, Theme } from '../../ui';
import {
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
Spacer,
TextField,
Theme,
type Widget,
} from '../../ui';
import { NAME_WIDTH, removeButton } from './fieldFactory';
import { addNote, type InspectorHost } from './host';
const KIND_LABELS: Record<DeclaredPortKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
object: 'Object',
list: 'List',
struct: 'Struct',
};
const KIND_MARKS: Record<DeclaredPortKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
object: '◇',
list: '≡',
struct: '⊞',
};
import { KIND_LABELS, kindMark, markLabel } from './variableRows';
/**
* The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked
@@ -75,11 +68,6 @@ export function buildVariablePortSection(
for (const port of ports) {
const known = variables.some((v) => v.key === port.key);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
choose.onClick = () => {
const items = pick(port.key, (next) =>
@@ -87,16 +75,7 @@ export function buildVariablePortSection(
);
new Menu(items).openBelow(choose);
};
row.add(choose, 1);
row.add(new Spacer());
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)),
}),
);
host.body.add(row);
portRow(host, assetId, node, port, choose);
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
}
}
@@ -143,31 +122,30 @@ export function buildPortSection(
);
for (const port of ports) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
row.add(
new TextField(port.key, {
width: 90,
onChange: (next) =>
host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
}),
1,
);
row.add(new Spacer());
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)),
}),
);
host.body.add(row);
const name = new TextField(port.key, {
width: NAME_WIDTH,
onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
});
portRow(host, assetId, node, port, name);
}
}
function portRow(
host: InspectorHost,
assetId: string,
node: GraphNode,
port: DeclaredPort,
name: Widget,
): void {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(markLabel(kindMark(port.kind)));
row.add(name, 1);
row.add(new Spacer());
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
host.body.add(row);
}
/**
* Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of
* typing them again. Only offered where there is a shape to copy; a field the node already has
+2 -4
View File
@@ -78,10 +78,8 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
}
/**
* Row handlers that preview into the model while editing and take that preview back before
* committing. A command records the value it finds at execute time, so committing right away
* would make the last previewed frame the undo target: the value from before the preview is
* written back first – including "the key did not exist", via `remove`.
* Row handlers that preview into the model and restore the pre-preview value (or its absence, via
* `remove`) before committing, since a command records whatever it finds at execute time as its undo target.
*/
export function previewingHandlers<Target, Model = PropertyValue>(
host: { state: EditorState },
+64
View File
@@ -0,0 +1,64 @@
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
import { findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
import type { Asset } from '../../core/model/Asset';
import type { ObjectRef } from '../../core/model/instance';
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import type { EditorState } from '../state';
import { currentProps, currentRef } from './objectQueries';
import type { PreviewedValue } from './previewHandlers';
/** Where one value lives, shared by the inspector and the List tab so both write it the same way. */
export function assetValue(state: EditorState, assetId: string, key: string): PreviewedValue<Asset> {
return {
target: () => findAsset(state.project, assetId),
read: (asset) => asset.props[key],
write: (asset, value) => (asset.props[key] = value),
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
commit: (value) => new SetAssetPropertyCommand(assetId, key, value),
};
}
export function overrideValue(
state: EditorState,
holderId: string,
objectId: string,
key: string,
): PreviewedValue<ObjectRef> {
return {
target: () => currentRef(state, holderId, objectId),
read: (ref) => ref.object.overrides[key],
current: () => currentProps(state, holderId, objectId)?.[key],
write: (ref, value) => ref.setOverride(key, value),
remove: (ref) => ref.setOverride(key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, key, value),
};
}
export function nodeParamValue(
state: EditorState,
assetId: string,
nodeId: string,
key: string,
): PreviewedValue<GraphNode> {
return {
target: () => currentNode(state.project, assetId, nodeId)?.node,
read: (node) => node.params[key],
write: (node, value) => (node.params[key] = value),
remove: (node) => delete node.params[key],
commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value),
};
}
/** The node together with its graph, because its ports are derived from both. */
export function currentNode(
project: Project,
assetId: string,
nodeId: string,
): { node: GraphNode; graph: Graph } | undefined {
const asset = findAssetOf(project, assetId, 'behavior');
if (!asset) return undefined;
const node = findNode(asset.graph, nodeId);
return node && { node, graph: asset.graph };
}
+29 -44
View File
@@ -1,3 +1,4 @@
import type { DeclaredPortKind } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties';
import {
FIELD_KINDS,
@@ -8,10 +9,12 @@ import {
type StructDef,
type StructFieldDef,
type StructFieldKind,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui';
import type { ListDef } from '../list/slot';
import {
addValue,
attachRowActions,
createField,
FIELD_INDENT,
@@ -20,26 +23,34 @@ import {
type FieldRow,
MARK_WIDTH,
NAME_WIDTH,
READ_ONLY,
removeButton,
type RowActions,
structFieldValue,
} from './fieldFactory';
import { addNote, type InspectorHost } from './host';
// Keyed by the wide `PropertyDef['kind']` because variables are `PropertyDef`s, but only the
// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
struct: '⊞',
};
type ShownKind = VariableKind | ListItemKind | StructFieldKind | DeclaredPortKind;
const LIST_ITEM_MARKS: Record<ListItemKind, string> = {
/** One glyph and one name per kind, wherever a kind is shown: variables, list items, fields, ports. */
const KIND_MARKS: Record<ShownKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
list: '≡',
struct: '⊞',
object: '◇',
};
export const KIND_LABELS: Record<ShownKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
list: 'List',
struct: 'Struct',
object: 'Object',
};
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
@@ -50,21 +61,10 @@ const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
struct: 'Structs',
};
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
list: '≡',
};
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
list: 'List',
};
/** Variables are `PropertyDef`s, so the kind is typed wide; only the shown kinds ever reach it. */
export function kindMark(kind: string): string {
return (KIND_MARKS as Partial<Record<string, string>>)[kind] ?? '?';
}
/** Changing a declaration – offered only on the asset that declares it. */
export interface VariableEditing {
@@ -82,8 +82,6 @@ export interface VariableOptions {
actions?: RowActions;
}
const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
/**
* One variable, drawn the same on its asset and on a placed object – minus `edit` there, since the
* shape belongs to the asset. Adds its rows to the body itself and returns the first.
@@ -169,7 +167,7 @@ function itemShapeRows(host: InspectorHost, def: ListDef, edit: VariableEditing)
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
return gridRow(1, markLabel(FIELD_KIND_MARKS[field.kind]), name);
return gridRow(1, markLabel(KIND_MARKS[field.kind]), name);
}
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
@@ -194,22 +192,13 @@ function nameCell(text: string, depth: number, rename: ((next: string) => void)
: new Label(text, { style: 'muted', width, padding: Theme.fieldPadding });
}
function addValue(
row: HStack,
field: { widget: Widget; grow: boolean; setValue: (v: PropertyValue) => void },
): (v: PropertyValue) => void {
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
return field.setValue;
}
function markOf(def: PropertyDef, edit: VariableEditing | undefined): Widget {
if (def.kind !== 'list') return markLabel(VARIABLE_KIND_MARKS[def.kind] ?? '?');
const glyph = `≡${LIST_ITEM_MARKS[def.items]}`;
if (def.kind !== 'list') return markLabel(kindMark(def.kind));
const glyph = `≡${KIND_MARKS[def.items]}`;
return edit ? itemKindPicker(glyph, def.items, edit) : markLabel(glyph);
}
function markLabel(glyph: string): Label {
export function markLabel(glyph: string): Label {
return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH });
}
@@ -244,12 +233,8 @@ function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing):
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
new Menu(
kinds.map((kind) => ({ label: FIELD_KIND_LABELS[kind], onSelect: () => edit.addField(kind) })),
kinds.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => edit.addField(kind) })),
).openBelow(add);
};
return add;
}
function removeButton(onClick: () => void): Button {
return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick });
}
+10 -10
View File
@@ -6,7 +6,14 @@
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties';
import { type StructDef, structOf } from '../../core/properties/PropertySchema';
import { createField, type FieldContext, primitiveDef, type PrimitiveDef } from '../inspector/fieldFactory';
import {
addValue,
createField,
type FieldContext,
primitiveDef,
type PrimitiveDef,
removeButton,
} from '../inspector/fieldFactory';
import type { EditorState } from '../state';
import { type ListAccess, type ListDef, resolveList } from './slot';
@@ -124,15 +131,8 @@ export class ListEditor extends VStack {
if (tall) label.minHeight = Theme.rowHeight;
row.add(label);
const field = createField(this.ctx, itemDef(access.def, index), item, access.itemHandlers(index));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => access.replace(access.value.filter((_, i) => i !== index)),
}),
);
addValue(row, field);
row.add(removeButton(() => access.replace(access.value.filter((_, i) => i !== index))));
this.setters.push(field.setValue);
this.fields.push({ widget: field.widget, parts: field.parts });
return row;
+12 -42
View File
@@ -1,14 +1,8 @@
/**
* Resolves a `ListSlot` to the list in it and to the two ways of writing it back. Each of the
* three cases is the spec the matching inspector section already uses, so an item edited in the
* List tab goes through the same command, the same coercion and the same undo step as the row
* it replaces. A slot with a `field` names a list *inside* a struct: the same three owners, one
* level deeper, and the writes still go through the owner's command as a whole bundle.
* Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so a List tab
* edit gets the same command, coercion and undo step. A `field` slot still writes the whole struct.
*/
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
import { edgesInto, findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
import { resolveDef } from '../../core/graph/nested';
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
import type { Asset } from '../../core/model/Asset';
@@ -28,6 +22,7 @@ import { resolveAssetProps } from '../../core/properties/resolve';
import type { FieldHandlers } from '../inspector/fieldFactory';
import { currentProps, currentRef } from '../inspector/objectQueries';
import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers';
import { assetValue, currentNode, nodeParamValue, overrideValue } from '../inspector/valueSpecs';
import type { EditorState, ListSlot } from '../state';
export type ListDef = Extract<PropertyDef, { kind: 'list' }>;
@@ -157,20 +152,14 @@ function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset'
const asset = findAsset(state.project, slot.assetId);
const def = asset && schemaForAsset(asset).find((d) => d.key === slot.key);
if (!asset || !def) return null;
const at = () => findAsset(state.project, slot.assetId);
return {
def,
title: `${asset.name} · ${def.label}`,
current: () => {
const now = at();
return now ? resolveAssetProps(now)[slot.key] : undefined;
},
spec: {
target: at,
read: (found) => found.props[slot.key],
write: (found, value) => (found.props[slot.key] = value),
commit: (value) => new SetAssetPropertyCommand(slot.assetId, slot.key, value),
const now = findAsset(state.project, slot.assetId);
return now && resolveAssetProps(now)[slot.key];
},
spec: assetValue(state, slot.assetId, slot.key),
};
}
@@ -186,18 +175,12 @@ function objectOwner(
def,
title: `${ref.object.name} · ${def.label}`,
current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key],
spec: {
target: () => currentRef(state, slot.holderId, slot.objectId),
read: (found) => found.object.overrides[slot.key],
write: (found, value) => found.setOverride(slot.key, value),
remove: (found) => found.setOverride(slot.key, undefined),
commit: (value) => new SetOverrideCommand(slot.holderId, slot.objectId, slot.key, value),
},
spec: overrideValue(state, slot.holderId, slot.objectId, slot.key),
};
}
function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null {
const found = nodeIn(state, slot);
const found = currentNode(state.project, slot.assetId, slot.nodeId);
if (!found) return null;
const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project));
const def =
@@ -206,14 +189,9 @@ function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>
return {
def,
title: `${nodeDef.label} · ${def.label}`,
current: () => nodeIn(state, slot)?.node.params[slot.key] ?? def.default,
spec: {
target: () => nodeIn(state, slot)?.node,
read: (node) => node.params[slot.key],
write: (node, value) => (node.params[slot.key] = value),
remove: (node) => delete node.params[slot.key],
commit: (value) => new SetNodeParamCommand(slot.assetId, slot.nodeId, slot.key, value),
},
current: () =>
currentNode(state.project, slot.assetId, slot.nodeId)?.node.params[slot.key] ?? def.default,
spec: nodeParamValue(state, slot.assetId, slot.nodeId, slot.key),
};
}
@@ -228,11 +206,3 @@ function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, k
const inline = inlineInputDef(port);
return inline.kind === 'list' ? inline : null;
}
/** The node together with its graph, because `resolveDef` needs both. */
function nodeIn(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>) {
const asset = findAsset(state.project, slot.assetId);
if (asset?.type !== 'behavior') return null;
const node = findNode(asset.graph, slot.nodeId);
return node ? { node, graph: asset.graph } : null;
}
+7 -23
View File
@@ -1,5 +1,5 @@
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { oneStep } from '../../core/commands/CompoundCommand';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes';
@@ -9,22 +9,12 @@ import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { deviationMarks } from '../../core/properties/deviation';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { addCollider, canAddCollider, createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
* Objects of the scene or container being edited, as a tree (children indented below their parent).
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there, into the gap above or below a row to order it among that row's
* siblings, or onto the empty space below the rows to move it to the top level. The object keeps
* its world position every time.
*
* A row's name turns gold when the object deviates from what it follows – its container template
* or its asset – or when anything below it does, so a folded subtree still shows it.
*
* Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them.
* Everything starts collapsed – which ids are open is view state kept here, never part of the
* project, and selecting an object always unfolds the way down to it.
* Objects of the scene or container being edited, as a tree; dragging re-parents or reorders and always
* keeps world position. Fold state is view state kept here, never in the project.
*/
export class HierarchyPanel extends Panel {
private readonly list = new ListView();
@@ -72,12 +62,7 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
/**
* A drop either puts the dragged row inside another one (parenting, which keeps the world
* position) or into a gap, which orders it among the siblings there – and does both when the
* gap belongs to a parent the object did not have. A move that would leave the order as it is
* does nothing, so a drag that lands where it started costs no undo step.
*/
/** A move that leaves the order as it is does nothing, so a drag back to its start costs no undo step. */
private drop(draggedId: string, spot: DropSpot): void {
const holder = this.holder();
const object = holder && findObject(holder, draggedId);
@@ -108,7 +93,7 @@ export class HierarchyPanel extends Panel {
if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId));
commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId));
if (parentId) this.expanded.add(parentId);
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand('Move object', commands));
this.state.execute(oneStep('Move object', commands));
}
/**
@@ -165,7 +150,6 @@ export class HierarchyPanel extends Panel {
if (!holder) return undefined;
const inScene = holder.type === 'scene';
const object = findObject(holder, id);
const type = object && findAsset(state.project, object.assetId)?.type;
// Content of a placed container belongs to its template: values can be changed here, the
// structure cannot (see `model/instance.ts`).
const source = this.source();
@@ -181,7 +165,7 @@ export class HierarchyPanel extends Panel {
},
{
label: 'Add collider',
disabled: !type || type === 'collider' || many || fromTemplate || instance,
disabled: !object || !source || many || !canAddCollider(state.project, source, object),
onSelect: () => addCollider(state, holder.id, id),
},
{
-1
View File
@@ -8,7 +8,6 @@ import type { EditorState, SaveStatus } from '../state';
export interface ToolbarActions {
/** The "Project" dropdown: list, create and delete projects. `anchor` is the button to open the menu under. */
openProjectMenu(anchor: Widget): void;
/** The scene dropdown: switch the active scene. */
openSceneMenu(anchor: Widget): void;
/** Play in edit mode, Stop in play mode. */
togglePlay(): void;
+11 -26
View File
@@ -6,6 +6,7 @@ import { resolvedHolder } from '../core/model/instance';
import {
createEmptyProject,
findAsset,
findAssetOf,
findObjectHolder,
findScene,
type Project,
@@ -89,13 +90,8 @@ export type EditorEvent =
type Listener = () => void;
/**
* Single source of truth for the editor: the project, undo stack, selection, mode and
* save status. Panels subscribe to coarse events and refresh themselves; there is no
* framework and no virtual DOM.
*
* **Play mode edits a copy.** `enterPlay()` swaps `project` for a deep clone (same ids) with
* its own undo stack; every panel and command keeps working on it, the running world mirrors
* it, and `exitPlay()` throws the copy away and restores the saved model untouched.
* Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
* with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
*/
export class EditorState {
project: Project = createEmptyProject('', 'Untitled');
@@ -105,9 +101,8 @@ export class EditorState {
listSlot: ListSlot | null = null;
mode: EditorMode = 'edit';
/**
* Play is on but the world is not being ticked. A sub-state of `mode === 'play'` rather than a
* third mode: everything that distinguishes playing from editing (the runtime copy, the
* keyboard, edits not being saved) is just as true while paused.
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
* still apply.
*/
paused = false;
saveStatus: SaveStatus = 'saved';
@@ -118,7 +113,6 @@ export class EditorState {
private readonly listeners = new Map<EditorEvent, Set<Listener>>();
/** Subscribes to an event; returns the unsubscribe function. */
on(event: EditorEvent, listener: Listener): () => void {
let set = this.listeners.get(event);
if (!set) this.listeners.set(event, (set = new Set()));
@@ -147,10 +141,7 @@ export class EditorState {
this.emit('mode');
}
/**
* Switches to play mode: from now on `project` is a runtime copy of the model. Returns
* that copy (the world is instantiated from it). Selection is kept – ids are identical.
*/
/** Returns the runtime copy; the selection survives because ids are identical. */
enterPlay(): Project {
if (this.mode === 'play') return this.project;
const copy = deserializeProject(serializeProject(this.project), this.project.id);
@@ -171,7 +162,6 @@ export class EditorState {
this.emit('mode');
}
/** Leaves play mode: the runtime copy is discarded, the saved model comes back unchanged. */
exitPlay(): void {
if (this.mode !== 'play') return;
if (this.edit) {
@@ -285,7 +275,6 @@ export class EditorState {
this.emit('selection');
}
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */
toggleObject(holderId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
@@ -295,7 +284,6 @@ export class EditorState {
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
}
/** Ctrl/Shift-click: adds the node to the selection or removes it again. */
toggleNode(assetId: string, nodeId: string): void {
const s = this.selection;
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
@@ -305,12 +293,10 @@ export class EditorState {
this.select(nodeSelection(assetId, next, held ? undefined : nodeId) ?? { kind: 'asset', assetId });
}
/** Ids of the selected scene objects (empty unless objects are selected). */
get selectedObjectIds(): string[] {
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
}
/** Ids of the selected graph nodes (empty unless nodes are selected). */
get selectedNodeIds(): string[] {
return this.selection?.kind === 'node' ? this.selection.nodeIds : [];
}
@@ -327,9 +313,9 @@ export class EditorState {
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
}
if (s.kind === 'node') {
const asset = findAsset(this.project, s.assetId);
const asset = findAssetOf(this.project, s.assetId, 'behavior');
// Fall back to the behavior asset so the graph stays open when its node vanished (undo).
if (!asset || asset.type !== 'behavior') this.select(null);
if (!asset) this.select(null);
else {
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
if (alive.length !== s.nodeIds.length)
@@ -337,9 +323,9 @@ export class EditorState {
}
}
if (s.kind === 'frame') {
const asset = findAsset(this.project, s.assetId);
const asset = findAssetOf(this.project, s.assetId, 'texture');
// Same fallback as a graph node: the texture stays open when its frame vanished.
if (!asset || asset.type !== 'texture') this.select(null);
if (!asset) this.select(null);
else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId });
}
@@ -350,8 +336,7 @@ export class EditorState {
const s = this.selection;
if (!s) return null;
if (s.kind !== 'asset' && s.kind !== 'frame') return null;
const asset = findAsset(this.project, s.assetId);
return asset && asset.type === 'texture' ? asset.id : null;
return findAssetOf(this.project, s.assetId, 'texture')?.id ?? null;
}
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
+4 -13
View File
@@ -4,7 +4,7 @@
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { oneStep } from '../../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { isSymbolType } from '../../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
@@ -18,6 +18,7 @@ import { type EditorState, objectSelection } from '../state';
import {
activeDrag,
clamp,
drawBand,
drawGrid,
endDrag,
onDragChange,
@@ -299,8 +300,7 @@ export class SceneViewport extends WorldViewport {
ref.setPosition(item.start);
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
}
if (moves.length === 1) this.state.execute(moves[0]!);
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
if (moves.length > 0) this.state.execute(oneStep('Move objects', moves));
}
private onWheel(e: FederatedWheelEvent): void {
@@ -489,15 +489,6 @@ export class SceneViewport extends WorldViewport {
}
this.drawDropPreview(view);
const m = this.marquee;
if (m?.active) {
const x = Math.min(m.start.x, m.current.x);
const y = Math.min(m.start.y, m.current.y);
const w = Math.abs(m.current.x - m.start.x);
const h = Math.abs(m.current.y - m.start.y);
this.gizmos
.rect(x, y, w, h)
.fill({ color: Theme.colors.marquee, alpha: 0.12 })
.stroke({ width: 1, color: Theme.colors.marquee });
}
if (m?.active) drawBand(this.gizmos, m.start, m.current);
}
}