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

commit6babe3292090bbc57b355a1629351ce5a0cf4af5
parent840bc4e9c0
authorLucas Galante <[email protected]>
date2026-08-30 10:44
feat: dropping a node on a wire splices it in

Dragging a node onto an existing wire connects it in between the
wire's ends: drag_update tracks the wire under the ghost (held by id —
hosts re-sync nodes every event, the hovered_port lesson) and draws it
in the highlight color as the drop affordance; commit_drag resolves it
into a pending splice (dragged id, upstream NAME, downstream id) that
hosts drain via GraphController::take_pending_splice (default None) and
rewire both Input params from. The wire derivation is shared — one
wire_pairs/wire_endpoints source feeds the draw pass, the segment rects,
and the hit test, so they cannot disagree about where a wire is. A node
with no Input parameter or no output port cannot splice, and a node's
own wires never count (that drop is a move, not a rewire).

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/widget/display/graph.rs | 213 +++++++++++++++++++++++++++++++++++++++-----
 src/widget/mod.rs           |   8 ++
 2 files changed, 200 insertions(+), 21 deletions(-)

diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 52969f8..3ae0152 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -98,6 +98,18 @@ pub struct Graph {
     current_mouse_pos: (f32, f32),
     pending_connection: Option<(String, String)>,
     hovered_port: Option<(usize, PortType, usize)>,
+
+    /// The wire the in-flight node drag would splice into, as (src node id,
+    /// dest node id) — ids, not indices, because hosts re-sync nodes on
+    /// every window event and an index would go stale between drag_update
+    /// and the release (the hovered_port lesson). Drawn highlighted while it
+    /// holds; resolved into `pending_splice` on drop.
+    splice_target: Option<(String, String)>,
+    /// A completed splice drop for the host: (dragged node id, the wire's
+    /// upstream node NAME — what Input params store, the wire's downstream
+    /// node id). The host rewires: dragged.Input = upstream name,
+    /// downstream.Input = dragged's name.
+    pending_splice: Option<(String, String, String)>,
 }
 
 impl Graph {
@@ -141,6 +153,8 @@ impl Graph {
             current_mouse_pos: (0.0, 0.0),
             pending_connection: None,
             hovered_port: None,
+            splice_target: None,
+            pending_splice: None,
         })
     }
 
@@ -400,27 +414,23 @@ impl Graph {
             push_clipped(end_x - wire_thickness / 2.0, v2_min_y, wire_thickness, v2_max_y - v2_min_y, color, q);
         };
 
-        // Connection wires: each node with an "input" parameter draws a wire from that source
-        // node's first output port to its own first input port.
+        // Connection wires: each node with an "input" parameter draws a wire
+        // from that source node's first output port to its own first input
+        // port (pairs and endpoints from the shared helpers the splice hit
+        // test also reads). The wire an in-flight node drag would splice
+        // into draws in the highlight color — the drop affordance.
         let wire_color = [0.0, 0.75, 1.0, 0.7 * self.node_opacity]; // Vibrant cyan glow
-        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) {
-                    if let (Some((sx, sy, sw, sh)), Some((ex, ey, ew, _eh))) = (self.node_rect(src_idx), self.node_rect(i)) {
-                        // Wires attach at the port circles' centers (which
-                        // float outside the node boxes); portless nodes fall
-                        // back to the edge midpoint.
-                        let (start_x, start_y) = self
-                            .port_center(src_idx, PortType::Output, 0)
-                            .unwrap_or((sx + sw / 2.0, sy + sh));
-                        let (end_x, end_y) = self
-                            .port_center(i, PortType::Input, 0)
-                            .unwrap_or((ex + ew / 2.0, ey));
-
-                        push_wire(start_x, start_y, end_x, end_y, wire_color, &mut quads);
-                    }
-                }
+        let hl = crate::color::graph_wire_highlight_color();
+        let splice_color = [hl[0], hl[1], hl[2], hl[3] * self.node_opacity];
+        for (src_idx, i) in self.wire_pairs() {
+            if let Some(((start_x, start_y), (end_x, end_y))) = self.wire_endpoints(src_idx, i) {
+                let is_splice_target = self
+                    .splice_target
+                    .as_ref()
+                    .map(|(s, d)| self.nodes[src_idx].id == *s && self.nodes[i].id == *d)
+                    .unwrap_or(false);
+                let color = if is_splice_target { splice_color } else { wire_color };
+                push_wire(start_x, start_y, end_x, end_y, color, &mut quads);
             }
         }
 
