git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commita29d54d0b5d7d1df348a56602db6342fdb8848f4
parentc042bcde9d
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 13:02
feat: a graph draws every wire a host types node, each into its own port

wire_pairs drew one wire, the parameter named input, so a Switch's second
input or a Boolean's With was a connection with no line. Every parameter
typed node is a wire now, the k-th into input port k; a host that types
none keeps its one input wire. A connection reports the port it was
dropped on (take_pending_connection_to_port), and a splice takes only a
wire into port 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                   |  19 +++++++
 src/widget/display/graph.rs | 135 ++++++++++++++++++++++++++++++++++----------
 src/widget/display/mod.rs   |   2 +-
 src/widget/mod.rs           |   9 ++-
 4 files changed, 133 insertions(+), 32 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 71c99f1..b1a76be 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -784,6 +784,25 @@ NOTHING until this change, when the wires were a hard-coded cyan 3 px.
 `every_wire_style_runs_from_port_to_port` and the splice test, run in all
 four styles, are the tests.
 
+## A node has as many wires as the host says (since 2026-09-30)
+
+`Graph::wire_pairs` draws a wire for EVERY parameter a host types `node`,
+the k-th into input port k (`node_wires`, public so a host can read the
+rule back) — a Switch's four inputs, a Boolean's With, a Transfer's From.
+Until then it drew one, the parameter NAMED `input`, so every second
+operand was a real connection with no line. A host that types no parameter
+`node` keeps exactly that (cce-files and cce-graph pass `("input", name,
+"string")`), so nothing changed for them. An empty value is a port with
+nothing wired; a wire past the node's ports lands on port 0.
+
+A connection the pointer makes reports its port too:
+`GraphController::take_pending_connection_to_port` gives (input node id,
+output node name, port), and the old `take_pending_connection` — which
+takes the same connection — is the default for hosts that do not care.
+Splicing a dragged node onto a wire takes only a wire into port 0, since
+the splice rewires Inputs. `every_node_parameter_is_a_wire_into_its_own_port`
+is the test.
+
 ## Units — logical px inside, real lengths at the edges
 
 The toolkit's working unit is and stays the **logical pixel**: every layout
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 90de728..95e5d91 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -226,6 +226,26 @@ fn segment_meets_rect(a: (f32, f32), b: (f32, f32), x1: f32, y1: f32, x2: f32, y
     true
 }
 
