node-based graph editor
git clone https://git.lucas.co/cce-graph.git
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"]);
+ }
+}