@@ -929,6 +939,13 @@ impl Input for Graph {
             };
 
             self.drag_node_pos = Some((nx, ny));
+            // The wire the ghost sits on right now, held by id (hosts
+            // re-sync between events) and drawn highlighted — the drop
+            // affordance the user aims by.
+            self.splice_target = self
+                .dragging_idx
+                .and_then(|i| self.splice_wire_at(i, nx, ny))
+                .map(|(s, d)| (self.nodes[s].id.clone(), self.nodes[d].id.clone()));
             return true;
         }
         false
@@ -1055,14 +1072,105 @@ impl Graph {
         false
     }
 
-    /// Drop the in-flight node drag onto the nearest free grid cell (legacy `drag_end`).
+    /// 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)> {
+        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));
+                }
+            }
+        }
+        out
+    }
+
+    /// A wire's two attachment points — the port circles' centers, falling
+    /// 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))> {
+        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));
+        let end = self
+            .port_center(dest_idx, PortType::Input, 0)
+            .unwrap_or((ex + ew / 2.0, ey));
+        Some((start, end))
+    }
+
+    /// The wire's three segments as axis-aligned rects at the drawn
+    /// thickness — `push_wire`'s exact shape, unclipped.
+    fn wire_segment_rects(&self, src_idx: usize, dest_idx: usize) -> Option<[(f32, f32, f32, f32); 3]> {
+        let ((start_x, start_y), (end_x, end_y)) = self.wire_endpoints(src_idx, dest_idx)?;
+        let scale_f = self.grid_size_x / 80.0;
+        let t = (3.0 * scale_f).clamp(1.0, 15.0);
+        let mid_y = start_y + (end_y - start_y) / 2.0;
+        let v1 = (start_x - t / 2.0, start_y.min(mid_y), t, (start_y - mid_y).abs());
+        let h = (start_x.min(end_x), mid_y - t / 2.0, (end_x - start_x).abs(), t);
+        let v2 = (end_x - t / 2.0, mid_y.min(end_y), t, (end_y - mid_y).abs());
+        Some([v1, h, v2])
+    }
+
+    /// The wire the dragged node's ghost at (nx, ny) would splice into —
+    /// the first pair (draw order) whose segment run touches the ghost rect,
+    /// 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.
+    fn splice_wire_at(&self, idx: usize, nx: f32, ny: f32) -> Option<(usize, usize)> {
+        let node = self.nodes.get(idx)?;
+        let has_input = node
+            .parameters
+            .iter()
+            .any(|(name, _, _)| name.eq_ignore_ascii_case("input"));
+        if !has_input || node.outputs == 0 {
+            return None;
+        }
+        let (_, _, nw, nh) = self.node_rect(idx)?;
+        let pad = crate::layout::graph_wire_activation_radius().max(0.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 {
+                continue;
+            }
+            let Some(segs) = self.wire_segment_rects(src, dest) else { continue };
+            let hit = segs.iter().any(|&(x, y, w, h)| {
+                x < gx2 && x + w > gx1 && y < gy2 && y + h > gy1
+            });
+            if hit {
+                return Some((src, dest));
+            }
+        }
+        None
+    }
+
+    /// Drop the in-flight node drag onto the nearest free grid cell (legacy `drag_end`),
+    /// resolving a held splice target into `pending_splice` for the host.
     fn commit_drag(&mut self) {
+        let target = self.splice_target.take();
         if let Some((nx, ny)) = self.drag_node_pos.take() {
             let c = ((nx - self.grid_origin_x) / (self.grid_size_x + self.skipped_col_w)).round();
             let r = ((ny - self.grid_origin_y) / (self.grid_size_y + self.skipped_row_h)).round();
             if let Some(idx) = self.dragging_idx.take() {
                 let (nx, ny) = self.find_empty_cell(c, r, Some(idx));
                 self.nodes[idx].position = (nx, ny);
+                if let Some((src_id, dest_id)) = target {
+                    let src_name = self.nodes.iter().find(|n| n.id == src_id).map(|n| n.name.clone());
+                    let dest_ok = self.nodes.iter().any(|n| n.id == dest_id);
+                    if let (Some(src_name), true) = (src_name, dest_ok) {
+                        self.pending_splice = Some((self.nodes[idx].id.clone(), src_name, dest_id));
+                    }
+                }
             }
         } else {
             self.dragging_idx = None;
@@ -1105,10 +1213,12 @@ impl GraphController for Graph {
             if self.dragging_idx.is_none() {
                 self.dragging_id = None;
                 self.drag_node_pos = None;
+                self.splice_target = None;
             }
         } else {
             self.dragging_idx = None;
             self.drag_node_pos = None;
+            self.splice_target = None;
         }
 
         // double_clicked_id / double_click_timer survive deliberately: they
@@ -1143,6 +1253,9 @@ impl GraphController for Graph {
     fn take_pending_connection(&mut self) -> Option<(String, String)> {
         self.pending_connection.take()
     }
+    fn take_pending_splice(&mut self) -> Option<(String, String, String)> {
+        self.pending_splice.take()
+    }
     fn cancel_connecting(&mut self) {
         self.connecting_from = None;
     }
@@ -1245,6 +1358,64 @@ mod tests {
         assert_eq!(g.selected_node(), None);
     }
 
+    /// Dropping a dragged node onto a wire splices it in: the drop reports
+    /// (dragged id, the wire's upstream NAME, the wire's downstream id) for
+    /// the host to rewire both Input params. A drop away from every wire
+    /// reports nothing, and the handshake is take-once.
+    #[test]
+    fn node_dropped_on_a_wire_reports_a_splice() {
+        let mut ctx = UiContext::new();
+        let mut g = Graph::new();
+        WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
+        g.set_grid_sizes(80.0, 40.0);
+        g.set_skipped_sizes(20.0, 20.0);
+        g.set_grid_origin(100.0, 100.0);
+        g.set_grid_snap_enabled(true);
+        let node = |id: &str, name: &str, col: f32, row: f32, params: Vec<(String, String, String)>| GraphNode {
+            id: id.into(),
+            name: name.into(),
+            position: (col, row),
+            parameters: params,
+            geom_visible: true,
+            node_type: String::new(),
+            inputs: 1,
+            outputs: 1,
+        };
+        let p = |v: &str| vec![("Input".to_string(), v.to_string(), "text".to_string())];
+        // alpha → beta wire runs through the empty cell (1, 0) between them;
+        // gamma sits below, unwired.
+        g.set_nodes(&[
+            node("a", "alpha", 0.0, 0.0, Vec::new()),
+            node("b", "beta", 2.0, 0.0, p("alpha")),
+            node("c", "gamma", 0.0, 2.0, p("")),
+        ]);
+        let (id, ptr) = (g.id(), g.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+
+        // Drag gamma's body onto the wire's horizontal run (cell (1, 0)).
+        assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 240.0, &mut ctx));
+        g.drag_begin(110.0, 240.0);
+        assert!(g.drag_update(210.0, 140.0));
+        assert!(g.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 140.0, &mut ctx));
+
+        let splice = GraphController::take_pending_splice(&mut *g);
+        assert_eq!(
+            splice,
+            Some(("c".to_string(), "alpha".to_string(), "b".to_string())),
+            "drop on the wire must report (dragged, upstream name, downstream id)"
+        );
+        assert_eq!(GraphController::take_pending_splice(&mut *g), None, "take-once");
+
+        // A drop in open space reports nothing. Gamma landed in cell (1, 0)
+        // — the free cell its splice drop resolved to — so drag it from
+        // there down to open space clear of the wire.
+        assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 210.0, 110.0, &mut ctx));
+        g.drag_begin(210.0, 110.0);
+        assert!(g.drag_update(210.0, 230.0));
+        assert!(g.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 230.0, &mut ctx));
+        assert_eq!(GraphController::take_pending_splice(&mut *g), None);
+    }
+
     #[test]
     fn port_click_starts_and_completes_a_connection() {
         let mut ctx = UiContext::new();
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 9387562..32c4178 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -602,6 +602,14 @@ 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)>;
+    /// 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
+    /// params: dragged.Input = upstream name, downstream.Input = dragged's
+    /// name. Default None for hosts whose graphs have no wires to splice.
+    fn take_pending_splice(&mut self) -> Option<(String, String, String)> {
+        None
+    }
     fn cancel_connecting(&mut self);
     fn is_node_rect(&self, qx: f32, qy: f32, qw: f32, qh: f32) -> bool;
     /// The topmost node whose body contains (px, py), window-absolute coords.