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

commit20e5df997b6d7683e886a1ec617cd68ae5eb231d
parent577b17e7e8
authorLucas Galante <[email protected]>
date2026-06-26 14:57
feat: render node port connectors as circles with hover highlights

 src/main.rs                      |  40 +++++++++++++
 src/widget/container/menu.rs     |   7 +++
 src/widget/container/switcher.rs |  12 ++++
 src/widget/display/graph.rs      | 120 ++++++++++++++++++++++++++++++++-------
 src/widget/mod.rs                |   1 +
 5 files changed, 161 insertions(+), 19 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 032a285..00011a4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -60,6 +60,40 @@ impl Vertex {
     }
 }
 
+fn circle_vertices(
+    cx: f32, cy: f32, r: f32,
+    sw: f32, sh: f32,
+    color: [f32; 4],
+    segments: usize,
+) -> Vec<Vertex> {
+    let mut verts = Vec::with_capacity(segments * 3);
+    for i in 0..segments {
+        let a1 = i as f32 * 2.0 * std::f32::consts::PI / segments as f32;
+        let a2 = (i + 1) as f32 * 2.0 * std::f32::consts::PI / segments as f32;
+        let x1 = cx + r * a1.cos();
+        let y1 = cy + r * a1.sin();
+        let x2 = cx + r * a2.cos();
+        let y2 = cy + r * a2.sin();
+
+        verts.push(Vertex {
+            position: [(cx / sw) * 2.0 - 1.0, 1.0 - (cy / sh) * 2.0],
+            color,
+            clip_circle: [0.0, 0.0, 0.0],
+        });
+        verts.push(Vertex {
+            position: [(x1 / sw) * 2.0 - 1.0, 1.0 - (y1 / sh) * 2.0],
+            color,
+            clip_circle: [0.0, 0.0, 0.0],
+        });
+        verts.push(Vertex {
+            position: [(x2 / sw) * 2.0 - 1.0, 1.0 - (y2 / sh) * 2.0],
+            color,
+            clip_circle: [0.0, 0.0, 0.0],
+        });
+    }
+    verts
+}
+
 fn quad_vertices(
     x: f32, y: f32, w: f32, h: f32,
     surface_w: f32, surface_h: f32,
@@ -652,6 +686,9 @@ impl State {
                 for (qx, qy, qw, qh, qc) in jl.extra_quads() {
                     verts.extend(extra_quad_vertices(jl, qx, qy, qw, qh, sw, sh, qc));
                 }
+                for (cx, cy, r, qc) in jl.extra_circles() {
+                    verts.extend(circle_vertices(cx, cy, r, sw, sh, qc, 16));
+                }
             }
         } else {
             let mut draw_order: Vec<usize> = (0..self.widgets.len()).collect();
@@ -662,6 +699,9 @@ impl State {
                 for (qx, qy, qw, qh, qc) in w.extra_quads() {
                     verts.extend(extra_quad_vertices(w.as_ref(), qx, qy, qw, qh, sw, sh, qc));
                 }
+                for (cx, cy, r, qc) in w.extra_circles() {
+                    verts.extend(circle_vertices(cx, cy, r, sw, sh, qc, 16));
+                }
             }
 
             // Draw popover quads on top
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index 325cfd9..46a282b 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -665,6 +665,13 @@ impl Element for MenuBar {
         self.menus.extra_arcs()
     }
 
+    fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
+        if !self.visible {
+            return Vec::new();
+        }
+        self.menus.extra_circles()
+    }
+
     fn prepare_text(&mut self, fs: &mut glyphon::FontSystem) {
         if !self.visible {
             return;
diff --git a/src/widget/container/switcher.rs b/src/widget/container/switcher.rs
index f541615..dc4645f 100644
--- a/src/widget/container/switcher.rs
+++ b/src/widget/container/switcher.rs
@@ -393,6 +393,18 @@ impl Element for Switcher {
         Vec::new()
     }
 
+    fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
+        if !self.visible {
+            return Vec::new();
+        }
+        if let Some(idx) = self.active_index {
+            if idx < self.children.len() {
+                return unsafe { (*self.children[idx]).extra_circles() };
+            }
+        }
+        Vec::new()
+    }
+
     fn as_menu_controller(&self) -> Option<&dyn MenuController> { Some(self) }
     fn as_menu_controller_mut(&mut self) -> Option<&mut dyn MenuController> { Some(self) }
 }
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index e2b9b77..cd1b220 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -65,6 +65,7 @@ pub struct Graph {
     connecting_from: Option<(usize, PortType, usize)>,
     current_mouse_pos: (f32, f32),
     pending_connection: Option<(String, String)>,
+    hovered_port: Option<(usize, PortType, usize)>,
 }
 
 impl Graph {
@@ -122,6 +123,7 @@ impl Graph {
             connecting_from: None,
             current_mouse_pos: (0.0, 0.0),
             pending_connection: None,
+            hovered_port: None,
         }
     }
 
@@ -354,6 +356,19 @@ impl Element for Graph {
         self.dragging_id = None;
     }
 
