diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index b556d54..4cdd5c0 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -33,12 +33,18 @@ and open its settings. ## The node panel -**Consumes** and **Provides** declare the node's ports. Each has a port name, -payload type, bound message and delivery interval. Outputs also have a -streaming toggle. +**Consumes** and **Provides** declare the node's ports. Each has a bound +message, a port name, a payload type and a delivery interval. Outputs also have +a streaming toggle. + +A row reads top to bottom, from where a value starts to where it ends. An +input starts as a message and arrives in your code as an argument, so the +message is on top and the argument below it (*as* `prompt`). An output starts +as a key of what your code returns and ends as a message, so the key is on top. A **Device** node stands for a paired device instead: pick the device, then -its ports under **Publishes** and **Follows** (see +its ports under **Publishes** (the device's port, then the message) and +**Follows** (the message, then the port it drives; see [the device's own ports](dashboards.md#the-devices-own-ports)). A device that reports its ports offers them in a list with their type filled in and a form for the settings each one reads, and the command palette offers each one as a @@ -47,10 +53,14 @@ shortcut, such as *Hallway camera*. Message fields suggest existing names. Matching an output's message is how you connect an input. -A blank port-name field follows the message's last segment. Set a port name -to keep the argument name fixed while changing its message binding. Names use -letters, digits and `_`, cannot start with a digit, and must be unique among -the node's inputs or outputs. +A port name follows the message's last segment (`home.outdoor` becomes +`outdoor`) until you name it. While it follows, it is shown as muted text, and +the pencil beside it opens a field. A field only appears on its own when the +name differs from the message. Set a port name to keep the argument name fixed +while changing its message binding. Names use letters, digits and `_`, cannot +start with a digit, and must be unique among the node's inputs or outputs. Two +messages that end in the same segment, such as `a.level` and `b.level`, get a +warning until one of them is named differently. Renaming an output's message updates its consumers in the flow. Changing an input binding points that input at a different message. diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 74e2f95..f35dcef 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -5,6 +5,7 @@ import { CornerDownRight, Maximize2, Minimize2, + Pencil, Trash2, X, } from "lucide-react" @@ -231,6 +232,7 @@ function MessageNameInput({ suggestions, placeholder, autoFocus, + className, onChange, onRenamed, }: { @@ -238,6 +240,8 @@ function MessageNameInput({ suggestions: string[] placeholder: string autoFocus: boolean + /** Replaces the flex share, for a row that wraps. */ + className?: string onChange: (next: string) => void /** The name as it was before this edit, once the field is done with. */ onRenamed?: (previous: string, next: string) => void @@ -253,9 +257,13 @@ function MessageNameInput({ empty="No matching message." placeholder={placeholder} aria-label="Message name" + title={value || undefined} // A port added by hand is meant to be named right away. autoFocus={autoFocus} - className="h-8 flex-1 font-mono text-sm" + className={cn( + "h-11 min-w-0 font-mono text-sm md:h-8", + className ?? "flex-1", + )} onChange={onChange} onFocus={() => { before.current = value @@ -276,8 +284,19 @@ function portProblem(name: string, others: string[]): string | null { return null } +/** + * The ports of one side of a node, a row each. + * + * Data reads top to bottom. An input starts from a message and ends as an + * argument of the node's code, so the message is on top and the argument + * below it. An output starts as a key of what the code returns and ends as a + * message, so the key is on top. A name the code uses only gets a field of its + * own when it differs from the message's last segment, which is what it + * follows until it is named by hand; until then it is muted text. + */ function PortList({ title, + side, specs, flow, emptyHint, @@ -286,9 +305,10 @@ function PortList({ onRenamed, onPortRenamed, onRemoved, - streamable = false, }: { title: string + /** An input reads a message into the code, an output writes one out of it. */ + side: "requires" | "provides" specs: MessageSpec[] flow: string emptyHint: string @@ -299,9 +319,8 @@ function PortList({ 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. */ - streamable?: boolean }) { + const reads = side === "requires" // 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 @@ -309,6 +328,8 @@ function PortList({ const [naming, setNaming] = useState<{ index: number; text: string } | null>( null, ) + // The port whose name was opened for naming by hand with the pencil. + const [opened, setOpened] = useState(null) const update = (index: number, patch: Partial) => { const next = specs.map((spec, i) => @@ -349,31 +370,81 @@ function PortList({ specs.filter((_, i) => i !== index).map(portName), ) : null + const code = portName(spec) + // Two ports that derive one name would only differ by what they carry. + const clash = + !problem && + code !== "" && + specs.some((other, i) => i !== index && portName(other) === code) + const hasField = own !== "" || opened === index || typed !== null + + const codeName = hasField ? ( + 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) + setOpened(null) + }} + /> + ) : ( + + + {code || "message name"} + + + + ) + + const messageName = ( + + update(index, { + name, + port: ownsName(spec) ? spec.port : "", + }) + } + onRenamed={onRenamed} + /> + ) + 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) - }} - /> + {reads ? messageName : codeName} - {spec.dtype === "list" ? ( - - ) : null}
-
+ {/* Wraps for a list port, whose item type would otherwise take + the room the name needs. */} +
- - update(index, { - name, - port: ownsName(spec) ? spec.port : "", - }) - } - onRenamed={onRenamed} - /> - - update(index, { interval: Number(event.target.value) || 0 }) - } - /> - {streamable ? ( - - ) : null} + /> + {/* An input's own flags (it reads without waking the node) go + here, as an output's streaming toggle does. */} + {side === "provides" ? ( + + ) : null} +
{problem ? (

{problem}

+ ) : clash ? ( +

+ Another {reads ? "input" : "output"} here is also called {code}. + Name one of them differently. +

) : null} {spec.name ? ( void onConfig: (port: string, next: Record) => void }) { + // An `out` port is one the device is driven through: it follows a message. + const follows = direction === "out" const offered = reported?.filter((port) => wiresAs(port, direction)) // An out port is read when the device asks for it, never a reason to run. const fresh = (port: ReportedPort | null): MessageSpec => ({ @@ -694,69 +780,82 @@ function DevicePortList({ Object.keys((said.config.properties as object) ?? {}).length ? said.config : undefined + // The device's own port: picked from what it reports, or typed. + const portField = offered ? ( + + ) : ( + { + const next = portOf(event.currentTarget.value) + event.currentTarget.value = next || port + if (next && next !== port) + onChange( + specs.map((one, at) => + at === index ? { ...one, port: next } : one, + ), + port ? [port, next] : undefined, + ) + }} + /> + ) + const messageField = ( + update(index, { name: next })} + /> + ) + // A port the device publishes is read top to bottom as its port, then + // the message. One it follows is a message, then the port it drives. return (
- {offered ? ( - - ) : ( - { - const next = portOf(event.currentTarget.value) - event.currentTarget.value = next || port - if (next && next !== port) - onChange( - specs.map((one, at) => - at === index ? { ...one, port: next } : one, - ), - port ? [port, next] : undefined, - ) - }} - /> - )} + {follows ? messageField : portField}