A Filter sounds like one band of the Parametric EQ, but it keeps what the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a node of its own and gets the EQ's editor with itself as the one band. The editors also stop running out of their room: what does not fit is left out instead of being cut. - The curve of the EQ is now BandCurve with a host the EQ and the Filter both fill. CurvePanel holds what their panels share. - FilterPanel shows the Filter as one band. A drag of its handle sets frequency and gain as one undo step. A number fed by a link is greyed out and the drag leaves it alone. - A double-click on the Filter's title opens the same in a tab of its own. Delete there does nothing, the node stays. - The Filter is tapped like the EQ, so its node and its tab draw the spectrum of what passes it. A tap is two gain nodes per voice, the analysers are made only while someone listens. - EqFace and FilterFace are one CurveFace now, given the panel. - EQ and Filter nodes have a corner to resize them, drawn like the floating window's. The size is saved on the node as `size`, each drag is one undo step, the least is 240 x 120. - In the node the help line is gone. The row of numbers comes in only once the curve keeps 120 px of height. A group of the row that does not fit is not drawn, the one on the right first. - A frequency or dB label that would run into the one before it is left out. - The all-pass is drawn flat. The curve's maths takes every shape a Filter has. Found on the way: - The instrument presets set nodes 240 apart. A Filter with its editor is about 700 wide and covered the Bitcrusher, so a Filter now takes four columns. Tests: - Unit: 8 new (filter band, resize command and save, fitting rules), stage lists with a Filter now hold its taps - e2e 34 (atx): ALL PASSED, 130 checks (4 new) - Gate (atx): CHECK EXIT 0, 284 files, 4367 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
754 lines
28 KiB
TypeScript
754 lines
28 KiB
TypeScript
import { CanvasTextMetrics, Container, Graphics, Rectangle, type Text } from 'pixi.js';
|
||
import {
|
||
asNumber,
|
||
asString,
|
||
edgesInto,
|
||
type Graph,
|
||
type GraphNode,
|
||
inlineInputDef,
|
||
inlineInputs,
|
||
inlineValue,
|
||
type NodeCategory,
|
||
type NodeDef,
|
||
nodeDef,
|
||
type PortDef,
|
||
type PortType,
|
||
type Value,
|
||
} from '../../core/graph';
|
||
import { layerIndexOf, layerMaskOf, layerName, maskLabel } from '../../core/model/collisionLayers';
|
||
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
|
||
import type { Project } from '../../core/model/Project';
|
||
import { isList, type PropertyValue } from '../../core/model/Properties';
|
||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||
import {
|
||
formatNumber,
|
||
Theme,
|
||
textStyle,
|
||
type TextStyleToken,
|
||
toHex,
|
||
truncate,
|
||
Widget,
|
||
crispText,
|
||
} from '../../ui';
|
||
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
|
||
import { RESIZE_HANDLE } from './AnnotationView';
|
||
import { collapsedNodeWidth } from './folding';
|
||
import type { NodeFace } from './nodeFace';
|
||
import { inputTextRoom, valueSpan } from './portRow';
|
||
|
||
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
||
export const NODE_WIDTH = 176;
|
||
export const TITLE_HEIGHT = 24;
|
||
const ROW_HEIGHT = 20;
|
||
const PORT_RADIUS = 5;
|
||
/** Distance (graph units) within which a press counts as hitting a port. */
|
||
const PORT_HIT_RADIUS = 9;
|
||
const PAD = 6;
|
||
/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */
|
||
const CHEVRON_SLOT = 14;
|
||
const TITLE_X = PAD + CHEVRON_SLOT;
|
||
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
|
||
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
|
||
/** From a node's edge to the name of a port. */
|
||
const PORT_TEXT_X = PAD + PORT_RADIUS + 4;
|
||
|
||
function categoryColor(category: NodeCategory): number {
|
||
const c = Theme.colors;
|
||
switch (category) {
|
||
case 'event':
|
||
return c.categoryEvent;
|
||
case 'flow':
|
||
return c.categoryFlow;
|
||
case 'data':
|
||
return c.categoryData;
|
||
case 'variable':
|
||
return c.categoryVariable;
|
||
case 'action':
|
||
return c.categoryAction;
|
||
case 'state':
|
||
return c.categoryState;
|
||
case 'shader':
|
||
return c.categoryShader;
|
||
case 'particles':
|
||
return c.categoryParticles;
|
||
case 'sound':
|
||
return c.categorySound;
|
||
}
|
||
}
|
||
|
||
export function portColor(type: PortType): number {
|
||
const c = Theme.colors;
|
||
switch (type) {
|
||
case 'exec':
|
||
return c.portExec;
|
||
case 'number':
|
||
return c.portNumber;
|
||
case 'boolean':
|
||
return c.portBoolean;
|
||
case 'string':
|
||
return c.portString;
|
||
case 'object':
|
||
return c.portObject;
|
||
case 'list':
|
||
return c.portList;
|
||
case 'struct':
|
||
return c.portStruct;
|
||
case 'vec3':
|
||
return c.portVec3;
|
||
case 'picture':
|
||
return c.portPicture;
|
||
case 'audio':
|
||
return c.portAudio;
|
||
case 'fxparams':
|
||
return c.portFxParams;
|
||
case 'trigger':
|
||
return c.portTrigger;
|
||
case 'envelope':
|
||
return c.portEnvelope;
|
||
case 'any':
|
||
return c.portAny;
|
||
}
|
||
}
|
||
|
||
function rowStyle(port: PortDef): TextStyleToken {
|
||
if (port.ghost) return 'warning';
|
||
return port.type === 'exec' ? 'body' : 'muted';
|
||
}
|
||
|
||
export interface TextSpan {
|
||
text: string;
|
||
left: number;
|
||
right: number;
|
||
}
|
||
|
||
export interface PortHit {
|
||
port: PortDef;
|
||
isOutput: boolean;
|
||
}
|
||
|
||
/** Draws one graph node entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */
|
||
export class NodeView extends Widget {
|
||
private readonly box = new Graphics();
|
||
private readonly ports = new Graphics();
|
||
private readonly texts: Text[] = [];
|
||
/** The input and output text of each port row, among `texts`. */
|
||
private readonly rowTexts: { input: Text | null; output: Text | null }[] = [];
|
||
private paramLines: string[] = [];
|
||
/** Why this node can do nothing; see `callProblem`. */
|
||
private problem: string | null = null;
|
||
selected = false;
|
||
hot = false;
|
||
/** The state a machine begins in wears a ring, so the entry point is visible at a glance. */
|
||
isStart = false;
|
||
/**
|
||
* While something is running: `held` for a state some instance of the machine is in, `current`
|
||
* for the one the selected object's instance holds. Nothing outside a run.
|
||
*/
|
||
stateMark: 'none' | 'held' | 'current' = 'none';
|
||
/** Port a running edge drag would connect to, drawn with a ring so the drop is predictable. */
|
||
targetPort: { port: string; isOutput: boolean } | null = null;
|
||
/** Data inputs that currently have a connection (their inline value is hidden). */
|
||
private connected = new Set<string>();
|
||
private inlineTexts = new Map<string, string>();
|
||
/** Which sides carry a link – a collapsed node marks those with one dot each. */
|
||
private linked = { inputs: false, outputs: false };
|
||
private liveTexts = new Map<string, string>();
|
||
private readonly valueTexts = new Map<string, Text>();
|
||
/** Where an output's value may be drawn: right-aligned before its label, clear of the input text. */
|
||
private valueSlots = new Map<string, { left: number; right: number; top: number }>();
|
||
/**
|
||
* What the texts and the box were last made from. A graph is redrawn on every model event and on
|
||
* every frame of a run, and for nearly every node nothing has changed: a text made anew is a
|
||
* picture uploaded anew, and a box cleared is a shape built anew (#292).
|
||
*/
|
||
private textsOf = '';
|
||
private drawnOf = '';
|
||
/** How wide the sides of a face are, with the pins and their names (#380). */
|
||
private sides = { left: 0, right: 0 };
|
||
/** The corner a face that may be resized is dragged by (#385); it only shows the cursor. */
|
||
private readonly corner = new Container();
|
||
/** What the node was last drawn from, so a drag of its corner can draw it again (#385). */
|
||
private last: { graph: Graph; project: Project | null; problem: string | null } | null = null;
|
||
/** How often the texts and the box were really made, for the checks. */
|
||
readonly made = { texts: 0, box: 0 };
|
||
|
||
/**
|
||
* Not fixed at construction: a nested-behavior node's ports come from another graph, so the
|
||
* definition that applies to this node can change without the node itself changing.
|
||
*/
|
||
def: NodeDef;
|
||
|
||
constructor(
|
||
readonly node: GraphNode,
|
||
def: NodeDef,
|
||
/** What the node shows instead of its rows (#380), if anything. */
|
||
readonly face: NodeFace | null = null,
|
||
) {
|
||
super();
|
||
this.def = def;
|
||
this.cursor = 'grab';
|
||
// Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred
|
||
// nodes were a few hundred draw calls. In the batch they share one.
|
||
this.box.context.batchMode = 'batch';
|
||
this.ports.context.batchMode = 'batch';
|
||
this.addChild(this.box, this.ports);
|
||
if (face) this.addChild(face.widget);
|
||
this.corner.eventMode = 'static';
|
||
this.corner.cursor = 'nwse-resize';
|
||
this.corner.hitArea = new Rectangle(0, 0, RESIZE_HANDLE, RESIZE_HANDLE);
|
||
this.addChild(this.corner);
|
||
this.sync(node, { nodes: [node], edges: [] }, null, def);
|
||
}
|
||
|
||
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
|
||
override resize(w: number, h: number): void {
|
||
super.resize(w, h);
|
||
this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h);
|
||
}
|
||
|
||
/**
|
||
* Whether a local point is on this node, by the same area its pointer events use. An input port
|
||
* sits at exactly x = 0, and rounding a global point back can land a hair outside it – which made
|
||
* a drop on such a port count as a drop on empty canvas and opened the palette.
|
||
*/
|
||
containsLocal(x: number, y: number): boolean {
|
||
return this.hitArea?.contains(x, y) ?? false;
|
||
}
|
||
|
||
/** `project` resolves asset references (Spawn, Is asset). */
|
||
sync(
|
||
node: GraphNode,
|
||
graph: Graph,
|
||
project: Project | null,
|
||
def: NodeDef,
|
||
problem: string | null = null,
|
||
): void {
|
||
this.def = def;
|
||
this.problem = problem;
|
||
this.last = { graph, project, problem };
|
||
this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1;
|
||
const hidden = new Set([
|
||
...(this.def.hiddenParams?.(node.params) ?? []),
|
||
...(this.def.idleParams?.(node.params, (key) => edgesInto(graph, node.id, key).length > 0) ?? []),
|
||
]);
|
||
const title = this.def.label;
|
||
const derived = title !== nodeDef(node.type)?.label;
|
||
this.paramLines = this.def.params
|
||
.filter((p) => !hidden.has(p.key))
|
||
.map((p) => ({
|
||
label: p.label,
|
||
value: formatParam(p, node.params[p.key] ?? p.default, project),
|
||
}))
|
||
// A title made from a setting states it ("Behavior: Double" under "Double"). A dangling
|
||
// reference keeps its row: "Double?" is not in the title.
|
||
.filter((p) => !derived || (p.value !== title && !title.endsWith(` ${p.value}`)))
|
||
.map((p) => `${p.label}: ${p.value}`);
|
||
this.connected = new Set(
|
||
inlineInputs(this.def)
|
||
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
|
||
.map((p) => p.key),
|
||
);
|
||
this.inlineTexts = new Map();
|
||
// Every free input shows what it carries, its default too: an untouched `And` reads "false",
|
||
// not nothing, empty text reads "(empty)" – the same value the node computes with. A free
|
||
// object or struct has nothing to type in and carries null, which it says as well.
|
||
for (const port of inlineInputs(this.def)) {
|
||
if (this.connected.has(port.key)) continue;
|
||
const text = formatField(inlineInputDef(port), inlineValue(node, port), project);
|
||
this.inlineTexts.set(port.key, text === '' ? EMPTY_TEXT : text);
|
||
}
|
||
for (const port of this.def.inputs) {
|
||
if (
|
||
(port.type === 'object' || port.type === 'struct') &&
|
||
edgesInto(graph, node.id, port.key).length === 0
|
||
)
|
||
this.inlineTexts.set(port.key, NULL_TEXT);
|
||
}
|
||
// A face shows the settings and the values itself: its sides name the ports only.
|
||
if (this.shownFace()) {
|
||
this.paramLines = [];
|
||
this.inlineTexts = new Map();
|
||
}
|
||
this.linked = {
|
||
inputs: graph.edges.some((e) => e.to.node === node.id),
|
||
outputs: graph.edges.some((e) => e.from.node === node.id),
|
||
};
|
||
const face = this.shownFace();
|
||
if (face) this.sides = this.faceSides();
|
||
const height = this.collapsed
|
||
? TITLE_HEIGHT
|
||
: face
|
||
? this.headerHeight() + this.faceSize().h + PAD
|
||
: this.rowsEnd() + PAD;
|
||
// Sized before the texts: output labels are placed against the right edge.
|
||
this.resize(
|
||
this.collapsed
|
||
? this.collapsedWidth()
|
||
: face
|
||
? this.sides.left + this.faceSize().w + this.sides.right
|
||
: NODE_WIDTH,
|
||
height,
|
||
);
|
||
if (this.face) {
|
||
this.face.widget.visible = face !== null;
|
||
if (face) this.placeFace(face);
|
||
else this.face.hide();
|
||
}
|
||
this.corner.visible = this.resizable;
|
||
this.corner.position.set(this.w - RESIZE_HANDLE, this.h - RESIZE_HANDLE);
|
||
const texts = this.textSignature();
|
||
if (texts !== this.textsOf) {
|
||
this.textsOf = texts;
|
||
this.made.texts += 1;
|
||
this.rebuildTexts();
|
||
}
|
||
this.position.set(node.x, node.y);
|
||
}
|
||
|
||
/** Everything `rebuildTexts` reads. */
|
||
private textSignature(): string {
|
||
const rows: unknown[] = [];
|
||
for (let i = 0; i < this.rowCount(); i++) {
|
||
const input = this.def.inputs[i];
|
||
const output = this.def.outputs[i];
|
||
rows.push(
|
||
input ? [this.inputText(input), rowStyle(input)] : null,
|
||
output ? [output.label, rowStyle(output), output.type !== 'exec' && !output.ghost, output.key] : null,
|
||
);
|
||
}
|
||
return JSON.stringify([this.w, this.collapsed, this.def.label, this.paramLines, this.problem, rows]);
|
||
}
|
||
|
||
/** Everything `redraw` reads. */
|
||
private drawSignature(): string {
|
||
const port = (p: PortDef) => `${p.key}:${p.paintType ?? p.type}:${p.ghost ? 1 : 0}`;
|
||
return JSON.stringify([
|
||
this.w,
|
||
this.h,
|
||
this.collapsed,
|
||
this.def.category,
|
||
this.def.transitionEdges === true,
|
||
this.isStart,
|
||
this.hot,
|
||
this.selected,
|
||
this.problem !== null,
|
||
this.stateMark,
|
||
this.linked,
|
||
this.targetPort,
|
||
this.headerHeight(),
|
||
this.resizable,
|
||
this.def.inputs.map(port),
|
||
this.def.outputs.map(port),
|
||
]);
|
||
}
|
||
|
||
private collapsedWidth(): number {
|
||
const title = CanvasTextMetrics.measureText(this.def.label, textStyle('accent')).width;
|
||
return collapsedNodeWidth(title, TITLE_X + COLLAPSED_TITLE_END, NODE_WIDTH);
|
||
}
|
||
|
||
get collapsed(): boolean {
|
||
return this.node.collapsed === true;
|
||
}
|
||
|
||
chevronAt(localX: number, localY: number): boolean {
|
||
return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < TITLE_X;
|
||
}
|
||
|
||
chevronGlobal(): { x: number; y: number } {
|
||
return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 });
|
||
}
|
||
|
||
/** Rows of ports, which set the node's height – none on a state, whose ports are never drawn. */
|
||
private rowCount(): number {
|
||
if (this.def.transitionEdges === true) return 0;
|
||
return Math.max(this.def.inputs.length, this.def.outputs.length);
|
||
}
|
||
|
||
/** The warning line as drawn – checks read what the node really shows, not a recomputation. */
|
||
get problemText(): string | null {
|
||
return this.problem;
|
||
}
|
||
|
||
/** The setting rows as drawn, for the same reason. */
|
||
get settingLines(): string[] {
|
||
return [...this.paramLines];
|
||
}
|
||
|
||
get inputLines(): string[] {
|
||
return this.def.inputs.map((input) => this.inputText(input));
|
||
}
|
||
|
||
/** Where each port row's input and output text really landed, in node units, as cut to fit. */
|
||
get rowSpans(): { input: TextSpan | null; output: TextSpan | null }[] {
|
||
const span = (t: Text | null): TextSpan | null => t && { text: t.text, left: t.x, right: t.x + t.width };
|
||
return this.rowTexts.map((row) => ({ input: span(row.input), output: span(row.output) }));
|
||
}
|
||
|
||
get outputLines(): string[] {
|
||
return this.def.outputs.map((output) => {
|
||
const value = this.valueTexts.get(output.key)?.text;
|
||
return value === undefined ? output.label : `${value} ${output.label}`;
|
||
});
|
||
}
|
||
|
||
/** Nothing clears them when play ends: the views are rebuilt then. */
|
||
setLiveValues(values: Record<string, Value> | null, project: Project | null): void {
|
||
const next = new Map<string, string>();
|
||
for (const output of this.def.outputs) {
|
||
const value = values?.[output.key];
|
||
if (output.type === 'exec' || value === undefined) continue;
|
||
next.set(output.key, formatLive(output, value, project));
|
||
}
|
||
if (next.size === this.liveTexts.size && [...next].every(([k, v]) => this.liveTexts.get(k) === v)) return;
|
||
this.liveTexts = next;
|
||
this.placeValues();
|
||
}
|
||
|
||
private placeValues(): void {
|
||
for (const text of this.valueTexts.values()) text.destroy();
|
||
this.valueTexts.clear();
|
||
if (this.collapsed) return;
|
||
const style = textStyle('live');
|
||
for (const [key, value] of this.liveTexts) {
|
||
const slot = this.valueSlots.get(key);
|
||
if (!slot || slot.right - slot.left < 12) continue;
|
||
const text = crispText({ text: truncate(value, style, slot.right - slot.left), style });
|
||
text.position.set(slot.right - text.width, slot.top);
|
||
this.addChild(text);
|
||
this.valueTexts.set(key, text);
|
||
}
|
||
}
|
||
|
||
private inputText(input: PortDef): string {
|
||
const inline = this.inlineTexts.get(input.key);
|
||
return inline === undefined ? input.label : `${input.label} ${inline}`;
|
||
}
|
||
|
||
/** Everything above the first port row. The problem line sits in here, so ports move down with it. */
|
||
private headerHeight(): number {
|
||
const lines = this.paramLines.length + (this.problem ? 1 : 0);
|
||
return TITLE_HEIGHT + PAD + lines * ROW_HEIGHT;
|
||
}
|
||
|
||
/** Below the last port row. */
|
||
private rowsEnd(): number {
|
||
return this.headerHeight() + this.rowCount() * ROW_HEIGHT;
|
||
}
|
||
|
||
/** The node's face, while the node is open. */
|
||
private shownFace(): NodeFace | null {
|
||
return (!this.collapsed && this.face) || null;
|
||
}
|
||
|
||
/** The size the node was dragged to (#385), never below the least its face takes; else the face's own. */
|
||
private faceSize(): { w: number; h: number } {
|
||
const min = this.face?.minSize;
|
||
const size = this.node.size;
|
||
if (min && size) return { w: Math.max(min.w, size.w), h: Math.max(min.h, size.h) };
|
||
return this.face?.widget.measure() ?? { w: 0, h: 0 };
|
||
}
|
||
|
||
/** Whether the node has a corner to resize its face by: an open node whose face may be (#385). */
|
||
get resizable(): boolean {
|
||
return this.shownFace()?.minSize !== undefined;
|
||
}
|
||
|
||
/** Whether a local point is on that corner. */
|
||
cornerAt(x: number, y: number): boolean {
|
||
return (
|
||
this.resizable &&
|
||
x >= this.w - RESIZE_HANDLE &&
|
||
x <= this.w &&
|
||
y >= this.h - RESIZE_HANDLE &&
|
||
y <= this.h
|
||
);
|
||
}
|
||
|
||
cornerGlobal(): { x: number; y: number } | null {
|
||
if (!this.resizable) return null;
|
||
const at = this.toGlobal({ x: this.w - RESIZE_HANDLE / 2, y: this.h - RESIZE_HANDLE / 2 });
|
||
return { x: at.x, y: at.y };
|
||
}
|
||
|
||
/** How much of the node is not its face: the least the node itself can be dragged to follows from it. */
|
||
get faceChrome(): { w: number; h: number } {
|
||
const size = this.faceSize();
|
||
return { w: this.w - size.w, h: this.h - size.h };
|
||
}
|
||
|
||
/** A drag of the corner changed the node's size in the model: the node is drawn at it. */
|
||
refit(): void {
|
||
const last = this.last;
|
||
const face = this.shownFace();
|
||
if (!last || !face) return;
|
||
const size = this.faceSize();
|
||
if (size.w === face.widget.w && size.h === face.widget.h) return;
|
||
this.sync(this.node, last.graph, last.project, this.def, last.problem);
|
||
}
|
||
|
||
/** Each side of a face as wide as the longest name on it. */
|
||
private faceSides(): { left: number; right: number } {
|
||
const side = (ports: PortDef[]) =>
|
||
Math.ceil(
|
||
PORT_TEXT_X +
|
||
Math.max(
|
||
0,
|
||
...ports.map((p) => CanvasTextMetrics.measureText(p.label, textStyle(rowStyle(p))).width),
|
||
) +
|
||
PAD,
|
||
);
|
||
return { left: side(this.def.inputs), right: side(this.def.outputs) };
|
||
}
|
||
|
||
private placeFace(face: NodeFace): void {
|
||
const size = this.faceSize();
|
||
face.widget.position.set(this.sides.left, this.headerHeight());
|
||
face.widget.resize(size.w, size.h);
|
||
face.sync();
|
||
}
|
||
|
||
/** Local position of a port's centre. */
|
||
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
|
||
const list = isOutput ? this.def.outputs : this.def.inputs;
|
||
const index = list.findIndex((p) => p.key === key);
|
||
if (index < 0) return null;
|
||
return { x: isOutput ? this.w : 0, y: this.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) };
|
||
}
|
||
|
||
/** A face has its ports in the middle of its sides. */
|
||
private rowY(index: number): number {
|
||
const top = this.shownFace() ? (this.faceSize().h - this.rowCount() * ROW_HEIGHT) / 2 : 0;
|
||
return this.headerHeight() + top + index * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||
}
|
||
|
||
/**
|
||
* Every port with its local centre – none while collapsed, and none on a state, so neither can
|
||
* be drawn or grabbed. `portPosition` stays open either way: the edges still need their anchor.
|
||
*/
|
||
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
|
||
if (this.collapsed || this.def.transitionEdges === true) return;
|
||
for (const [list, isOutput] of [
|
||
[this.def.inputs, false],
|
||
[this.def.outputs, true],
|
||
] as const) {
|
||
for (let i = 0; i < list.length; i++)
|
||
yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
|
||
}
|
||
}
|
||
|
||
portAt(localX: number, localY: number): PortHit | null {
|
||
for (const { port, isOutput, x, y } of this.portCentres()) {
|
||
if (Math.hypot(localX - x, localY - y) <= PORT_HIT_RADIUS) return { port, isOutput };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private rebuildTexts(): void {
|
||
for (const t of this.texts) t.destroy();
|
||
this.texts.length = 0;
|
||
this.rowTexts.length = 0;
|
||
this.valueSlots = new Map();
|
||
const add = (
|
||
text: string,
|
||
style: TextStyleToken,
|
||
x: number,
|
||
y: number,
|
||
align: 'left' | 'right' = 'left',
|
||
maxWidth = NODE_WIDTH - PAD * 2,
|
||
) => {
|
||
const node = crispText({ 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);
|
||
return node;
|
||
};
|
||
add(
|
||
this.def.label,
|
||
'accent',
|
||
TITLE_X,
|
||
(TITLE_HEIGHT - 16) / 2,
|
||
'left',
|
||
this.w - TITLE_X - (this.collapsed ? COLLAPSED_TITLE_END : PAD),
|
||
);
|
||
if (this.collapsed) {
|
||
this.placeValues();
|
||
return;
|
||
}
|
||
let y = TITLE_HEIGHT + PAD;
|
||
for (const line of this.paramLines) {
|
||
add(line, 'small', PAD + 2, y + 2);
|
||
y += ROW_HEIGHT;
|
||
}
|
||
if (this.problem) add(this.problem, 'warning', PAD + 2, y + 2);
|
||
const inputX = PORT_TEXT_X;
|
||
const face = this.shownFace() !== null;
|
||
for (let i = 0; i < this.rowCount(); i++) {
|
||
const input = this.def.inputs[i];
|
||
const output = this.def.outputs[i];
|
||
const rowTop = this.rowY(i) - ROW_HEIGHT / 2 + 2;
|
||
const outputText = output
|
||
? add(
|
||
output.label,
|
||
rowStyle(output),
|
||
this.w - PAD - PORT_RADIUS - 4,
|
||
rowTop,
|
||
'right',
|
||
face ? this.sides.right : NODE_WIDTH / 2 - 10,
|
||
)
|
||
: null;
|
||
const room = face
|
||
? this.sides.left
|
||
: inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null);
|
||
const inputText = input
|
||
? add(this.inputText(input), rowStyle(input), inputX, rowTop, 'left', room)
|
||
: null;
|
||
this.rowTexts.push({ input: inputText, output: outputText });
|
||
if (!face && output && outputText && output.type !== 'exec' && !output.ghost) {
|
||
const inputEnd = inputText ? inputText.x + inputText.width : PAD;
|
||
this.valueSlots.set(output.key, { ...valueSpan(inputEnd, outputText.x), top: rowTop + 1 });
|
||
}
|
||
}
|
||
this.placeValues();
|
||
}
|
||
|
||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||
this.face?.hide();
|
||
super.destroy(options);
|
||
}
|
||
|
||
override redraw(): void {
|
||
const drawn = this.drawSignature();
|
||
if (drawn === this.drawnOf) return;
|
||
this.drawnOf = drawn;
|
||
this.made.box += 1;
|
||
const c = Theme.colors;
|
||
this.box.clear();
|
||
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
|
||
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
|
||
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
|
||
if (!this.collapsed)
|
||
this.box
|
||
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
|
||
.fill(categoryColor(this.def.category));
|
||
this.drawChevron(PAD + 4, TITLE_HEIGHT / 2);
|
||
// The ring sits where a state's ports never are: a state draws none (see `portCentres`).
|
||
if (this.isStart)
|
||
this.box
|
||
.circle(this.w - PAD - PORT_RADIUS, TITLE_HEIGHT / 2, PORT_RADIUS)
|
||
.stroke({ width: 2, color: c.text });
|
||
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder;
|
||
this.box
|
||
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
|
||
.stroke({ width: this.selected || this.hot || this.problem ? 2 : 1, color: border });
|
||
// Inside the border, so a selected state still shows both: what is picked, and what is live.
|
||
if (this.stateMark !== 'none')
|
||
this.box
|
||
.roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius)
|
||
.stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 });
|
||
if (this.resizable) {
|
||
// The floating window's corner (#371), so it reads as the same handle.
|
||
for (const at of [4, 8, 12])
|
||
this.box.moveTo(this.w - 2 - at, this.h - 2).lineTo(this.w - 2, this.h - 2 - at);
|
||
this.box.stroke({ width: 1, color: c.textMuted });
|
||
}
|
||
|
||
this.ports.clear();
|
||
const r = PORT_RADIUS;
|
||
if (this.collapsed) {
|
||
for (const [linked, x] of [
|
||
[this.linked.inputs, 0],
|
||
[this.linked.outputs, this.w],
|
||
] as const) {
|
||
if (!linked) continue;
|
||
this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).fill(c.textMuted);
|
||
this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).stroke({ width: 1, color: c.node });
|
||
}
|
||
}
|
||
for (const { port, isOutput, x, y } of this.portCentres()) {
|
||
if (this.targetPort?.port === port.key && this.targetPort.isOutput === isOutput) {
|
||
// A gap in the node colour first, so the ring stays visible on the white exec pin too.
|
||
this.ports.circle(x, y, r + 2).stroke({ width: 2, color: c.node });
|
||
this.ports.circle(x, y, r + 4).stroke({ width: 2, color: c.portTarget });
|
||
}
|
||
if (port.ghost) {
|
||
// Hollow: a pin is drawn where a port used to be, and nothing stands behind it any more.
|
||
this.ports.circle(x, y, r).stroke({ width: 2, color: c.danger });
|
||
} else if (port.type === 'exec') {
|
||
// A pentagon pointing right: the Blueprint-style exec pin.
|
||
this.ports
|
||
.moveTo(x - r, y - r)
|
||
.lineTo(x + 1, y - r)
|
||
.lineTo(x + r + 1, y)
|
||
.lineTo(x + 1, y + r)
|
||
.lineTo(x - r, y + r)
|
||
.closePath()
|
||
.fill(portColor('exec'));
|
||
} else {
|
||
// `paintType`: an `any` input takes the colour of the edge feeding it, its type stays `any`.
|
||
this.ports.circle(x, y, r).fill(portColor(port.paintType ?? port.type));
|
||
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The hierarchy's shape, so both folds read the same. */
|
||
private drawChevron(x: number, y: number): void {
|
||
const color = Theme.colors.text;
|
||
if (this.collapsed)
|
||
this.box
|
||
.moveTo(x - 1, y - 4)
|
||
.lineTo(x + 4, y)
|
||
.lineTo(x - 1, y + 4)
|
||
.fill(color);
|
||
else
|
||
this.box
|
||
.moveTo(x - 3, y - 2)
|
||
.lineTo(x + 5, y - 2)
|
||
.lineTo(x + 1, y + 3)
|
||
.fill(color);
|
||
}
|
||
}
|
||
|
||
function formatParam(param: PropertyDef, value: PropertyValue | undefined, project: Project | null): string {
|
||
if (value === undefined || value === '') return '—';
|
||
return formatField(param, value, project);
|
||
}
|
||
|
||
/** A value in the words of the field that edits it: a key's name, a color's hex, a texture's name. */
|
||
function formatField(def: PropertyDef, value: Value, project: Project | null): string {
|
||
if (def.kind === 'key') return keyLabel(asString(value));
|
||
if (def.kind === 'color') return toHex(asNumber(value));
|
||
// Layers read as names, not as the bits they are stored in (#152, #158).
|
||
const layers = project?.collisionLayers ?? [];
|
||
if (def.kind === 'layer') return layerName(layers, layerIndexOf(value));
|
||
if (def.kind === 'layers') return maskLabel(layerMaskOf(value), layers);
|
||
if (def.kind === 'tileLayer') return asString(value) || FIRST_TILE_LAYER;
|
||
if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value);
|
||
return referenceLabel(def, project, asString(value)) ?? formatValue(value);
|
||
}
|
||
|
||
/** How a node says "no value" and "empty value" – for inputs and live outputs alike. */
|
||
/** How a node that does nothing at its settings is drawn, a bypassed effect (#354). */
|
||
const DIMMED_ALPHA = 0.45;
|
||
const NULL_TEXT = '(null)';
|
||
const EMPTY_TEXT = '(empty)';
|
||
|
||
function formatLive(output: PortDef, value: Value, project: Project | null): string {
|
||
if (value === null) return NULL_TEXT;
|
||
if (value === '') return EMPTY_TEXT;
|
||
return output.field ? formatField(output.field, value, project) : formatValue(value);
|
||
}
|
||
|
||
function formatValue(value: Value): string {
|
||
if (typeof value === 'number') return formatNumber(value);
|
||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||
// A list can hold hundreds of items – shown whole it would stretch the node across the graph.
|
||
if (isList(value)) {
|
||
if (value.length === 0) return EMPTY_TEXT;
|
||
const head = value.slice(0, 4).map(formatValue).join(', ');
|
||
return value.length > 4 ? `${head}, … (${value.length})` : head;
|
||
}
|
||
return asString(value);
|
||
}
|