+    fn handle_event(&mut self, event: &crate::widget::Event, _ctx: &mut UiContext) -> bool {
+        match event {
+            crate::widget::Event::MouseLeave => {
+                let changed = self.hovered_port.is_some() || self.toggle_hovered_idx.is_some();
+                self.hovered_port = None;
+                self.toggle_hovered_idx = None;
+                return changed;
+            }
+            _ => {}
+        }
+        false
+    }
+
     fn on_cursor_moved(&mut self, px: f32, py: f32, _ctx: &mut UiContext) -> bool {
         let mut changed = false;
         if self.connecting_from.is_some() {
@@ -362,15 +377,41 @@ impl Element for Graph {
         }
         let was_toggle_hovered = self.toggle_hovered_idx;
         self.toggle_hovered_idx = None;
+        
+        let was_hovered_port = self.hovered_port;
+        self.hovered_port = None;
+
         for i in 0..self.nodes.len() {
+            if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
+                let scale_f = nw / 80.0;
+                let hit_radius = (6.0 * scale_f).max(2.0) * 1.5;
+                let node = &self.nodes[i];
+                
+                for k in 0..node.inputs {
+                    let cx = nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32;
+                    let cy = ny;
+                    if (px - cx).powi(2) + (py - cy).powi(2) <= hit_radius.powi(2) {
+                        self.hovered_port = Some((i, PortType::Input, k));
+                    }
+                }
+                
+                for k in 0..node.outputs {
+                    let cx = nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32;
+                    let cy = ny + nh;
+                    if (px - cx).powi(2) + (py - cy).powi(2) <= hit_radius.powi(2) {
+                        self.hovered_port = Some((i, PortType::Output, k));
+                    }
+                }
+            }
+
             if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
                 if px >= tx && px < tx + tw && py >= ty && py < ty + th {
                     self.toggle_hovered_idx = Some(i);
-                    break;
                 }
             }
         }
-        if was_toggle_hovered != self.toggle_hovered_idx {
+        
+        if was_toggle_hovered != self.toggle_hovered_idx || was_hovered_port != self.hovered_port {
             changed = true;
         }
         changed
@@ -761,23 +802,6 @@ impl Element for Graph {
                 };
                 push_clipped(nx, ny, nw, nh, bg_color, &mut quads);
 
-                // Draw input ports on top edge
-                let port_size = (6.0 * scale_f).max(2.0);
-                let port_color = [0.1, 0.8, 0.4, 1.0]; // Bright green/emerald
-                let node = &self.nodes[i];
-                for k in 0..node.inputs {
-                    let px = nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32 - port_size / 2.0;
-                    let py = ny - port_size / 2.0;
-                    push_clipped(px, py, port_size, port_size, port_color, &mut quads);
-                }
-
-                // Draw output ports on bottom edge
-                for k in 0..node.outputs {
-                    let px = nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32 - port_size / 2.0;
-                    let py = ny + nh - port_size / 2.0;
-                    push_clipped(px, py, port_size, port_size, port_color, &mut quads);
-                }
-
                 if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
                     let btn_color = if self.toggle_hovered_idx == Some(i) {
                         colors::TOGGLE_HOVER
@@ -797,6 +821,63 @@ impl Element for Graph {
         quads
     }
 
+    fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
+        let mut circles = Vec::new();
+        let min_x = self.x;
+        let min_y = self.y;
+        let max_x = self.x + self.w;
+        let max_y = self.y + self.h;
+
+        let mut push_circle_clipped = |cx: f32, cy: f32, r: f32, color: [f32; 4]| {
+            if cx >= min_x && cx <= max_x && cy >= min_y && cy <= max_y {
+                circles.push((cx, cy, r, color));
+            }
+        };
+
+        for i in 0..self.nodes.len() {
+            if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
+                let scale_f = nw / 80.0;
+                let port_size = (6.0 * scale_f).max(2.0);
+                let base_r = port_size / 2.0;
+
+                let node = &self.nodes[i];
+                
+                // Input ports (top edge)
+                for k in 0..node.inputs {
+                    let cx = nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32;
+                    let cy = ny;
+                    
+                    let is_hovered = self.hovered_port == Some((i, PortType::Input, k));
+                    let is_connecting = self.connecting_from == Some((i, PortType::Input, k));
+                    
+                    let (r, color) = if is_hovered || is_connecting {
+                        (base_r * 1.4, [0.0, 1.0, 0.9, 1.0]) // neon cyan glow
+                    } else {
+                        (base_r, [0.1, 0.8, 0.4, 1.0])
+                    };
+                    push_circle_clipped(cx, cy, r, color);
+                }
+
+                // Output ports (bottom edge)
+                for k in 0..node.outputs {
+                    let cx = nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32;
+                    let cy = ny + nh;
+                    
+                    let is_hovered = self.hovered_port == Some((i, PortType::Output, k));
+                    let is_connecting = self.connecting_from == Some((i, PortType::Output, k));
+                    
+                    let (r, color) = if is_hovered || is_connecting {
+                        (base_r * 1.4, [0.0, 1.0, 0.9, 1.0]) // neon cyan glow
+                    } else {
+                        (base_r, [0.1, 0.8, 0.4, 1.0])
+                    };
+                    push_circle_clipped(cx, cy, r, color);
+                }
+            }
+        }
+        circles
+    }
+
 
 
     fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
@@ -828,6 +909,7 @@ impl Drop for Graph {
 impl GraphController for Graph {
     fn set_nodes(&mut self, nodes: &[GraphNode]) {
         self.nodes = nodes.to_vec();
+        self.hovered_port = None;
         
         // Sync selected_idx from selected_id
         if let Some(ref id) = self.selected_id {
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index d26bf73..68e55ae 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -417,6 +417,7 @@ pub trait Element {
 
     fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> { Vec::new() }
     fn extra_arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> { Vec::new() }
+    fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> { Vec::new() }
     
     fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
         let mut quads = self.extra_quads();