From 6fe59347e04080f76bd0cb7d22461a9eeef2270f Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 5 Oct 2026 23:28:04 +0200 Subject: [PATCH] Name a port apart from its message in the node panel, two lines per port The port name follows the message until it is typed by hand, and clearing it follows again. The second line holds the message, so a list port's item type no longer squeezes the name. --- docs/interface/flow-editor.md | 19 +- docs/reference/node-types.md | 8 +- frontend/src/components/Flow/NodePanel.tsx | 276 +++++++++++++-------- 3 files changed, 197 insertions(+), 106 deletions(-) diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index 35081ec..cc40063 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -35,11 +35,20 @@ the canvas with its panel open. Floats on the right; a full-screen sheet on a phone. -**Consumes** and **Provides** are the ports. Each row is a message name, a -payload type, and, on an output, a *streaming* toggle for ports that publish -repeatedly during one execution. The name field suggests messages already in -the flow, which is usually how you wire something: type the name that already -exists. +**Consumes** and **Provides** are the ports. Each port has two lines: its name +and payload type (a `list` also says what its items are), then the message it +is bound to, how often at most it is delivered, and, on an output, a +*streaming* toggle for ports that publish repeatedly during one execution. The +message field suggests messages already in the flow, which is usually how you +wire something: type the name that already exists. + +A port is named after its message until you name it yourself. Its name field +then stays empty and shows the name it follows. A name typed there stays when +the message changes, which is how a port is bound to a message called +something else, as a [`wren`](../reference/node-types.md#wren) target is; +clear it to follow the message again. The name is what the node's code sees, +so it is letters, digits and `_`, not starting with a digit, and no two inputs +or two outputs of a node share one. The field keeps the name it had otherwise. Renaming an output renames the message everywhere it is read. Inputs are not renamed, because an input is more often re-pointed at a different message than diff --git a/docs/reference/node-types.md b/docs/reference/node-types.md index 19a1f74..e9447c1 100644 --- a/docs/reference/node-types.md +++ b/docs/reference/node-types.md @@ -321,10 +321,10 @@ that is not wired to the node. `heat`, `boost` for heating; `media`, `music`, `tv`; `shutter`, `blind`, `cover`; `water`, `watering`, `sprinkler` -A port naming anything else is refused when the flow is built. The editor -names a port after its message, so there a target's message carries the name; -binding a port to a message named otherwise, as `{"name": "setpoint", "port": -"living_room_setpoint"}`, takes the flow API. +A port naming anything else is refused when the flow is built. A port is +named after its message unless it is given a name of its own, in the node +panel's port name field or as `{"name": "setpoint", "port": +"living_room_setpoint"}` through the flow API. Targets of one area and kind are one device to the model, and each takes what a call means for its type. A target's current value is the state the model diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index cfafc0b..0115b90 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -1,5 +1,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Activity, Maximize2, Minimize2, X } from "lucide-react" +import { + Activity, + CornerDownRight, + Maximize2, + Minimize2, + X, +} from "lucide-react" import { type ComponentProps, lazy, @@ -244,6 +250,14 @@ function MessageNameInput({ ) } +/** Why a port cannot be called `name`, or null when it can. */ +function portProblem(name: string, others: string[]): string | null { + if (!IDENTIFIER.test(name)) + return "A port name is letters, digits and _, and does not start with a digit." + if (others.includes(name)) return `Another port here is called ${name}.` + return null +} + function PortList({ title, specs, @@ -252,6 +266,7 @@ function PortList({ suggestions, onChange, onRenamed, + onPortRenamed, onRemoved, streamable = false, }: { @@ -262,6 +277,8 @@ function PortList({ suggestions: string[] onChange: (next: MessageSpec[]) => void onRenamed?: (previous: string, next: string) => void + /** A port taking another name, reported just before the list that has it. */ + onPortRenamed?: (from: string, to: string) => void /** The port about to be dropped, reported just before the shorter list. */ onRemoved?: (spec: MessageSpec) => void /** Outputs only: a port a node publishes on repeatedly while it runs. */ @@ -269,6 +286,11 @@ function PortList({ }) { // The port just added, so its name field can take focus. const [freshIndex, setFreshIndex] = useState(null) + // A port name is kept once its field is left, so what is typed lives here + // until then. Only one field has focus at a time, so one draft is enough. + const [naming, setNaming] = useState<{ index: number; text: string } | null>( + null, + ) const update = (index: number, patch: Partial) => { const next = specs.map((spec, i) => @@ -298,116 +320,162 @@ function PortList({

