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.
This commit is contained in:
2026-10-05 23:28:04 +02:00
parent aa7d9eece8
commit 6fe59347e0
3 changed files with 197 additions and 106 deletions
+14 -5
View File
@@ -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
+4 -4
View File
@@ -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
+179 -97
View File
@@ -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<number | null>(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<MessageSpec>) => {
const next = specs.map((spec, i) =>
@@ -298,116 +320,162 @@ function PortList({
<p className="text-sm text-muted-foreground">{emptyHint}</p>
) : 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.
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
placeholder={`name in ${flow}`}
autoFocus={index === freshIndex}
onChange={(name) => update(index, { name, port: "" })}
onRenamed={onRenamed}
/>
<Select
value={spec.dtype ?? "float"}
onValueChange={(value) =>
update(index, { dtype: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[104px] text-sm"
aria-label="Type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
{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.
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<Input
value={naming?.index === index ? naming.text : own}
placeholder={portName({ name: spec.name }) || "port"}
aria-label="Port name"
aria-invalid={Boolean(problem)}
title="The name the node sees this port by. Empty follows the message."
className="h-8 flex-1 font-mono text-sm"
onChange={(event) =>
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)
}}
/>
<Select
value={spec.item ?? "record"}
value={spec.dtype ?? "float"}
onValueChange={(value) =>
update(index, { item: value as DType })
update(index, { dtype: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[92px] text-sm"
aria-label="Item type"
title="What each item of the list is"
className="!h-8 w-[104px] text-sm"
aria-label="Type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_DTYPES.map((dtype) => (
{DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Input
type="number"
min={0}
step="any"
value={spec.interval ? String(spec.interval) : ""}
placeholder="∞"
aria-label="Deliver at most every n seconds"
title="Deliver at most every n seconds; empty is every time"
className="h-8 w-14 text-sm"
onChange={(event) =>
update(index, { interval: Number(event.target.value) || 0 })
}
/>
{streamable ? (
{spec.dtype === "list" ? (
<Select
value={spec.item ?? "record"}
onValueChange={(value) =>
update(index, { item: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[92px] text-sm"
aria-label="Item type"
title="What each item of the list is"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Button
variant={spec.stream ? "secondary" : "ghost"}
variant="ghost"
size="icon-sm"
aria-label="Streaming output"
aria-pressed={spec.stream ?? false}
title={
"Published repeatedly while the node runs: a curve rather than " +
"a result. A run keeps every value it takes."
}
className={cn(
"shrink-0",
!spec.stream && "text-muted-foreground",
)}
onClick={() => update(index, { stream: !spec.stream })}
className="text-muted-foreground"
aria-label="Remove port"
onClick={() => {
onRemoved?.(spec)
onChange(specs.filter((_, i) => i !== index))
}}
>
<Activity />
<X />
</Button>
</div>
<div className="flex min-w-0 items-center gap-1.5">
<CornerDownRight
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
/>
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
placeholder={`message in ${flow}`}
autoFocus={index === freshIndex}
onChange={(name) =>
update(index, {
name,
port: ownsName(spec) ? spec.port : "",
})
}
onRenamed={onRenamed}
/>
<Input
type="number"
min={0}
step="any"
value={spec.interval ? String(spec.interval) : ""}
placeholder="∞"
aria-label="Deliver at most every n seconds"
title="Deliver at most every n seconds; empty is every time"
className="h-8 w-14 text-sm"
onChange={(event) =>
update(index, { interval: Number(event.target.value) || 0 })
}
/>
{streamable ? (
<Button
variant={spec.stream ? "secondary" : "ghost"}
size="icon-sm"
aria-label="Streaming output"
aria-pressed={spec.stream ?? false}
title={
"Published repeatedly while the node runs: a curve rather than " +
"a result. A run keeps every value it takes."
}
className={cn(
"shrink-0",
!spec.stream && "text-muted-foreground",
)}
onClick={() => update(index, { stream: !spec.stream })}
>
<Activity />
</Button>
) : null}
</div>
{problem ? (
<p className="text-xs text-destructive">{problem}</p>
) : null}
{spec.name ? (
<MessageSparkline
flow={flow}
name={spec.name}
dtype={spec.dtype ?? undefined}
/>
) : null}
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove port"
onClick={() => {
onRemoved?.(spec)
onChange(specs.filter((_, i) => i !== index))
}}
>
<X />
</Button>
</div>
{spec.name ? (
<MessageSparkline
flow={flow}
name={spec.name}
dtype={spec.dtype ?? undefined}
/>
) : null}
</div>
))}
)
})}
</div>
)
}
@@ -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<string | null>(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<string | null>(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<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<string | null>(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]
}}
/>
<ParamsForm