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

commit1063a74c3018fa603ae02250af64bcd4e3c6d492
parenta3dc7eab2a
authorLucas Galante <[email protected]>
date2026-07-08 12:38
feat(widget): migrate Node — Param + Geom controller capabilities (Phase 5k)

Second controller widget: both ParamController and GeomController ride
the Input capability hooks. The self-move drag (grid snap + bounds clamp)
ports to Input::drag_reposition; the geometry toggle sub-zone follows the
Spinbox PointerMove pattern; legacy focus()/unfocus() selection arrives
as FocusIn/FocusOut through the adapter's focus forwards. The node body
quad now comes from Paint::paint (legacy relied on a host reading
color(), plus extra_quads for the toggle).

No live constructors exist for Node in any app repo (verified), so
verification is compile + tests: router-level toggle/drag/controller
round-trip tests added; all 19 workspace client crates build.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LkfJazPs9bRchkcozmxXCX

 src/widget/display/node.rs | 353 +++++++++++++++++++++++++++++----------------
 1 file changed, 225 insertions(+), 128 deletions(-)

diff --git a/src/widget/display/node.rs b/src/widget/display/node.rs
index 59fcdb9..08676d2 100644
--- a/src/widget/display/node.rs
+++ b/src/widget/display/node.rs
@@ -1,12 +1,23 @@
+//! Narrow-trait `Node` (Phase 5k) — a network-editor node box: draggable with grid snap
+//! (self-moving, via [`Input::drag_reposition`]), a geometry-visibility toggle sub-zone, and
+//! two controller capabilities ([`ParamController`] + [`GeomController`]) re-exposed through
+//! the `Input` hooks for the legacy `Element::as_*_controller` downcasts.
+
 use crate::colors;
-use crate::widget::*;
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
+use crate::widget::{
+    Adapted, ElementState, Event, EventCtx, GeomController, Input, Layout, MouseButton, Paint,
+    ParamController,
+};
 
