git.lucas.co / cce-graph
node-based graph editor
git clone https://git.lucas.co/cce-graph.git

commit3a4ee54c5ea0a3f5f53578c5f537068886a9598a
parentc8c361ee03
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 13:14
fix: nodes get ids, and a wire drawn with the mouse is kept

Nodes loaded from a project or added from the menu had an empty id, and
nothing took the Graph widget's pending connection, so every wire drawn
between ports was dropped. ensure_ids gives each node a session id (wires
reference names, so ids are not saved); connect writes the taken wire into
the port's parameter: the port-th `node`-typed one, else the `input`
parameter for port 0, padding typed inputs up to the port.

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

 CLAUDE.md     |  14 +++----
 src/main.rs   |  17 +++++++-
 src/wiring.rs | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 147 insertions(+), 8 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e06a98d..7fb640b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,17 +7,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 `cce-graph` is a node-graph / mood-board editor client for the CCE Wayland desktop
 environment: a grid-aligned canvas of connected nodes plus free-floating images, saved
 as KDL project directories. It is one crate of the multi-repo `cce` workspace (its own
-git repo side-by-side with its siblings; `origin` is the local *bare* repo
-`~/git/cce-graph.git`, so **committing is not publishing — `git push origin main` is**,
-after which the gitsite system mirrors it to `https://git.lucas.co/cce-graph.git`, the
-old fetch-only static mirror now kept as the `published` remote). Read the workspace-level
+git repo side-by-side with its siblings; `origin` is GitHub and a post-commit hook
+pushes each commit, with git.lucas.co an hourly mirror). Read the workspace-level
 `../cce-compositor/WORKSPACE.md` first — workspace layout, the `cce-ui` toolkit, config
 conventions, and the multi-repo rules (each crate is its own git repo; commit here, not at
 the workspace root) all live there.
 
-The entire app is **one file, `src/main.rs`**: a `GraphApp` struct implementing
-`cce-ui`'s `Application` trait, run via `cce_ui::engine::run::<GraphApp>()`. There are
-no tests. The actual node-canvas widget (`Graph`, `GraphNode`) lives in `cce-ui`, not
+The project editor is `src/main.rs`: a `GraphApp` struct implementing
+`cce-ui`'s `Application` trait, run via `cce_ui::engine::run::<GraphApp>()`.
+`src/wiring.rs` gives nodes their session ids and turns a wire drawn with the
+mouse (the widget's pending connection) into the port's parameter — before it,
+nodes loaded with empty ids and drawn wires were dropped. The actual node-canvas widget (`Graph`, `GraphNode`) lives in `cce-ui`, not
 here — this crate is orchestration: menu bar + File/Edit/View dropdowns, the `Graph`
 widget, an image overlay, and a floating "control panel" showing the selected
 node/image.
diff --git a/src/main.rs b/src/main.rs
index e7f478e..1bb57fd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,7 @@
 use wayland_client::QueueHandle;
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+mod wiring;
+
 use cce_ui::widget::{Adapted, MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost, Event, Graph, GraphNode, MenuBar, GraphController, Dropdown, Label};
 use image::GenericImageView;
 
@@ -668,7 +670,9 @@ impl GraphApp {
             serde_json::from_str(&content)?
         };
 
-        self.graph.set_nodes(&state.nodes);
+        let mut nodes = state.nodes.clone();
+        wiring::ensure_ids(&mut nodes);
+        self.graph.set_nodes(&nodes);
         self.show_grid = state.show_grid;
         self.opacity = state.opacity;
 
@@ -1036,6 +1040,7 @@ impl Application for GraphApp {
                     inputs: 1,
                     outputs: 1,
                 });