{emptyHint}

) : null} - {specs.map((spec, index) => ( - // The curve reads as its own thing rather than as part of the row - // above it, so it gets a little air. -
-
- update(index, { name, port: "" })} - onRenamed={onRenamed} - /> - - {spec.dtype === "list" ? ( + {specs.map((spec, index) => { + // A port follows its message until it is named by hand; the field + // then holds that name, and is empty while there is none. + const own = ownsName(spec) ? (spec.port ?? "") : "" + const typed = naming?.index === index ? naming.text.trim() : null + const problem = typed + ? portProblem( + typed, + specs.filter((_, i) => i !== index).map(portName), + ) + : null + return ( + // The curve reads as its own thing rather than as part of the row + // above it, so it gets a little air. +
+
+ + setNaming({ index, text: event.target.value }) + } + onBlur={() => { + if (typed !== null && !problem && typed !== own) { + const to = typed || portName({ name: spec.name }) + if (to !== portName(spec)) + onPortRenamed?.(portName(spec), to) + update(index, { port: typed }) + } + setNaming(null) + }} + /> - ) : null} - - update(index, { interval: Number(event.target.value) || 0 }) - } - /> - {streamable ? ( + {spec.dtype === "list" ? ( + + ) : null} +
+
+ + + update(index, { + name, + port: ownsName(spec) ? spec.port : "", + }) + } + onRenamed={onRenamed} + /> + + update(index, { interval: Number(event.target.value) || 0 }) + } + /> + {streamable ? ( + + ) : null} +
+ {problem ? ( +

{problem}

+ ) : null} + {spec.name ? ( + ) : null} -
- {spec.name ? ( - - ) : null} -
- ))} + ) + })}
) } @@ -1129,6 +1197,13 @@ const SCAFFOLD_SHAPE = (() => { const portName = (spec: MessageSpec) => spec.port || (spec.name ?? "").split(".").pop() || "" +/** + * Whether a port was named by hand rather than after its message. Nothing + * stores that: a port whose name is not the one its message gives it is one. + */ +const ownsName = (spec: MessageSpec) => + Boolean(spec.port) && spec.port !== portName({ name: spec.name }) + /** * A `process` that takes this node's inputs and settings, and returns its * outputs. @@ -1189,9 +1264,10 @@ function PanelBody({ }) const [code, setCode] = useState(null) - // The port the X is dropping, noted just before the shorter list arrives: - // both are one edit, and a second one would only overwrite the first. - const dropped = useRef(null) + // The port the X is dropping or its name field renaming, noted just before + // the list that says so arrives: both are one edit, and a second one would + // only overwrite the first. + const portMove = useRef<[string, string | null] | null>(null) const timer = useRef | null>(null) const pending = useRef(null) const save = useRef(onSaveSource) @@ -1248,6 +1324,9 @@ function PanelBody({ const renamePort = (previous: string, next: string) => { onRenameMessage(previous, next) + // A port named by hand keeps its name through a new message. + const spec = node.provides?.find((entry) => entry.name === next) + if (spec && ownsName(spec)) return // Only a finished rename moves a value; per keystroke it would delete it // on the first character typed. const moved = repoint( @@ -1309,16 +1388,19 @@ function PanelBody({ emptyHint="Nothing yet. Add a message this node publishes." suggestions={suggestions.provides} onChange={(provides) => { - const port = dropped.current - dropped.current = null - editNode(repoint({ ...node, provides }, port ?? "", null)) + const [from, to] = portMove.current ?? ["", null] + portMove.current = null + editNode(repoint({ ...node, provides }, from, to)) }} streamable // Only the publishing side names a message; an input is as often // re-pointed at a different one as it is renamed. onRenamed={renamePort} + onPortRenamed={(from, to) => { + portMove.current = [from, to] + }} onRemoved={(spec) => { - dropped.current = portName(spec) + portMove.current = [portName(spec), null] }} />