+#[derive(Debug, Clone)]
 pub struct Node {
-    x: f32, y: f32, w: f32, h: f32,
     hovered: bool,
     selected: bool,
     dragging: bool,
-    drag_ox: f32, drag_oy: f32,
+    drag_ox: f32,
+    drag_oy: f32,
     bounds: Option<(f32, f32, f32, f32)>,
     grid_snap_x: f32,
     grid_snap_y: f32,
@@ -20,186 +31,272 @@ pub struct Node {
 }
 
 impl Node {
-    pub fn new(x: f32, y: f32, w: f32, h: f32, name: &str) -> Self {
-        Self {
-            x, y, w, h,
-            hovered: false, selected: false, dragging: false, drag_ox: 0.0, drag_oy: 0.0,
-            bounds: None, grid_snap_x: 0.0, grid_snap_y: 0.0,
-            grid_origin_x: 0.0, grid_origin_y: 0.0,
-            name: name.to_string(), parameters: Vec::new(),
-            geom_visible: true, geom_toggled: false, toggle_hovered: false,
-        }
+    pub fn new(x: f32, y: f32, w: f32, h: f32, name: &str) -> Adapted<Node> {
+        let mut node = Adapted::new(Node {
+            hovered: false,
+            selected: false,
+            dragging: false,
+            drag_ox: 0.0,
+            drag_oy: 0.0,
+            bounds: None,
+            grid_snap_x: 0.0,
+            grid_snap_y: 0.0,
+            grid_origin_x: 0.0,
+            grid_origin_y: 0.0,
+            name: name.to_string(),
+            parameters: Vec::new(),
+            geom_visible: true,
+            geom_toggled: false,
+            toggle_hovered: false,
+        });
+        crate::widget::Element::set_rect(&mut node, x, y, w, h);
+        node
     }
 
-    pub fn with_params(mut self, params: &[(&str, &str)]) -> Self {
-        self.parameters = params.iter().map(|(k, v)| (k.to_string(), v.to_string(), "string".to_string())).collect();
-        self
+    pub(crate) fn toggle_rect(rect: Rect) -> (f32, f32, f32, f32) {
+        (rect.x + rect.width - 30.0, rect.y + (rect.height - 18.0) / 2.0, 18.0, 18.0)
     }
 
-    pub fn with_grid_snap(mut self, gx: f32, gy: f32) -> Self {
+    fn in_toggle(rect: Rect, px: f32, py: f32) -> bool {
+        let (tx, ty, tw, th) = Self::toggle_rect(rect);
+        px >= tx && px < tx + tw && py >= ty && py < ty + th
+    }
+
+    pub fn set_grid_snap(&mut self, gx: f32, gy: f32) {
         self.grid_snap_x = gx;
         self.grid_snap_y = gy;
-        self
     }
-
-    pub(crate) fn toggle_rect(&self) -> (f32, f32, f32, f32) {
-        (self.x + self.w - 30.0, self.y + (self.h - 18.0) / 2.0, 18.0, 18.0)
+    pub fn set_grid_origin(&mut self, ox: f32, oy: f32) {
+        self.grid_origin_x = ox;
+        self.grid_origin_y = oy;
+    }
+    pub fn set_node_name(&mut self, name: &str) {
+        self.name = name.to_string();
     }
-
-    pub fn set_grid_snap(&mut self, gx: f32, gy: f32) { self.grid_snap_x = gx; self.grid_snap_y = gy; }
-    pub fn set_grid_origin(&mut self, ox: f32, oy: f32) { self.grid_origin_x = ox; self.grid_origin_y = oy; }
-    pub fn set_node_name(&mut self, name: &str) { self.name = name.to_string(); }
 }
 
-impl Element for Node {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] {
-        if self.dragging { colors::node_drag_color() }
-        else if self.selected { colors::node_selected_color() }
-        else { colors::node_color() }
-    }
-    fn as_ptr(&self) -> *mut (dyn Element + 'static) {
-        self as *const Self as *mut Self as *mut (dyn Element + 'static)
-    }
-    fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
-        self as *mut Self as *mut (dyn Element + 'static)
+impl Adapted<Node> {
+    pub fn with_params(mut self, params: &[(&str, &str)]) -> Self {
+        self.parameters =
+            params.iter().map(|(k, v)| (k.to_string(), v.to_string(), "string".to_string())).collect();
+        self
     }
-    fn set_hovered(&mut self, v: bool) { self.hovered = v; }
-    fn hovered(&self) -> bool { self.hovered }
 
-    fn focus(&mut self) {
-        self.selected = true;
-        focus::set_focused(self);
+    pub fn with_grid_snap(mut self, gx: f32, gy: f32) -> Self {
+        self.set_grid_snap(gx, gy);
+        self
     }
-    fn unfocus(&mut self) { self.selected = false; }
-
-    fn as_param_controller(&self) -> Option<&dyn ParamController> { Some(self) }
-    fn as_param_controller_mut(&mut self) -> Option<&mut dyn ParamController> { Some(self) }
-    fn as_geom_controller(&self) -> Option<&dyn GeomController> { Some(self) }
-    fn as_geom_controller_mut(&mut self) -> Option<&mut dyn GeomController> { Some(self) }
+}
 
-    fn text_labels(&self) -> Vec<TextLabel> {
-        vec![TextLabel {
-            text: self.name.clone(),
-            x: self.x + self.w + 8.0,
-            y: crate::layout::align_text_y(self.y, self.h, 14.0, 0.0),
-            font_size: 14.0,
-            color: [0xcc, 0xcc, 0xd4],
-        }]
-    }
+impl Layout for Node {}
 
-    fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
-        self.bounds = Some((bx, by, bw, bh));
+impl Paint for Node {
+    fn color(&self) -> [f32; 4] {
+        if self.dragging {
+            colors::node_drag_color()
+        } else if self.selected {
+            colors::node_selected_color()
+        } else {
+            colors::node_color()
+        }
     }
 
-    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        let was_hovered = self.hovered;
-        self.hovered = self.hit_test(px, py, ctx);
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        ctx.quad(rect, Paint::color(self));
 
-        let was_toggle_hovered = self.toggle_hovered;
-        let (tx, ty, tw, th) = self.toggle_rect();
-        self.toggle_hovered = px >= tx && px < tx + tw && py >= ty && py < ty + th;
+        let (tx, ty, tw, th) = Self::toggle_rect(rect);
+        let bg_color = if self.toggle_hovered { colors::TOGGLE_HOVER } else { colors::TOGGLE_OFF };
+        ctx.quad(Rect { x: tx, y: ty, width: tw, height: th }, bg_color);
+        if self.geom_visible {
+            let inset = 3.0;
+            ctx.quad(
+                Rect { x: tx + inset, y: ty + inset, width: tw - inset * 2.0, height: th - inset * 2.0 },
+                colors::TOGGLE_ON,
+            );
+        }
 
-        was_hovered != self.hovered || was_toggle_hovered != self.toggle_hovered
+        ctx.text(
+            self.name.clone(),
+            rect.x + rect.width + 8.0,
+            crate::layout::align_text_y(rect.y, rect.height, 14.0, 0.0),
+            14.0,
+            [0xcc, 0xcc, 0xd4],
+        );
     }
+}
 
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        if button != MouseButton::Left { return false; }
-        match state {
-            ElementState::Pressed => {
-                if self.hit_test(px, py, ctx) {
-                    let (tx, ty, tw, th) = self.toggle_rect();
-                    if px >= tx && px < tx + tw && py >= ty && py < ty + th {
-                        self.geom_visible = !self.geom_visible;
-                        self.geom_toggled = true;
-                        return true;
-                    }
-                    self.drag_begin(px, py);
-                    return true;
+impl Input for Node {
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::PointerMove { x: px, y: py, .. } => {
+                let r = ectx.rect;
+                let was_hovered = self.hovered;
+                self.hovered =
+                    *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
+                let was_toggle = self.toggle_hovered;
+                self.toggle_hovered = Self::in_toggle(r, *px, *py);
+                was_hovered != self.hovered || was_toggle != self.toggle_hovered
+            }
+            Event::MouseButton {
+                button: MouseButton::Left,
+                state: ElementState::Pressed,
+                x: px,
+                y: py,
+                ..
+            } => {
+                if Self::in_toggle(ectx.rect, *px, *py) {
+                    self.geom_visible = !self.geom_visible;
+                    self.geom_toggled = true;
+                } else {
+                    self.drag_begin(*px, *py, ectx.rect);
                 }
+                true
             }
-            ElementState::Released => {
-                if self.dragging { self.drag_end(); return true; }
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
+                if self.dragging {
+                    self.drag_end();
+                    true
+                } else {
+                    false
+                }
+            }
+            // Legacy `focus()`/`unfocus()` toggled selection; hosts reach them through the
+            // adapter's focus forwards, which arrive here as focus events.
+            Event::FocusIn => {
+                self.selected = true;
+                true
             }
+            Event::FocusOut => {
+                self.selected = false;
+                true
+            }
+            _ => false,
         }
-        false
     }
 
-    fn is_dragging(&self) -> bool { self.dragging }
-    fn draggable(&self) -> bool { !self.toggle_hovered }
+    fn draggable(&self) -> bool {
+        !self.toggle_hovered
+    }
+    fn is_dragging(&self) -> bool {
+        self.dragging
+    }
 
-    fn drag_update(&mut self, px: f32, py: f32) -> bool {
+    fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
+        self.dragging = true;
+        self.drag_ox = px - rect.x;
+        self.drag_oy = py - rect.y;
+    }
+
+    fn drag_reposition(&mut self, px: f32, py: f32, rect: Rect) -> Option<(f32, f32)> {
         let nx = px - self.drag_ox;
         let ny = py - self.drag_oy;
         let (nx, ny) = if let Some((bx, by, bw, bh)) = self.bounds {
-            (nx.clamp(bx, bx + bw - self.w), ny.clamp(by, by + bh - self.h))
+            (nx.clamp(bx, bx + bw - rect.width), ny.clamp(by, by + bh - rect.height))
         } else {
             (nx, ny)
         };
         let nx = if self.grid_snap_x > 0.0 {
             let relative = nx - self.grid_origin_x;
-            let snapped = (relative / self.grid_snap_x).round() * self.grid_snap_x;
-            snapped + self.grid_origin_x
-        } else { nx };
+            (relative / self.grid_snap_x).round() * self.grid_snap_x + self.grid_origin_x
+        } else {
+            nx
+        };
         let ny = if self.grid_snap_y > 0.0 {
             let relative = ny - self.grid_origin_y;
-            let snapped = (relative / self.grid_snap_y).round() * self.grid_snap_y;
-            snapped + self.grid_origin_y
-        } else { ny };
-        if (nx - self.x).abs() > 0.01 || (ny - self.y).abs() > 0.01 {
-            self.x = nx;
-            self.y = ny;
-            return true;
+            (relative / self.grid_snap_y).round() * self.grid_snap_y + self.grid_origin_y
+        } else {
+            ny
+        };
+        if (nx - rect.x).abs() > 0.01 || (ny - rect.y).abs() > 0.01 {
+            Some((nx, ny))
+        } else {
+            None
         }
-        false
     }
 
-    fn drag_begin(&mut self, px: f32, py: f32) {
-        self.dragging = true;
-        self.drag_ox = px - self.x;
-        self.drag_oy = py - self.y;
+    fn drag_end(&mut self) {
+        self.dragging = false;
     }
 
-    fn drag_end(&mut self) { self.dragging = false; }
-
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let (tx, ty, tw, th) = self.toggle_rect();
-        let bg_color = if self.toggle_hovered {
-            colors::TOGGLE_HOVER
-        } else {
-            colors::TOGGLE_OFF
-        };
-
-        let mut quads = vec![
-            (tx, ty, tw, th, bg_color)
-        ];
-
-        if self.geom_visible {
-            let inset = 3.0;
-            quads.push((tx + inset, ty + inset, tw - inset * 2.0, th - inset * 2.0, colors::TOGGLE_ON));
-        }
-
-        quads
+    fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
+        self.bounds = Some((bx, by, bw, bh));
     }
 
+    fn param_controller(&self) -> Option<&dyn ParamController> {
+        Some(self)
+    }
+    fn param_controller_mut(&mut self) -> Option<&mut dyn ParamController> {
+        Some(self)
+    }
+    fn geom_controller(&self) -> Option<&dyn GeomController> {
+        Some(self)
+    }
+    fn geom_controller_mut(&mut self) -> Option<&mut dyn GeomController> {
+        Some(self)
+    }
 }
 
 impl ParamController for Node {
-    fn node_params(&self) -> Vec<(String, String, String)> { self.parameters.clone() }
+    fn node_params(&self) -> Vec<(String, String, String)> {
+        self.parameters.clone()
+    }
     fn set_display_params(&mut self, params: &[(String, String, String)]) {
         self.parameters = params.to_vec();
     }
 }
 
 impl GeomController for Node {
-    fn set_geom_visible(&mut self, visible: bool) { self.geom_visible = visible; }
-    fn geom_visible(&self) -> bool { self.geom_visible }
-    fn take_geom_toggle(&mut self) -> bool { std::mem::take(&mut self.geom_toggled) }
+    fn set_geom_visible(&mut self, visible: bool) {
+        self.geom_visible = visible;
+    }
+    fn geom_visible(&self) -> bool {
+        self.geom_visible
+    }
+    fn take_geom_toggle(&mut self) -> bool {
+        std::mem::take(&mut self.geom_toggled)
+    }
 }
 
-impl Drop for Node {
-    fn drop(&mut self) {
-        clear_widget_references(self);
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::context::UiContext;
+    use crate::widget::Element;
+
+    #[test]
+    fn toggle_click_flips_geom_and_press_starts_drag() {
+        let mut ctx = UiContext::new();
+        let mut node = Node::new(100.0, 100.0, 120.0, 40.0, "geo1");
+        let (id, ptr) = (node.id(), node.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+
+        // Toggle zone: (100+120-30, 100+11) => 18x18 at (190, 111).
+        assert!(node.mouse_input(MouseButton::Left, ElementState::Pressed, 195.0, 115.0, &mut ctx));
+        let elem: &mut dyn Element = &mut node;
+        let geom = elem.as_geom_controller_mut().expect("Node exposes GeomController");
+        assert!(!geom.geom_visible(), "toggle click hides geometry");
+        assert!(geom.take_geom_toggle(), "toggle flag set once");
+        assert!(!geom.take_geom_toggle(), "…and drained");
+
+        // A press outside the toggle starts a drag; reposition snaps to the drag origin.
+        assert!(node.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 110.0, &mut ctx));
+        assert!(Element::is_dragging(&node));
+        assert!(Element::drag_update(&mut node, 150.0, 130.0));
+        assert_eq!(Element::rect(&node), (140.0, 120.0, 120.0, 40.0), "moved by the pointer delta");
+        assert!(node.mouse_input(MouseButton::Left, ElementState::Released, 150.0, 130.0, &mut ctx));
+        assert!(!Element::is_dragging(&node));
+    }
+
+    #[test]
+    fn param_controller_roundtrips_through_element() {
+        let mut node = Node::new(0.0, 0.0, 10.0, 10.0, "n").with_params(&[("k", "v")]);
+        let elem: &mut dyn Element = &mut node;
+        let params = elem.as_param_controller().expect("Node exposes ParamController").node_params();
+        assert_eq!(params, vec![("k".to_string(), "v".to_string(), "string".to_string())]);
+        elem.as_param_controller_mut()
+            .unwrap()
+            .set_display_params(&[("a".to_string(), "b".to_string(), "int".to_string())]);
+        assert_eq!(node.parameters.len(), 1);
+        assert_eq!(node.parameters[0].2, "int");
     }
 }