+                wiring::ensure_ids(&mut nodes);
                 self.graph.set_nodes(&nodes);
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
@@ -1538,6 +1543,16 @@ impl Application for GraphApp {
                         if self.ui_context.propagate_event(&ev, g) {
                             self.selected_image_idx = None;
                             changed = true;
+                            // A press on a port can complete a wire drawn
+                            // with the mouse; write it into the node.
+                            if let Some((to, from, port)) =
+                                GraphController::take_pending_connection_to_port(&mut *self.graph)
+                            {
+                                let mut nodes = self.graph.get_nodes();
+                                if wiring::connect(&mut nodes, &to, &from, port) {
+                                    self.graph.set_nodes(&nodes);
+                                }
+                            }
                         } else if let Some(img_idx) = self.hit_test_image(pos.x, pos.y) {
                             let img = &self.loaded_images[img_idx];
                             let (grid_origin_x, grid_origin_y) = self.graph.grid_origin();
diff --git a/src/wiring.rs b/src/wiring.rs
new file mode 100644
index 0000000..57694f5
--- /dev/null
+++ b/src/wiring.rs
@@ -0,0 +1,124 @@
+//! Node identity and mouse-drawn wires for the project editor.
+//!
+//! The `Graph` widget reports a wire drawn with the mouse as a pending
+//! connection naming the downstream node by `id` and the upstream one by
+//! `name`, and draws wires from parameters (`cce_ui::widget::node_wires`):
+//! a node's `node`-typed parameters, one per input port in order, else its
+//! parameter named `input`. Two things were missing, so every drawn wire
+//! was dropped: nodes loaded and added with an empty id (the pending
+//! connection could not say which node), and nothing took the pending
+//! connection and wrote it into a parameter.
+
+use cce_ui::widget::GraphNode;
+
+/// Give every node without one a unique session id. Ids are not saved:
+/// wires reference names, so a fresh id per load is enough.
+pub fn ensure_ids(nodes: &mut [GraphNode]) {
+    let mut taken: std::collections::HashSet<String> =
+        nodes.iter().filter(|n| !n.id.is_empty()).map(|n| n.id.clone()).collect();
+    let mut next = 1usize;
+    for node in nodes.iter_mut().filter(|n| n.id.is_empty()) {
+        while taken.contains(&format!("n{next}")) {
+            next += 1;
+        }
+        node.id = format!("n{next}");
+        taken.insert(node.id.clone());
+    }
+}
+
+/// Wire `from` (a node name) into input `port` of the node with id `to`.
+/// Writes the value of that port's parameter — the port-th `node`-typed
+/// one, or the `input` parameter for port 0 of a node that has no typed
+/// ones — and adds the parameter when the node has none for that port.
+/// False when no node has that id, or the wire would loop onto itself.
+pub fn connect(nodes: &mut [GraphNode], to: &str, from: &str, port: usize) -> bool {
+    let Some(node) = nodes.iter_mut().find(|n| n.id == to) else { return false };
+    if node.name == from {
+        return false;
+    }
+    let is_typed = |p: &(String, String, String)| p.2 == "node";
+    let untyped_input = node.parameters.iter().position(|p| p.0.eq_ignore_ascii_case("input"));
+    if !node.parameters.iter().any(is_typed) {
+        if let Some(i) = untyped_input {
+            if port == 0 {
+                node.parameters[i].1 = from.to_string();
+                return true;
+            }
+            // A later port on a node whose only wire is an untyped
+            // `input`: type that one, so it stays port 0.
+            node.parameters[i].2 = "node".into();
+        }
+    }
+    let typed: Vec<usize> =
+        node.parameters.iter().enumerate().filter(|(_, p)| is_typed(p)).map(|(i, _)| i).collect();
+    if let Some(&i) = typed.get(port) {
+        node.parameters[i].1 = from.to_string();
+        return true;
+    }
+    // Typed parameters are read in order, so pad up to this port.
+    for k in typed.len()..=port {
+        let name = if k == 0 { "input".to_string() } else { format!("input{}", k + 1) };
+        let value = if k == port { from.to_string() } else { String::new() };
+        node.parameters.push((name, value, "node".into()));
+    }
+    true
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use cce_ui::widget::node_wires;
+
+    fn node(id: &str, name: &str, params: Vec<(&str, &str, &str)>) -> GraphNode {
+        GraphNode {
+            id: id.into(),
+            name: name.into(),
+            position: (0.0, 0.0),
+            parameters: params.into_iter().map(|(a, b, c)| (a.into(), b.into(), c.into())).collect(),
+            geom_visible: true,
+            node_type: String::new(),
+            inputs: 2,
+            outputs: 1,
+        }
+    }
+
+    #[test]
+    fn ids_are_unique_and_kept() {
+        let mut nodes = vec![node("", "a", vec![]), node("n1", "b", vec![]), node("", "c", vec![])];
+        ensure_ids(&mut nodes);
+        let ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
+        assert_eq!(ids, ["n2", "n1", "n3"]);
+    }
+
+    #[test]
+    fn a_drawn_wire_becomes_the_port_parameter() {
+        let mut nodes = vec![node("n1", "a", vec![]), node("n2", "b", vec![])];
+        assert!(connect(&mut nodes, "n2", "a", 0));
+        assert_eq!(node_wires(&nodes[1]), ["a"]);
+        // Port 1 pads nothing (port 0 exists) and adds the second input.
+        assert!(connect(&mut nodes, "n2", "a", 1));
+        assert_eq!(node_wires(&nodes[1]), ["a", "a"]);
+        // Rewiring port 0 replaces it.
+        nodes.push(node("n3", "c", vec![]));
+        assert!(connect(&mut nodes, "n2", "c", 0));
+        assert_eq!(node_wires(&nodes[1]), ["c", "a"]);
+        assert!(!connect(&mut nodes, "n9", "a", 0));
+        assert!(!connect(&mut nodes, "n1", "a", 0), "no self-loop");
+    }
+
+    #[test]
+    fn an_untyped_input_parameter_keeps_working() {
+        let mut nodes = vec![node("n1", "x", vec![("Input", "old", "string")])];
+        assert!(connect(&mut nodes, "n1", "y", 0));
+        assert_eq!(node_wires(&nodes[0]), ["y"]);
+        assert!(connect(&mut nodes, "n1", "z", 1));
+        assert_eq!(node_wires(&nodes[0]), ["y", "z"]);
+    }
+
+    #[test]
+    fn a_later_port_pads_the_ones_before_it() {
+        let mut nodes = vec![node("n1", "x", vec![])];
+        assert!(connect(&mut nodes, "n1", "y", 1));
+        assert_eq!(node_wires(&nodes[0]), ["", "y"]);
+    }
+}