+/// The nodes `node` reads, one per input port in order: the values of its
+/// parameters of type `node`, else its parameter named `input` alone (see
+/// `Graph::wire_pairs`). Empty is a port with nothing wired to it.
+pub fn node_wires(node: &GraphNode) -> Vec<String> {
+    let typed: Vec<String> = node
+        .parameters
+        .iter()
+        .filter(|(_, _, ty)| ty == "node")
+        .map(|(_, value, _)| value.trim().to_string())
+        .collect();
+    if !typed.is_empty() {
+        return typed;
+    }
+    node.parameters
+        .iter()
+        .find(|(name, _, _)| name.eq_ignore_ascii_case("input"))
+        .map(|(_, value, _)| vec![value.clone()])
+        .unwrap_or_default()
+}
+
 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
 pub struct GraphNode {
     #[serde(default)]
@@ -295,7 +315,9 @@ pub struct Graph {
     // Connection state
     connecting_from: Option<(usize, PortType, usize)>,
     current_mouse_pos: (f32, f32),
-    pending_connection: Option<(String, String)>,
+    /// A connection finished by the pointer: (input node id, output node
+    /// name, the input PORT it was dropped on).
+    pending_connection: Option<(String, String, usize)>,
     hovered_port: Option<(usize, PortType, usize)>,
 
     /// The wire the in-flight node drag would splice into, as (src node id,
@@ -673,8 +695,8 @@ impl Graph {
     }
 
     /// The wire from `src_idx`'s output to `dest_idx`'s input, as drawn.
-    fn wire_segments(&self, src_idx: usize, dest_idx: usize) -> Option<Vec<WireSeg>> {
-        let (start, end) = self.wire_endpoints(src_idx, dest_idx)?;
+    fn wire_segments(&self, src_idx: usize, dest_idx: usize, port: usize) -> Option<Vec<WireSeg>> {
+        let (start, end) = self.wire_endpoints(src_idx, dest_idx, port)?;
         Some(wire_path(self.wire_style(), start, end, self.wire_thickness(), self.wire_bend()))
     }
 
@@ -701,8 +723,8 @@ impl Graph {
         let hl = crate::color::graph_wire_highlight_color();
         let splice_color = [hl[0], hl[1], hl[2], hl[3] * self.node_opacity];
         pc.clip(rect, |pc| {
-            for (src_idx, i) in self.wire_pairs() {
-                let Some(segs) = self.wire_segments(src_idx, i) else { continue };
+            for (src_idx, i, port) in self.wire_pairs() {
+                let Some(segs) = self.wire_segments(src_idx, i, port) else { continue };
                 let is_splice_target = self
                     .splice_target
                     .as_ref()
@@ -1213,20 +1235,20 @@ impl Graph {
                         let dx = px - port_x;
                         let dy = py - port_y;
                         if dx * dx + dy * dy <= port_click_radius_sq {
-                            if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
+                            if let Some((src_idx, src_port_type, src_port_idx)) = self.connecting_from {
                                 // A click on the opposite port kind of ANOTHER
                                 // node completes the connection; anything else
                                 // cancels it.
                                 if src_idx != i && src_port_type != port_type {
-                                    let (out_idx, in_idx) = if port_type == PortType::Input {
-                                        (src_idx, i)
+                                    let (out_idx, in_idx, in_port) = if port_type == PortType::Input {
+                                        (src_idx, i, k)
                                     } else {
-                                        (i, src_idx)
+                                        (i, src_idx, src_port_idx)
                                     };
                                     let output_node = &self.nodes[out_idx];
                                     let input_node = &self.nodes[in_idx];
                                     self.pending_connection =
-                                        Some((input_node.id.clone(), output_node.name.clone()));
+                                        Some((input_node.id.clone(), output_node.name.clone(), in_port));
                                 }
                                 self.connecting_from = None;
                             } else {
@@ -1241,7 +1263,7 @@ impl Graph {
         }
 
         // Actively connecting + clicked a target node body: connect to its closest compatible port
-        if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
+        if let Some((src_idx, src_port_type, src_port_idx)) = self.connecting_from {
             for i in (0..self.nodes.len()).rev() {
                 if src_idx != i {
                     if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
@@ -1250,13 +1272,14 @@ impl Graph {
                             if src_port_type == PortType::Output && node.inputs > 0 {
                                 let output_node = &self.nodes[src_idx];
                                 let input_node = &self.nodes[i];
-                                self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
+                                // A body, not a port: its first input.
+                                self.pending_connection = Some((input_node.id.clone(), output_node.name.clone(), 0));
                                 self.connecting_from = None;
                                 return true;
                             } else if src_port_type == PortType::Input && node.outputs > 0 {
                                 let output_node = &self.nodes[i];
                                 let input_node = &self.nodes[src_idx];
-                                self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
+                                self.pending_connection = Some((input_node.id.clone(), output_node.name.clone(), src_port_idx));
                                 self.connecting_from = None;
                                 return true;
                             }
@@ -1305,19 +1328,25 @@ impl Graph {
         false
     }
 
-    /// The wire pairs the draw pass renders: (src idx, dest idx), one per
-    /// node whose "Input" parameter names another node — the ONE derivation,
-    /// shared with the splice hit test so the two cannot disagree about
-    /// where a wire is.
-    fn wire_pairs(&self) -> Vec<(usize, usize)> {
+    /// The wires the draw pass renders: (src idx, dest idx, input port) —
+    /// the ONE derivation, shared with the splice hit test so the two cannot
+    /// disagree about where a wire is.
+    ///
+    /// A node's wires are its parameters of type `node`, in order, the k-th
+    /// into input port k: every one a host marks so, not just the first
+    /// (the designer's Switch reads four, a Boolean two). A node with none
+    /// so marked has its parameter NAMED `input` as its one wire, into port
+    /// 0 — what every host passed before the type said it (cce-files,
+    /// cce-graph).
+    fn wire_pairs(&self) -> Vec<(usize, usize, usize)> {
         let mut out = Vec::new();
         for i in 0..self.nodes.len() {
-            let node = &self.nodes[i];
-            if let Some((_, input_name, _)) =
-                node.parameters.iter().find(|(name, _, _)| name.eq_ignore_ascii_case("input"))
-            {
-                if let Some(src_idx) = self.nodes.iter().position(|n| n.name == *input_name) {
-                    out.push((src_idx, i));
+            for (port, source) in node_wires(&self.nodes[i]).into_iter().enumerate() {
+                if source.is_empty() {
+                    continue;
+                }
+                if let Some(src_idx) = self.nodes.iter().position(|n| n.name == source) {
+                    out.push((src_idx, i, port));
                 }
             }
         }
@@ -1328,14 +1357,17 @@ impl Graph {
     /// back to the node edge midpoints for portless nodes. The three-segment
     /// shape (down, across, down) derives from these in both the draw pass
     /// and [`Self::wire_segment_rects`].
-    fn wire_endpoints(&self, src_idx: usize, dest_idx: usize) -> Option<((f32, f32), (f32, f32))> {
+    fn wire_endpoints(&self, src_idx: usize, dest_idx: usize, port: usize) -> Option<((f32, f32), (f32, f32))> {
         let (sx, sy, sw, sh) = self.node_rect(src_idx)?;
         let (ex, ey, ew, _eh) = self.node_rect(dest_idx)?;
         let start = self
             .port_center(src_idx, PortType::Output, 0)
             .unwrap_or((sx + sw / 2.0, sy + sh));
+        // Into its own port; a wire past the node's ports (a host that
+        // declared fewer) lands on the first, and a portless node's edge.
         let end = self
-            .port_center(dest_idx, PortType::Input, 0)
+            .port_center(dest_idx, PortType::Input, port)
+            .or_else(|| self.port_center(dest_idx, PortType::Input, 0))
             .unwrap_or((ex + ew / 2.0, ey));
         Some((start, end))
     }
@@ -1345,7 +1377,9 @@ impl Graph {
     /// inflated by the wire activation radius so a near miss still takes.
     /// The dragged node's own wires never count (dropping a node on a wire
     /// it is already an end of is a move, not a rewire), and a node with no
-    /// "Input" parameter or no output port cannot sit mid-chain.
+    /// "Input" parameter or no output port cannot sit mid-chain. Only a
+    /// wire into port 0 — the Input — is spliced into, since the splice
+    /// rewires the Inputs.
     fn splice_wire_at(&self, idx: usize, nx: f32, ny: f32) -> Option<(usize, usize)> {
         let node = self.nodes.get(idx)?;
         let has_input = node
@@ -1361,11 +1395,11 @@ impl Graph {
         let pad = crate::layout::graph_wire_activation_radius().max(0.0) + self.wire_thickness() / 2.0;
         let (gx1, gy1) = (nx - pad, ny - pad);
         let (gx2, gy2) = (nx + nw + pad, ny + nh + pad);
-        for (src, dest) in self.wire_pairs() {
-            if src == idx || dest == idx {
+        for (src, dest, port) in self.wire_pairs() {
+            if src == idx || dest == idx || port != 0 {
                 continue;
             }
-            let Some(segs) = self.wire_segments(src, dest) else { continue };
+            let Some(segs) = self.wire_segments(src, dest, port) else { continue };
             let hit = segs.iter().any(|seg| match *seg {
                 WireSeg::Line(a, b) => segment_meets_rect(a, b, gx1, gy1, gx2, gy2),
                 // An arc, as the chords of its eighths.
@@ -1502,6 +1536,9 @@ impl GraphController for Graph {
     fn grid_origin(&self) -> (f32, f32) { (self.grid_origin_x, self.grid_origin_y) }
     fn set_show_network_grid(&mut self, show: bool) { self.show_network_grid = show; }
     fn take_pending_connection(&mut self) -> Option<(String, String)> {
+        self.pending_connection.take().map(|(id, name, _)| (id, name))
+    }
+    fn take_pending_connection_to_port(&mut self) -> Option<(String, String, usize)> {
         self.pending_connection.take()
     }
     fn take_pending_splice(&mut self) -> Option<(String, String, String)> {
@@ -1838,6 +1875,44 @@ mod tests {
         assert_eq!(pending, Some(("b".to_string(), "alpha".to_string())));
     }
 
+    /// Every parameter a host types `node` is a wire, the k-th into input
+    /// port k, and a connection dropped on a port says which; a host that
+    /// types none keeps its one `input` wire.
+    #[test]
+    fn every_node_parameter_is_a_wire_into_its_own_port() {
+        let mut ctx = UiContext::new();
+        let mut g = two_nodes();
+        let (id, ptr) = (g.id(), g.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let wire = |n: &str, v: &str| (n.to_string(), v.to_string(), "node".to_string());
+        let node = |id: &str, col: f32, row: f32, parameters: Vec<(String, String, String)>, inputs: usize| GraphNode {
+            id: id.into(),
+            name: id.into(),
+            position: (col, row),
+            parameters,
+            geom_visible: true,
+            node_type: String::new(),
+            inputs,
+            outputs: 1,
+        };
+        g.set_nodes(&[
+            node("a", 0.0, 0.0, vec![], 0),
+            node("b", 2.0, 0.0, vec![], 0),
+            node("sw", 1.0, 2.0, vec![wire("Input", "a"), wire("Input 2", "b"), wire("Input 3", ""), ("Index".into(), "1".into(), "spinbox".into())], 3),
+            node("old", 3.0, 2.0, vec![("input".into(), "b".into(), "string".into())], 1),
+        ]);
+        assert_eq!(g.wire_pairs(), vec![(0, 2, 0), (1, 2, 1), (1, 3, 0)], "two wires into the switch, each its port; the untyped host's one");
+        let (_, end) = g.wire_endpoints(1, 2, 1).unwrap();
+        assert_eq!(Some(end), g.port_center(2, PortType::Input, 1), "into its own port");
+
+        // Dropped on the switch's third port: port 2.
+        let (ax, ay) = g.port_center(0, PortType::Output, 0).unwrap();
+        assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, ax, ay, &mut ctx));
+        let (px, py) = g.port_center(2, PortType::Input, 2).unwrap();
+        assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, px, py, &mut ctx));
+        assert_eq!(GraphController::take_pending_connection_to_port(&mut *g), Some(("sw".to_string(), "a".to_string(), 2)));
+    }
+
     #[test]
     fn dual_geometry_views_stay_consistent() {
         let mut g = two_nodes();
diff --git a/src/widget/display/mod.rs b/src/widget/display/mod.rs
index ae2021f..908abfa 100644
--- a/src/widget/display/mod.rs
+++ b/src/widget/display/mod.rs
@@ -28,7 +28,7 @@ pub use self::splitter::Splitter;
 pub use self::list_item::{TextItem, InteractiveListItem};
 pub use self::separator::Separator;
 pub use self::serialize::serialize_widgets;
-pub use self::graph::{GraphNode, Graph, TaggedQuad, WireStyle};
+pub use self::graph::{GraphNode, Graph, TaggedQuad, WireStyle, node_wires};
 pub use self::usage_bar::UsageBar;
 pub use self::info_box::InfoBox;
 pub use self::status_dot::{DotStatus, StatusDot};
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 54d8423..3b624fb 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -620,7 +620,7 @@ pub use self::container::{
 pub use self::display::{
     TextLabel, Label, StyledLabel, LabelPrim, TextItem, UsageBar,
     InfoBox, StatusDot, InteractiveListItem,
-    GraphNode, Graph, TaggedQuad, Float3, ProgressBar, StatusBar, Splitter, Node, Separator,
+    GraphNode, Graph, TaggedQuad, node_wires, Float3, ProgressBar, StatusBar, Splitter, Node, Separator,
     DotStatus, Panel, ImageView, serialize_widgets,
     truncate_head, truncate_tail,
 };
@@ -678,6 +678,13 @@ pub trait GraphController {
     fn grid_origin(&self) -> (f32, f32);
     fn set_show_network_grid(&mut self, show: bool);
     fn take_pending_connection(&mut self) -> Option<(String, String)>;
+    /// [`Self::take_pending_connection`] with the INPUT PORT the connection
+    /// was dropped on: (input node id, output node name, port). A host whose
+    /// nodes read several wires (the k-th `node` parameter into port k)
+    /// writes the one the port is. Taking either takes the connection.
+    fn take_pending_connection_to_port(&mut self) -> Option<(String, String, usize)> {
+        self.take_pending_connection().map(|(id, name)| (id, name, 0))
+    }
     /// A node dropped onto a wire, to be spliced in between its ends:
     /// (dragged node id, the wire's upstream node NAME — what Input params
     /// store, the wire's downstream node id). The host rewires both Input