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

src/widget/display/graph.rs (70.9K)

   1 //! Narrow-trait `Graph` (Phase 5m) — the node-network editor: a pannable/zoomable grid of
   2 //! draggable nodes with geometry toggles, input/output ports, wire routing, and interactive
   3 //! connection dragging. [`GraphController`] rides the `Input` capability hooks.
   4 //!
   5 //! Rendering serves the legacy dual-geometry contract through the adapter's escape hatch:
   6 //! [`Paint::paint`] emits the ROUNDED view (what `render_widget` hosts — cce-files — and the
   7 //! scene walk — cce-graph — consume), while [`Paint::legacy_plain_quads`] serves the same
   8 //! geometry as plain quads for raw `extra_quads` readers (the designer's render path), with
   9 //! `all_quads` emptied by the adapter so no host draws it twice. Node-name text is clipped to
  10 //! the widget rect via [`Paint::text_bounds`]. All grid geometry is in absolute screen space
  11 //! (hosts pan by moving `grid_origin`); the widget rect only culls and clips.
  12 //!
  13 //! The grid is a LATTICE OF LINES with one size per axis — the pitch, from the centre of
  14 //! one line to the centre of the next — and a node is centred on the intersection its
  15 //! `position` names: node (c, r) sits on `grid_origin + (c * pitch_x, r * pitch_y)`. The
  16 //! node body has a size of its own (`graph_node_width` / `graph_node_height`, scaled with
  17 //! the zoom), independent of the pitch. It used to be a grid of CELLS — a cell size that was also the node size, plus a gap between cells,
  18 //! with a node filling its cell — and the cell-and-gap setters survive as a description of
  19 //! the same lattice for hosts that still speak it (a cell plus its gap is a pitch).
  20 
  21 use crate::colors;
  22 use crate::scene::layout::Rect;
  23 use crate::scene::paint::PaintCtx;
  24 use crate::widget::display::TextLabel;
  25 use crate::widget::{
  26     Adapted, ElementState, Event, EventCtx, GraphController, Input, Key, Layout, MouseButton,
  27     MouseScrollDelta, Paint,
  28 };
  29 
  30 #[derive(Clone, Copy, Debug, PartialEq)]
  31 pub enum PortType {
  32     Input,
  33     Output,
  34 }
  35 
  36 fn default_outputs() -> usize { 1 }
  37 
  38 /// One flat-geometry quad `(x, y, w, h, color, cell)` from
  39 /// [`Graph::geometry_quads_tagged`]: `cell` is `Some(corner flags)` for a grid
  40 /// cell — per-corner `(tl, tr, br, bl)` rounding that survived the pane clip —
  41 /// and `None` for everything else (wires, gaps, axes, nodes, toggles).
  42 pub type TaggedQuad = (f32, f32, f32, f32, [f32; 4], Option<(bool, bool, bool, bool)>);
  43 
  44 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
  45 pub struct GraphNode {
  46     #[serde(default)]
  47     pub id: String,
  48     pub name: String,
  49     pub position: (f32, f32), // (column, row)
  50     pub parameters: Vec<(String, String, String)>, // (name, value, type)
  51     pub geom_visible: bool,
  52     #[serde(default)]
  53     pub node_type: String,
  54     #[serde(default)]
  55     pub inputs: usize,
  56     #[serde(default = "default_outputs")]
  57     pub outputs: usize,
  58 }
  59 
  60 /// The widget's own corner style: the legacy `WidgetHost` defaults it inherited
  61 /// (`corner_radius` 12.0, bottom corners rounded).
  62 const WIDGET_RADIUS: f32 = 12.0;
  63 const WIDGET_CORNERS: (bool, bool, bool, bool) = (false, false, true, true);
  64 
  65 pub struct Graph {
  66     show_network_grid: bool,
  67     /// The pitch: centre of one grid line to the centre of the next, per
  68     /// axis. The grid's one size.
  69     pitch_x: f32,
  70     pitch_y: f32,
  71     /// The node body's size — its own (`graph_node_width` / `_height` at
  72     /// 100%), independent of the pitch; the cell-model setters set it too.
  73     node_w: f32,
  74     node_h: f32,
  75     /// The lattice intersection node (0, 0) is centred on, window-absolute.
  76     grid_origin_x: f32,
  77     grid_origin_y: f32,
  78     /// Smooth-scroll driver behind the pan origin: notches glide, a trackpad
  79     /// flick coasts across the unbounded canvas.
  80     pan_motion: crate::widget::ScrollMotion,
  81     nodes: Vec<GraphNode>,
  82     selected_idx: Option<usize>,
  83     selected_id: Option<String>,
  84     double_clicked_id: Option<String>,
  85     /// Keyed by node ID, not index: hosts (the designer) re-sync nodes on
  86     /// EVERY window event, and set_nodes used to wipe this state wholesale —
  87     /// the first press's timer never survived to the second press, so
  88     /// double-click detection could not fire at all. Same id-keyed survival
  89     /// as `selected_id` and the hovered-port remap.
  90     double_click_timer: Option<(std::time::Instant, String)>,
  91     grid_snap_enabled: bool,
  92     node_geom_toggled: Option<(usize, bool)>,
  93 
  94     // For dragging a node
  95     dragging_idx: Option<usize>,
  96     dragging_id: Option<String>,
  97     drag_ox: f32,
  98     drag_oy: f32,
  99     pub(crate) drag_node_pos: Option<(f32, f32)>,
 100 
 101     // Hover tracking
 102     toggle_hovered_idx: Option<usize>,
 103 
 104     uniform_background: bool,
 105     network_opacity: f32,
 106     /// Node-domain opacity (bodies, wires, connectors) — independent of
 107     /// `network_opacity`, which fades the pane surface (grid cells/gaps).
 108     node_opacity: f32,
 109     cell_color: [f32; 3],
 110     gap_color: [f32; 3],
 111 
 112     // Connection state
 113     connecting_from: Option<(usize, PortType, usize)>,
 114     current_mouse_pos: (f32, f32),
 115     pending_connection: Option<(String, String)>,
 116     hovered_port: Option<(usize, PortType, usize)>,
 117 
 118     /// The wire the in-flight node drag would splice into, as (src node id,
 119     /// dest node id) — ids, not indices, because hosts re-sync nodes on
 120     /// every window event and an index would go stale between drag_update
 121     /// and the release (the hovered_port lesson). Drawn highlighted while it
 122     /// holds; resolved into `pending_splice` on drop.
 123     splice_target: Option<(String, String)>,
 124     /// A completed splice drop for the host: (dragged node id, the wire's
 125     /// upstream node NAME — what Input params store, the wire's downstream
 126     /// node id). The host rewires: dragged.Input = upstream name,
 127     /// downstream.Input = dragged's name.
 128     pending_splice: Option<(String, String, String)>,
 129 }
 130 
 131 impl Graph {
 132     pub fn new() -> Adapted<Graph> {
 133         crate::layout::lazy_init_style_registry();
 134 
 135         let pitch_x = crate::layout::graph_spacing_x();
 136         let pitch_y = crate::layout::graph_spacing_y();
 137         let node_w = crate::layout::graph_node_width();
 138         let node_h = crate::layout::graph_node_height();
 139         let grid_snap_enabled = crate::layout::graph_grid_snap();
 140 
 141         let cell_col = crate::color::graph_cell_color();
 142         let gap_col = crate::color::graph_gap_color();
 143 
 144         Adapted::new(Graph {
 145             show_network_grid: false,
 146             pitch_x,
 147             pitch_y,
 148             node_w,
 149             node_h,
 150             grid_origin_x: 0.0,
 151             grid_origin_y: 0.0,
 152             pan_motion: crate::widget::ScrollMotion::new(),
 153             nodes: Vec::new(),
 154             selected_idx: None,
 155             selected_id: None,
 156             double_clicked_id: None,
 157             double_click_timer: None,
 158             grid_snap_enabled,
 159             node_geom_toggled: None,
 160             dragging_idx: None,
 161             dragging_id: None,
 162             drag_ox: 0.0,
 163             drag_oy: 0.0,
 164             drag_node_pos: None,
 165             toggle_hovered_idx: None,
 166             uniform_background: false,
 167             network_opacity: crate::color::graph_opacity(),
 168             node_opacity: crate::color::graph_node_opacity(),
 169             cell_color: cell_col,
 170             gap_color: gap_col,
 171             connecting_from: None,
 172             current_mouse_pos: (0.0, 0.0),
 173             pending_connection: None,
 174             hovered_port: None,
 175             splice_target: None,
 176             pending_splice: None,
 177         })
 178     }
 179 
 180     pub fn set_uniform_background(&mut self, uniform: bool) {
 181         self.uniform_background = uniform;
 182     }
 183     pub fn set_network_opacity(&mut self, opacity: f32) {
 184         self.network_opacity = opacity;
 185     }
 186     pub fn set_node_opacity(&mut self, opacity: f32) {
 187         self.node_opacity = opacity;
 188     }
 189     /// The pitch: centre of one grid line to the centre of the next, per axis.
 190     pub fn grid_pitch(&self) -> (f32, f32) {
 191         (self.pitch_x, self.pitch_y)
 192     }
 193     /// The node body's size at the current zoom.
 194     pub fn node_size(&self) -> (f32, f32) {
 195         (self.node_w, self.node_h)
 196     }
 197     /// Set the node body's size — what the cell-model setters do too, since
 198     /// there the cell IS the node.
 199     pub fn set_node_size(&mut self, w: f32, h: f32) {
 200         self.node_w = w;
 201         self.node_h = h;
 202     }
 203     /// The cell-model view of the lattice: the node body (its "cell").
 204     pub fn grid_sizes(&self) -> (f32, f32) {
 205         (self.node_w, self.node_h)
 206     }
 207     /// The cell-model view of the lattice: what a pitch has beyond the node
 208     /// body, as (row gap, column gap) — the order `set_skipped_sizes` takes.
 209     pub fn skipped_sizes(&self) -> (f32, f32) {
 210         (self.pitch_y - self.node_h, self.pitch_x - self.node_w)
 211     }
 212     pub fn grid_origin(&self) -> (f32, f32) {
 213         (self.grid_origin_x, self.grid_origin_y)
 214     }
 215     pub fn grid_snap_enabled(&self) -> bool {
 216         self.grid_snap_enabled
 217     }
 218     pub fn set_cell_color(&mut self, color: [f32; 3]) {
 219         self.cell_color = color;
 220     }
 221     pub fn set_gap_color(&mut self, color: [f32; 3]) {
 222         self.gap_color = color;
 223     }
 224 
 225     /// The top-left corner of a node body centred on lattice cell (col, row).
 226     fn cell_origin(&self, col: f32, row: f32) -> (f32, f32) {
 227         (
 228             self.grid_origin_x + col * self.pitch_x - self.node_w * 0.5,
 229             self.grid_origin_y + row * self.pitch_y - self.node_h * 0.5,
 230         )
 231     }
 232 
 233     /// The lattice cell whose intersection is nearest the CENTRE of a node
 234     /// body whose top-left is (nx, ny) — the one snapping rule, shared by the
 235     /// drag preview, the drop-target highlight and the drop itself. None on a
 236     /// degenerate pitch.
 237     fn nearest_cell(&self, nx: f32, ny: f32) -> Option<(f32, f32)> {
 238         if self.pitch_x <= 0.0 || self.pitch_y <= 0.0 {
 239             return None;
 240         }
 241         let c = ((nx + self.node_w * 0.5 - self.grid_origin_x) / self.pitch_x).round();
 242         let r = ((ny + self.node_h * 0.5 - self.grid_origin_y) / self.pitch_y).round();
 243         Some((c, r))
 244     }
 245 
 246     pub fn node_rect(&self, idx: usize) -> Option<(f32, f32, f32, f32)> {
 247         let node = self.nodes.get(idx)?;
 248         let at_cell = self.cell_origin(node.position.0, node.position.1);
 249         let (nx, ny) = if self.dragging_idx == Some(idx) {
 250             self.drag_node_pos.unwrap_or(at_cell)
 251         } else {
 252             at_cell
 253         };
 254         Some((nx, ny, self.node_w, self.node_h))
 255     }
 256 
 257     /// The rect the in-flight node drag will deposit its body on — hosts
 258     /// highlight it as the drop target. Runs the SAME resolution as
 259     /// `commit_drag` (nearest intersection, then `find_empty_cell` walks off
 260     /// occupied ones), so the highlight never lies about where the node
 261     /// actually lands. None outside a node drag.
 262     pub fn drop_target_cell_rect(&self) -> Option<(f32, f32, f32, f32)> {
 263         let idx = self.dragging_idx?;
 264         let (nx, ny) = self.drag_node_pos?;
 265         let (c, r) = self.nearest_cell(nx, ny)?;
 266         let (c, r) = self.find_empty_cell(c, r, Some(idx));
 267         let (x, y) = self.cell_origin(c, r);
 268         Some((x, y, self.node_w, self.node_h))
 269     }
 270 
 271     pub fn is_node_rect(&self, qx: f32, qy: f32, qw: f32, qh: f32) -> bool {
 272         for i in 0..self.nodes.len() {
 273             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
 274                 if (qx - nx).abs() < 0.1 && (qy - ny).abs() < 0.1 && (qw - nw).abs() < 0.1 && (qh - nh).abs() < 0.1 {
 275                     return true;
 276                 }
 277             }
 278         }
 279         false
 280     }
 281 
 282     /// The topmost node whose body contains (px, py), in the same
 283     /// window-absolute space `node_rect` reports (grid_origin = pane + pan).
 284     /// Reverse order so a later-drawn node wins where bodies overlap.
 285     pub fn node_at(&self, px: f32, py: f32) -> Option<usize> {
 286         for i in (0..self.nodes.len()).rev() {
 287             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
 288                 if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
 289                     return Some(i);
 290                 }
 291             }
 292         }
 293         None
 294     }
 295 
 296     /// How far a port's center floats off its node edge: the connector's own
 297     /// radius plus a small gap, so the circle sits fully OUTSIDE the node's
 298     /// bounding box rather than straddling its border.
 299     fn port_offset(scale_f: f32) -> f32 {
 300         (crate::layout::graph_connector_size() * scale_f).max(2.0) / 2.0 + 2.0 * scale_f
 301     }
 302 
 303     /// A port's center in graph coordinates — the ONE source for drawing,
 304     /// hover, click hit-testing, and wire endpoints, so they cannot drift.
 305     /// Inputs float above the node's top edge, outputs below its bottom.
 306     pub fn port_center(&self, idx: usize, port_type: PortType, k: usize) -> Option<(f32, f32)> {
 307         let (nx, ny, nw, nh) = self.node_rect(idx)?;
 308         let node = self.nodes.get(idx)?;
 309         let offset = Self::port_offset(nw / 80.0);
 310         match port_type {
 311             PortType::Input => (k < node.inputs)
 312                 .then(|| (nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32, ny - offset)),
 313             PortType::Output => (k < node.outputs)
 314                 .then(|| (nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32, ny + nh + offset)),
 315         }
 316     }
 317 
 318     pub fn toggle_rect(&self, idx: usize) -> Option<(f32, f32, f32, f32)> {
 319         if let Some(node) = self.nodes.get(idx) {
 320             // Settings containers have no geometry to toggle: utility nodes,
 321             // the designer's session node that now nests them, and the
 322             // per-node meta (preferences) node.
 323             if node.node_type == "utility" || node.node_type == "session" || node.node_type == "meta" {
 324                 return None;
 325             }
 326         }
 327         let (nx, ny, nw, nh) = self.node_rect(idx)?;
 328         let scale_f = nw / 80.0;
 329         let size = (18.0 * scale_f).clamp(6.0, 50.0);
 330         Some((nx + nw - size - 6.0 * scale_f, ny + (nh - size) / 2.0, size, size))
 331     }
 332 
 333     fn find_empty_cell(&self, start_x: f32, start_y: f32, skip_idx: Option<usize>) -> (f32, f32) {
 334         let x = start_x;
 335         let mut y = start_y;
 336         loop {
 337             let occupied = self.nodes.iter().enumerate().any(|(idx, node)| {
 338                 if Some(idx) == skip_idx {
 339                     false
 340                 } else {
 341                     (node.position.0 - x).abs() < 0.01 && (node.position.1 - y).abs() < 0.01
 342                 }
 343             });
 344             if occupied {
 345                 y += 1.0;
 346             } else {
 347                 break;
 348             }
 349         }
 350         (x, y)
 351     }
 352 
 353     /// Scale the lattice and the node bodies together; the limits are on the
 354     /// node width, as they always were.
 355     fn scale_by(&mut self, factor: f32) {
 356         self.pitch_x *= factor;
 357         self.pitch_y *= factor;
 358         self.node_w *= factor;
 359         self.node_h *= factor;
 360     }
 361 
 362     pub fn zoom_in(&mut self) {
 363         if self.node_w < 400.0 {
 364             self.scale_by(1.1);
 365         }
 366     }
 367 
 368     pub fn zoom_out(&mut self) {
 369         if self.node_w > 40.0 {
 370             self.scale_by(1.0 / 1.1);
 371         }
 372     }
 373 
 374     pub fn zoom_by_factor(&mut self, factor: f32) {
 375         let new_w = self.node_w * factor;
 376         if new_w >= 40.0 && new_w <= 400.0 {
 377             self.scale_by(factor);
 378         }
 379     }
 380 
 381     /// The graph's plate, as the colour-typed host paints it: the cell
 382     /// colour (or a near-transparent black) at the network opacity, and
 383     /// under `graph_blur` a frosted material whose tint alpha IS the blur
 384     /// value — the knob doubles as the frost's opacity.
 385     fn bg_color(&self) -> [f32; 4] {
 386         use crate::scene::{Frost, Material, PlateRole};
 387         let c = if self.uniform_background {
 388             [self.cell_color[0], self.cell_color[1], self.cell_color[2], self.network_opacity]
 389         } else {
 390             [0.0, 0.0, 0.0, 0.01 * self.network_opacity]
 391         };
 392         let blur_val = crate::layout::graph_blur();
 393         let m = if blur_val > 0.0 {
 394             Material::opaque([c[0], c[1], c[2], blur_val.abs() * self.network_opacity]).with_frost(Frost::from_style())
 395         } else {
 396             Material::opaque(c)
 397         };
 398         m.fill(PlateRole::Nested)
 399     }
 400 
 401     /// The corner radius of anything node-shaped at the current zoom — the
 402     /// hosts' empty-cell cursor and drop-target highlight read it. Clamped to
 403     /// a quarter sweep of the node body; 0 when the body is degenerate.
 404     pub fn cell_corner_radius(&self) -> f32 {
 405         // Pure GEOMETRY — no display gating: the cursor and the highlight
 406         // exist whether or not the lattice is drawn. Gating on
 407         // show_network_grid/uniform_background silently squared those
 408         // consumers whenever the grid was hidden.
 409         if self.node_w <= 0.0 || self.node_h <= 0.0 {
 410             return 0.0;
 411         }
 412         let body = self.node_w.min(self.node_h);
 413         // The NODE radius, so the cursor sitting on a node's cell traces the
 414         // same silhouette the node does.
 415         crate::layout::graph_node_corner_radius().min(body / 2.0)
 416     }
 417 
 418     /// The grid lines, flat, over whatever the graph is painted on — the
 419     /// pane plate. A lattice of lines one pitch apart in the gap colour at
 420     /// the network opacity, each centred on its coordinate (the pitch is
 421     /// measured centre to centre, and `graph_line_width` only thickens
 422     /// them), so the intersections are exactly where the node centres go.
 423     /// Plus the origin axes: the two lines through the (0, 0) intersection,
 424     /// 2px, in the axis colour. Gated on the grid's visibility only:
 425     /// `uniform_background` describes the widget's own background fill and
 426     /// the designer hard-codes it true, which is how its grid went undrawn
 427     /// until 2026-09-20. (Rounded cells with grout between them came before
 428     /// the lattice; a node then FILLED a cell rather than sitting on a
 429     /// crossing.)
 430     pub fn paint_grid(&self, rect: Rect, pc: &mut PaintCtx) {
 431         if self.pitch_x <= 0.0 || self.pitch_y <= 0.0 {
 432             return;
 433         }
 434         let (min_x, min_y) = (rect.x, rect.y);
 435         let (max_x, max_y) = (rect.x + rect.width, rect.y + rect.height);
 436         let clipped = |qx: f32, qy: f32, qw: f32, qh: f32, c: [f32; 4], pc: &mut PaintCtx| {
 437             let x1 = qx.max(min_x);
 438             let y1 = qy.max(min_y);
 439             let x2 = (qx + qw).min(max_x);
 440             let y2 = (qy + qh).min(max_y);
 441             if x2 > x1 && y2 > y1 {
 442                 pc.quad(Rect { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }, c);
 443             }
 444         };
 445 
 446         let line = crate::layout::graph_line_width().max(0.0);
 447         if self.show_network_grid && line > 0.0 && self.pitch_x >= 4.0 && self.pitch_y >= 4.0 {
 448             let color = [self.gap_color[0], self.gap_color[1], self.gap_color[2], self.network_opacity];
 449             // The line indices that can cross the rect, one past each edge so
 450             // a line's own width never pops at the boundary.
 451             let c0 = ((min_x - self.grid_origin_x) / self.pitch_x).floor() as i32 - 1;
 452             let c1 = ((max_x - self.grid_origin_x) / self.pitch_x).ceil() as i32 + 1;
 453             for c in c0..=c1 {
 454                 let x = self.grid_origin_x + c as f32 * self.pitch_x;
 455                 clipped(x - line / 2.0, rect.y, line, rect.height, color, pc);
 456             }
 457             let r0 = ((min_y - self.grid_origin_y) / self.pitch_y).floor() as i32 - 1;
 458             let r1 = ((max_y - self.grid_origin_y) / self.pitch_y).ceil() as i32 + 1;
 459             for r in r0..=r1 {
 460                 let y = self.grid_origin_y + r as f32 * self.pitch_y;
 461                 clipped(rect.x, y - line / 2.0, rect.width, line, color, pc);
 462             }
 463         }
 464 
 465         // Origin axes: the lattice lines through the (0, 0) intersection.
 466         let axis = [0.0, 0.0, 0.0, self.network_opacity];
 467         let thickness = 2.0;
 468         clipped(rect.x, self.grid_origin_y - thickness / 2.0, rect.width, thickness, axis, pc);
 469         clipped(self.grid_origin_x - thickness / 2.0, rect.y, thickness, rect.height, axis, pc);
 470     }
 471 
 472     /// The wires / connection preview / node bodies / toggles as plain quads — the legacy
 473     /// `extra_quads` body, against `rect` instead of a stored rect. The [`TaggedQuad`] cell
 474     /// tag is always `None` now: the lattice is `paint_grid`'s, and it has no cells.
 475     pub fn geometry_quads_tagged(&self, rect: Rect) -> Vec<TaggedQuad> {
 476         let mut quads = Vec::new();
 477         let min_x = rect.x;
 478         let min_y = rect.y;
 479         let max_x = rect.x + rect.width;
 480         let max_y = rect.y + rect.height;
 481         let push_clipped = |qx: f32, qy: f32, qw: f32, qh: f32, qc: [f32; 4], q: &mut Vec<TaggedQuad>| {
 482             let rx1 = qx.max(min_x);
 483             let ry1 = qy.max(min_y);
 484             let rx2 = (qx + qw).min(max_x);
 485             let ry2 = (qy + qh).min(max_y);
 486             let rw = rx2 - rx1;
 487             let rh = ry2 - ry1;
 488             if rw > 0.0 && rh > 0.0 {
 489                 q.push((rx1, ry1, rw, rh, qc, None));
 490             }
 491         };
 492 
 493         // A three-segment orthogonal wire from (start_x, start_y) down/up to (end_x, end_y).
 494         let scale_f = self.node_w / 80.0;
 495         let wire_thickness = (3.0 * scale_f).clamp(1.0, 15.0);
 496         let push_wire = |start_x: f32, start_y: f32, end_x: f32, end_y: f32, color: [f32; 4], q: &mut Vec<TaggedQuad>| {
 497             let mid_y = start_y + (end_y - start_y) / 2.0;
 498 
 499             let v1_min_y = start_y.min(mid_y);
 500             let v1_max_y = start_y.max(mid_y);
 501             push_clipped(start_x - wire_thickness / 2.0, v1_min_y, wire_thickness, v1_max_y - v1_min_y, color, q);
 502 
 503             let h_min_x = start_x.min(end_x);
 504             let h_max_x = start_x.max(end_x);
 505             push_clipped(h_min_x, mid_y - wire_thickness / 2.0, h_max_x - h_min_x, wire_thickness, color, q);
 506 
 507             let v2_min_y = mid_y.min(end_y);
 508             let v2_max_y = mid_y.max(end_y);
 509             push_clipped(end_x - wire_thickness / 2.0, v2_min_y, wire_thickness, v2_max_y - v2_min_y, color, q);
 510         };
 511 
 512         // Connection wires: each node with an "input" parameter draws a wire
 513         // from that source node's first output port to its own first input
 514         // port (pairs and endpoints from the shared helpers the splice hit
 515         // test also reads). The wire an in-flight node drag would splice
 516         // into draws in the highlight color — the drop affordance.
 517         let wire_color = [0.0, 0.75, 1.0, 0.7 * self.node_opacity]; // Vibrant cyan glow
 518         let hl = crate::color::graph_wire_highlight_color();
 519         let splice_color = [hl[0], hl[1], hl[2], hl[3] * self.node_opacity];
 520         for (src_idx, i) in self.wire_pairs() {
 521             if let Some(((start_x, start_y), (end_x, end_y))) = self.wire_endpoints(src_idx, i) {
 522                 let is_splice_target = self
 523                     .splice_target
 524                     .as_ref()
 525                     .map(|(s, d)| self.nodes[src_idx].id == *s && self.nodes[i].id == *d)
 526                     .unwrap_or(false);
 527                 let color = if is_splice_target { splice_color } else { wire_color };
 528                 push_wire(start_x, start_y, end_x, end_y, color, &mut quads);
 529             }
 530         }
 531 
 532         // Connection preview while dragging one out
 533         if let Some((node_idx, port_type, port_idx)) = self.connecting_from {
 534             if let Some((start_x, start_y)) = self.port_center(node_idx, port_type, port_idx) {
 535                 let preview_color = [1.0, 0.6, 0.0, 0.8]; // Golden orange preview
 536                 push_wire(start_x, start_y, self.current_mouse_pos.0, self.current_mouse_pos.1, preview_color, &mut quads);
 537             }
 538         }
 539 
 540         // The grid lines and the origin axes are `paint_grid`'s — hosts that
 541         // draw these quads themselves call it at the same point in their walk.
 542 
 543         // Node bodies (culled, not clipped — legacy) + geometry toggles (clipped)
 544         for i in 0..self.nodes.len() {
 545             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
 546                 let scale_f = nw / 80.0;
 547                 let mut bg_color = if self.dragging_idx == Some(i) {
 548                     colors::node_drag_color()
 549                 } else if self.selected_idx == Some(i) {
 550                     colors::node_selected_color()
 551                 } else {
 552                     colors::node_color()
 553                 };
 554                 bg_color[3] *= self.node_opacity;
 555                 if nx + nw > min_x && nx < max_x && ny + nh > min_y && ny < max_y {
 556                     quads.push((nx, ny, nw, nh, bg_color, None));
 557                 }
 558 
 559                 // The geometry toggle is a single-color circle now — it draws
 560                 // through the circles channel (see port_circles), not as
 561                 // quads: a filled dot when the geometry is visible, the same
 562                 // color faded when hidden. The old look was a two-tone square
 563                 // (state square inside a hover-tinted well).
 564                 let _ = scale_f;
 565             }
 566         }
 567 
 568         quads
 569     }
 570 
 571     /// [`geometry_quads_tagged`](Self::geometry_quads_tagged) with the cell tags
 572     /// stripped — the unchanged legacy `extra_quads` shape.
 573     fn geometry_quads(&self, rect: Rect) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
 574         self.geometry_quads_tagged(rect)
 575             .into_iter()
 576             .map(|(qx, qy, qw, qh, qc, _)| (qx, qy, qw, qh, qc))
 577             .collect()
 578     }
 579 
 580     /// The rounded view of the same geometry — the legacy `all_rounded_quads` conversion: the
 581     /// widget background, then each plain quad either as a grid cell (superellipse cell arcs),
 582     /// a node body (node corner radius, all corners), or with the widget's edge-corner
 583     /// resolution.
 584     fn rounded_geometry(&self, rect: Rect) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
 585         let mut rounded = Vec::new();
 586 
 587         let (w_tl, w_tr, w_br, w_bl) = WIDGET_CORNERS;
 588         rounded.push((rect.x, rect.y, rect.width, rect.height, WIDGET_RADIUS, self.bg_color(), WIDGET_CORNERS));
 589 
 590         let node_radius = crate::layout::graph_node_corner_radius();
 591         let cell_radius = self.cell_corner_radius();
 592         let (wx, wy, ww, wh) = (rect.x, rect.y, rect.width, rect.height);
 593 
 594         for (qx, qy, qw, qh, qc, cell) in self.geometry_quads_tagged(rect) {
 595             if self.is_node_rect(qx, qy, qw, qh) {
 596                 rounded.push((qx, qy, qw, qh, node_radius, qc, (true, true, true, true)));
 597             } else {
 598                 let tl = w_tl && qx <= wx + 1.5 && qy <= wy + 1.5;
 599                 let tr = w_tr && qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5;
 600                 let br = w_br && qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5;
 601                 let bl = w_bl && qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5;
 602 
 603                 if let Some((ctl, ctr, cbr, cbl)) = cell {
 604                     // A cell cut by the pane's own rounded corner wears the
 605                     // widget arc there; its interior corners keep the cell arc.
 606                     let r = if tl || tr || br || bl { cell_radius.max(WIDGET_RADIUS) } else { cell_radius };
 607                     rounded.push((qx, qy, qw, qh, r, qc, (ctl || tl, ctr || tr, cbr || br, cbl || bl)));
 608                 } else {
 609                     let r = if tl || tr || br || bl { WIDGET_RADIUS } else { 0.0 };
 610                     rounded.push((qx, qy, qw, qh, r, qc, (tl, tr, br, bl)));
 611                 }
 612             }
 613         }
 614 
 615         rounded
 616     }
 617 
 618     /// Input/output port circles, culled to the widget rect (legacy `extra_circles`).
 619     fn port_circles(&self, rect: Rect) -> Vec<(f32, f32, f32, [f32; 4])> {
 620         let mut circles = Vec::new();
 621         let min_x = rect.x;
 622         let min_y = rect.y;
 623         let max_x = rect.x + rect.width;
 624         let max_y = rect.y + rect.height;
 625 
 626         let mut push_circle_clipped = |cx: f32, cy: f32, r: f32, color: [f32; 4]| {
 627             if cx >= min_x && cx <= max_x && cy >= min_y && cy <= max_y {
 628                 circles.push((cx, cy, r, color));
 629             }
 630         };
 631 
 632         // Geometry toggles: one circle per toggleable node, a SINGLE color —
 633         // TOGGLE_ON at full alpha when visible, the same color faded when
 634         // hidden; hover grows the radius the way port dots do, so no second
 635         // hover tint is needed. Hit-testing stays toggle_rect's square (the
 636         // circle is inscribed in it).
 637         for i in 0..self.nodes.len() {
 638             if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
 639                 let cx = tx + tw / 2.0;
 640                 let cy = ty + th / 2.0;
 641                 let mut r = tw.min(th) / 2.0;
 642                 if self.toggle_hovered_idx == Some(i) {
 643                     r *= 1.15;
 644                 }
 645                 let mut c = colors::TOGGLE_ON;
 646                 if !self.nodes[i].geom_visible {
 647                     c[3] *= 0.25;
 648                 }
 649                 c[3] *= self.node_opacity;
 650                 push_circle_clipped(cx, cy, r, c);
 651             }
 652         }
 653 
 654         let conn_size = crate::layout::graph_connector_size();
 655         let mut conn_color = colors::graph_connector_color();
 656         let mut conn_hl_color = colors::graph_connector_highlight_color();
 657         conn_color[3] *= self.node_opacity;
 658         conn_hl_color[3] *= self.node_opacity;
 659 
 660         for i in 0..self.nodes.len() {
 661             if let Some((_, _, nw, _)) = self.node_rect(i) {
 662                 let scale_f = nw / 80.0;
 663                 let port_size = (conn_size * scale_f).max(2.0);
 664                 let base_r = port_size / 2.0;
 665 
 666                 let node = &self.nodes[i];
 667 
 668                 for (port_type, count) in
 669                     [(PortType::Input, node.inputs), (PortType::Output, node.outputs)]
 670                 {
 671                     for k in 0..count {
 672                         let Some((cx, cy)) = self.port_center(i, port_type, k) else { continue };
 673 
 674                         let is_hovered = self.hovered_port == Some((i, port_type, k));
 675                         let is_connecting = self.connecting_from == Some((i, port_type, k));
 676 
 677                         let (r, color) = if is_hovered || is_connecting {
 678                             (base_r * 1.4, conn_hl_color)
 679                         } else {
 680                             (base_r, conn_color)
 681                         };
 682                         push_circle_clipped(cx, cy, r, color);
 683                     }
 684                 }
 685             }
 686         }
 687         circles
 688     }
 689 
 690     /// Node-name labels beside each node, scaled with the grid, included only when they
 691     /// intersect the widget rect (legacy `text_labels`).
 692     /// A node's name hangs off its body's RIGHT edge — an 8 px gap and a
 693     /// 14 px font, both scaled with the body against its 80 px baseline —
 694     /// unless it would not fit there and fits on the LEFT, where it hangs
 695     /// off the left edge instead, right-aligned to it. A node parked against
 696     /// the pane's right edge used to draw with no name at all: the label
 697     /// began past the edge and the cull dropped it whole, whatever its
 698     /// length. Frame All assumes the right-hand placement, which is safe —
 699     /// after framing every label fits on the right and none flips.
 700     fn node_labels(&self, rect: Rect) -> Vec<TextLabel> {
 701         let mut labels = Vec::new();
 702         for (i, node) in self.nodes.iter().enumerate() {
 703             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
 704                 let scale_f = nw / 80.0;
 705                 let font_size = (14.0 * scale_f).clamp(6.0, 48.0);
 706                 let gap = 8.0 * scale_f;
 707                 let ly = crate::layout::align_text_y(ny, nh, font_size, 0.0);
 708                 let text_w = TextLabel::estimate_width(&node.name, font_size);
 709                 let right = nx + nw + gap;
 710                 let left = nx - gap - text_w;
 711                 let fits_right = right + text_w <= rect.x + rect.width;
 712                 let fits_left = left >= rect.x;
 713                 let lx = if !fits_right && fits_left { left } else { right };
 714                 if lx + text_w >= rect.x && lx < rect.x + rect.width && ly + font_size >= rect.y && ly < rect.y + rect.height {
 715                     labels.push(TextLabel {
 716                         text: node.name.clone(),
 717                         x: lx,
 718                         y: ly,
 719                         font_size,
 720                         color: [0xcc, 0xcc, 0xd4],
 721                     });
 722                 }
 723             }
 724         }
 725         labels
 726     }
 727 }
 728 
 729 fn read_zoom_bindings() -> (String, String) {
 730     let mut zoom_in_val = "=".to_string();
 731     let mut zoom_out_val = "-".to_string();
 732     let path = crate::config::get_config_path();
 733     if let Ok(content) = std::fs::read_to_string(&path) {
 734         if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
 735             if let Some(zoom_in) = val.pointer("/layout/zoom_in").and_then(|v| v.as_str()) {
 736                 zoom_in_val = zoom_in.to_string();
 737             }
 738             if let Some(zoom_out) = val.pointer("/layout/zoom_out").and_then(|v| v.as_str()) {
 739                 zoom_out_val = zoom_out.to_string();
 740             }
 741         }
 742     }
 743     (zoom_in_val, zoom_out_val)
 744 }
 745 
 746 impl Layout for Graph {}
 747 
 748 impl Paint for Graph {
 749     fn color(&self) -> [f32; 4] {
 750         self.bg_color()
 751     }
 752 
 753     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
 754         Some((WIDGET_RADIUS, WIDGET_CORNERS))
 755     }
 756 
 757     fn widget_font(&self) -> Option<String> {
 758         Some(crate::layout::graph_node_font())
 759     }
 760 
 761     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
 762         // The background is the first entry; the grid lines go over it
 763         // before the wires and nodes.
 764         for (i, (qx, qy, qw, qh, r, c, corners)) in self.rounded_geometry(rect).into_iter().enumerate() {
 765             ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, r, corners, c);
 766             if i == 0 {
 767                 self.paint_grid(rect, ctx);
 768             }
 769         }
 770         for (cx, cy, r, c) in self.port_circles(rect) {
 771             ctx.circle(cx, cy, r, c);
 772         }
 773         // Node names are arbitrary and the canvas is fixed, so a long name on a
 774         // node near the right edge used to draw off the graph entirely.
 775         let canvas = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
 776         for l in self.node_labels(rect) {
 777             ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, canvas);
 778         }
 779     }
 780 
 781     fn serves_legacy_plain_quads(&self) -> bool {
 782         true
 783     }
 784 
 785     fn legacy_plain_quads(&self, rect: Rect) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
 786         self.geometry_quads(rect)
 787     }
 788 
 789     fn text_bounds(&self, rect: Rect) -> Option<[f32; 4]> {
 790         Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height])
 791     }
 792 }
 793 
 794 impl Input for Graph {
 795     /// Advances the pan glide/coast behind the grid origin. Idle is a no-op.
 796     fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
 797         self.pan_motion.reconcile(self.grid_origin_x, self.grid_origin_y);
 798         if !self.pan_motion.is_animating() {
 799             return false;
 800         }
 801         let free = crate::widget::Bounds::UNBOUNDED;
 802         let moved = self.pan_motion.tick(dt, free, free);
 803         self.grid_origin_x = self.pan_motion.x.pos();
 804         self.grid_origin_y = self.pan_motion.y.pos();
 805         moved || self.pan_motion.is_animating()
 806     }
 807 
 808     fn wants_tick(&self) -> bool {
 809         true
 810     }
 811 
 812     /// Legacy hit test excluded the right/bottom edges.
 813     fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
 814         x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
 815     }
 816 
 817     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
 818         match event {
 819             Event::PointerMove { x: px, y: py, .. } => {
 820                 let (px, py) = (*px, *py);
 821                 let mut changed = false;
 822                 if self.connecting_from.is_some() {
 823                     self.current_mouse_pos = (px, py);
 824                     changed = true;
 825                 }
 826                 let was_toggle_hovered = self.toggle_hovered_idx;
 827                 self.toggle_hovered_idx = None;
 828 
 829                 let was_hovered_port = self.hovered_port;
 830                 self.hovered_port = None;
 831 
 832                 let conn_act_r = crate::layout::graph_connector_activation_radius();
 833 
 834                 for i in 0..self.nodes.len() {
 835                     if let Some((_, _, nw, _)) = self.node_rect(i) {
 836                         let scale_f = nw / 80.0;
 837                         let hit_radius = (conn_act_r * scale_f).max(2.0);
 838                         let node = &self.nodes[i];
 839 
 840                         for (port_type, count) in
 841                             [(PortType::Input, node.inputs), (PortType::Output, node.outputs)]
 842                         {
 843                             for k in 0..count {
 844                                 let Some((cx, cy)) = self.port_center(i, port_type, k) else {
 845                                     continue;
 846                                 };
 847                                 if (px - cx).powi(2) + (py - cy).powi(2) <= hit_radius.powi(2) {
 848                                     self.hovered_port = Some((i, port_type, k));
 849                                 }
 850                             }
 851                         }
 852                     }
 853 
 854                     if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
 855                         if px >= tx && px < tx + tw && py >= ty && py < ty + th {
 856                             self.toggle_hovered_idx = Some(i);
 857                         }
 858                     }
 859                 }
 860 
 861                 if was_toggle_hovered != self.toggle_hovered_idx || was_hovered_port != self.hovered_port {
 862                     changed = true;
 863                 }
 864                 changed
 865             }
 866             Event::MouseLeave => {
 867                 let changed = self.hovered_port.is_some() || self.toggle_hovered_idx.is_some();
 868                 self.hovered_port = None;
 869                 self.toggle_hovered_idx = None;
 870                 changed
 871             }
 872             Event::MouseButton { button: MouseButton::Right, state: ElementState::Pressed, .. } => {
 873                 if self.connecting_from.is_some() {
 874                     self.connecting_from = None;
 875                     true
 876                 } else {
 877                     false
 878                 }
 879             }
 880             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
 881                 self.on_left_press(*x, *y, ectx)
 882             }
 883             Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
 884                 if self.dragging_idx.is_some() {
 885                     self.commit_drag();
 886                     true
 887                 } else {
 888                     false
 889                 }
 890             }
 891             Event::MouseWheel { delta, x: px, y: py, .. } => {
 892                 let _ = (px, py); // hit-gated by the adapter
 893                 let ctrl = ectx.ui.as_deref().map_or(false, |ui| ui.ctrl_pressed);
 894                 if ctrl {
 895                     match delta {
 896                         MouseScrollDelta::LineDelta(_x, y) => {
 897                             if *y > 0.0 {
 898                                 self.zoom_by_factor(1.1);
 899                             } else if *y < 0.0 {
 900                                 self.zoom_by_factor(1.0 / 1.1);
 901                             }
 902                             true
 903                         }
 904                         MouseScrollDelta::PixelDelta(pos) => {
 905                             let factor = 1.0 + (pos.y as f32 * 0.015);
 906                             self.zoom_by_factor(factor);
 907                             true
 908                         }
 909                     }
 910                 } else {
 911                     // Pan: the origin moves WITH the wheel sign (no negation —
 912                     // the canvas follows the gesture), across an unbounded plane.
 913                     let (dx, dy) = match delta {
 914                         MouseScrollDelta::LineDelta(x, y) => (*x * 15.0, *y * 15.0),
 915                         MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
 916                     };
 917                     let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
 918                     let free = crate::widget::Bounds::UNBOUNDED;
 919                     self.pan_motion.reconcile(self.grid_origin_x, self.grid_origin_y);
 920                     self.pan_motion.apply_px(dx, dy, discrete, free, free);
 921                     self.grid_origin_x = self.pan_motion.x.pos();
 922                     self.grid_origin_y = self.pan_motion.y.pos();
 923                     true
 924                 }
 925             }
 926             Event::KeyInput(key_event) => {
 927                 if key_event.state == ElementState::Pressed {
 928                     if let Key::Character(ref ch) = key_event.logical_key {
 929                         let (zoom_in_binding, zoom_out_binding) = read_zoom_bindings();
 930                         if ch == &zoom_in_binding {
 931                             self.zoom_in();
 932                             return true;
 933                         } else if ch == &zoom_out_binding {
 934                             self.zoom_out();
 935                             return true;
 936                         }
 937                     }
 938                 }
 939                 false
 940             }
 941             _ => false,
 942         }
 943     }
 944 
 945     fn scrollable(&self) -> bool {
 946         false
 947     }
 948 
 949     // The graph is "draggable" only once a left press landed on a node body (`on_left_press`
 950     // sets `dragging_idx`); hosts then re-init via drag_begin and stream drag_update.
 951     fn draggable(&self, _rect: Rect) -> bool {
 952         self.dragging_idx.is_some()
 953     }
 954     fn is_dragging(&self) -> bool {
 955         self.dragging_idx.is_some()
 956     }
 957 
 958     fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
 959         if let Some(idx) = self.dragging_idx {
 960             if let Some((nx, ny, _, _)) = self.node_rect(idx) {
 961                 self.drag_ox = px - nx;
 962                 self.drag_oy = py - ny;
 963                 self.drag_node_pos = Some((nx, ny));
 964             }
 965         }
 966     }
 967 
 968     fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
 969         if self.dragging_idx.is_some() {
 970             let nx = px - self.drag_ox;
 971             let ny = py - self.drag_oy;
 972 
 973             // Snapping centres the body on the nearest intersection.
 974             let (nx, ny) = match self.nearest_cell(nx, ny) {
 975                 Some((c, r)) if self.grid_snap_enabled => self.cell_origin(c, r),
 976                 _ => (nx, ny),
 977             };
 978 
 979             self.drag_node_pos = Some((nx, ny));
 980             // The wire the ghost sits on right now, held by id (hosts
 981             // re-sync between events) and drawn highlighted — the drop
 982             // affordance the user aims by.
 983             self.splice_target = self
 984                 .dragging_idx
 985                 .and_then(|i| self.splice_wire_at(i, nx, ny))
 986                 .map(|(s, d)| (self.nodes[s].id.clone(), self.nodes[d].id.clone()));
 987             return true;
 988         }
 989         false
 990     }
 991 
 992     fn drag_end(&mut self) {
 993         self.commit_drag();
 994     }
 995 
 996 }
 997 
 998 impl Graph {
 999     /// The legacy left-press cascade: ports (start/complete a connection), a node-body
1000     /// fallback for an in-flight connection, geometry toggles, then node selection + drag
1001     /// arming; an empty-space press clears the selection and stays unconsumed.
1002     fn on_left_press(&mut self, px: f32, py: f32, ectx: &mut EventCtx) -> bool {
1003         for i in (0..self.nodes.len()).rev() {
1004             if let Some((_, _, nw, _)) = self.node_rect(i) {
1005                 let scale_f = nw / 80.0;
1006                 let conn_act_r = crate::layout::graph_connector_activation_radius();
1007                 let port_click_radius = (conn_act_r * scale_f).max(2.0);
1008                 let port_click_radius_sq = port_click_radius * port_click_radius;
1009 
1010                 let node = &self.nodes[i];
1011                 for (port_type, count) in
1012                     [(PortType::Input, node.inputs), (PortType::Output, node.outputs)]
1013                 {
1014                     for k in 0..count {
1015                         let Some((port_x, port_y)) = self.port_center(i, port_type, k) else {
1016                             continue;
1017                         };
1018                         let dx = px - port_x;
1019                         let dy = py - port_y;
1020                         if dx * dx + dy * dy <= port_click_radius_sq {
1021                             if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
1022                                 // A click on the opposite port kind of ANOTHER
1023                                 // node completes the connection; anything else
1024                                 // cancels it.
1025                                 if src_idx != i && src_port_type != port_type {
1026                                     let (out_idx, in_idx) = if port_type == PortType::Input {
1027                                         (src_idx, i)
1028                                     } else {
1029                                         (i, src_idx)
1030                                     };
1031                                     let output_node = &self.nodes[out_idx];
1032                                     let input_node = &self.nodes[in_idx];
1033                                     self.pending_connection =
1034                                         Some((input_node.id.clone(), output_node.name.clone()));
1035                                 }
1036                                 self.connecting_from = None;
1037                             } else {
1038                                 self.connecting_from = Some((i, port_type, k));
1039                                 self.current_mouse_pos = (px, py);
1040                             }
1041                             return true;
1042                         }
1043                     }
1044                 }
1045             }
1046         }
1047 
1048         // Actively connecting + clicked a target node body: connect to its closest compatible port
1049         if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
1050             for i in (0..self.nodes.len()).rev() {
1051                 if src_idx != i {
1052                     if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
1053                         if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
1054                             let node = &self.nodes[i];
1055                             if src_port_type == PortType::Output && node.inputs > 0 {
1056                                 let output_node = &self.nodes[src_idx];
1057                                 let input_node = &self.nodes[i];
1058                                 self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
1059                                 self.connecting_from = None;
1060                                 return true;
1061                             } else if src_port_type == PortType::Input && node.outputs > 0 {
1062                                 let output_node = &self.nodes[i];
1063                                 let input_node = &self.nodes[src_idx];
1064                                 self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
1065                                 self.connecting_from = None;
1066                                 return true;
1067                             }
1068                         }
1069                     }
1070                 }
1071             }
1072         }
1073 
1074         if self.connecting_from.is_some() {
1075             self.connecting_from = None;
1076         }
1077 
1078         for i in (0..self.nodes.len()).rev() {
1079             if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
1080                 if px >= tx && px < tx + tw && py >= ty && py < ty + th {
1081                     self.nodes[i].geom_visible = !self.nodes[i].geom_visible;
1082                     self.node_geom_toggled = Some((i, self.nodes[i].geom_visible));
1083                     return true;
1084                 }
1085             }
1086             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
1087                 if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
1088                     let now = std::time::Instant::now();
1089                     let clicked_id = self.nodes[i].id.clone();
1090                     if let Some((prev_time, prev_id)) = self.double_click_timer.take() {
1091                         if prev_id == clicked_id && now.duration_since(prev_time) < std::time::Duration::from_millis(500) {
1092                             self.double_clicked_id = Some(clicked_id.clone());
1093                         }
1094                     }
1095                     self.double_click_timer = Some((now, clicked_id));
1096                     self.selected_idx = Some(i);
1097                     self.selected_id = Some(self.nodes[i].id.clone());
1098                     self.dragging_idx = Some(i);
1099                     self.dragging_id = Some(self.nodes[i].id.clone());
1100                     self.drag_ox = px - nx;
1101                     self.drag_oy = py - ny;
1102                     self.drag_node_pos = Some((nx, ny));
1103                     ectx.request_focus();
1104                     return true;
1105                 }
1106             }
1107         }
1108         self.selected_idx = None;
1109         self.selected_id = None;
1110         false
1111     }
1112 
1113     /// The wire pairs the draw pass renders: (src idx, dest idx), one per
1114     /// node whose "Input" parameter names another node — the ONE derivation,
1115     /// shared with the splice hit test so the two cannot disagree about
1116     /// where a wire is.
1117     fn wire_pairs(&self) -> Vec<(usize, usize)> {
1118         let mut out = Vec::new();
1119         for i in 0..self.nodes.len() {
1120             let node = &self.nodes[i];
1121             if let Some((_, input_name, _)) =
1122                 node.parameters.iter().find(|(name, _, _)| name.eq_ignore_ascii_case("input"))
1123             {
1124                 if let Some(src_idx) = self.nodes.iter().position(|n| n.name == *input_name) {
1125                     out.push((src_idx, i));
1126                 }
1127             }
1128         }
1129         out
1130     }
1131 
1132     /// A wire's two attachment points — the port circles' centers, falling
1133     /// back to the node edge midpoints for portless nodes. The three-segment
1134     /// shape (down, across, down) derives from these in both the draw pass
1135     /// and [`Self::wire_segment_rects`].
1136     fn wire_endpoints(&self, src_idx: usize, dest_idx: usize) -> Option<((f32, f32), (f32, f32))> {
1137         let (sx, sy, sw, sh) = self.node_rect(src_idx)?;
1138         let (ex, ey, ew, _eh) = self.node_rect(dest_idx)?;
1139         let start = self
1140             .port_center(src_idx, PortType::Output, 0)
1141             .unwrap_or((sx + sw / 2.0, sy + sh));
1142         let end = self
1143             .port_center(dest_idx, PortType::Input, 0)
1144             .unwrap_or((ex + ew / 2.0, ey));
1145         Some((start, end))
1146     }
1147 
1148     /// The wire's three segments as axis-aligned rects at the drawn
1149     /// thickness — `push_wire`'s exact shape, unclipped.
1150     fn wire_segment_rects(&self, src_idx: usize, dest_idx: usize) -> Option<[(f32, f32, f32, f32); 3]> {
1151         let ((start_x, start_y), (end_x, end_y)) = self.wire_endpoints(src_idx, dest_idx)?;
1152         let scale_f = self.node_w / 80.0;
1153         let t = (3.0 * scale_f).clamp(1.0, 15.0);
1154         let mid_y = start_y + (end_y - start_y) / 2.0;
1155         let v1 = (start_x - t / 2.0, start_y.min(mid_y), t, (start_y - mid_y).abs());
1156         let h = (start_x.min(end_x), mid_y - t / 2.0, (end_x - start_x).abs(), t);
1157         let v2 = (end_x - t / 2.0, mid_y.min(end_y), t, (end_y - mid_y).abs());
1158         Some([v1, h, v2])
1159     }
1160 
1161     /// The wire the dragged node's ghost at (nx, ny) would splice into —
1162     /// the first pair (draw order) whose segment run touches the ghost rect,
1163     /// inflated by the wire activation radius so a near miss still takes.
1164     /// The dragged node's own wires never count (dropping a node on a wire
1165     /// it is already an end of is a move, not a rewire), and a node with no
1166     /// "Input" parameter or no output port cannot sit mid-chain.
1167     fn splice_wire_at(&self, idx: usize, nx: f32, ny: f32) -> Option<(usize, usize)> {
1168         let node = self.nodes.get(idx)?;
1169         let has_input = node
1170             .parameters
1171             .iter()
1172             .any(|(name, _, _)| name.eq_ignore_ascii_case("input"));
1173         if !has_input || node.outputs == 0 {
1174             return None;
1175         }
1176         let (_, _, nw, nh) = self.node_rect(idx)?;
1177         let pad = crate::layout::graph_wire_activation_radius().max(0.0);
1178         let (gx1, gy1) = (nx - pad, ny - pad);
1179         let (gx2, gy2) = (nx + nw + pad, ny + nh + pad);
1180         for (src, dest) in self.wire_pairs() {
1181             if src == idx || dest == idx {
1182                 continue;
1183             }
1184             let Some(segs) = self.wire_segment_rects(src, dest) else { continue };
1185             let hit = segs.iter().any(|&(x, y, w, h)| {
1186                 x < gx2 && x + w > gx1 && y < gy2 && y + h > gy1
1187             });
1188             if hit {
1189                 return Some((src, dest));
1190             }
1191         }
1192         None
1193     }
1194 
1195     /// Drop the in-flight node drag onto the nearest free intersection (legacy `drag_end`),
1196     /// resolving a held splice target into `pending_splice` for the host.
1197     fn commit_drag(&mut self) {
1198         let target = self.splice_target.take();
1199         if let Some((nx, ny)) = self.drag_node_pos.take() {
1200             if let Some(idx) = self.dragging_idx.take() {
1201                 if let Some((c, r)) = self.nearest_cell(nx, ny) {
1202                     let (c, r) = self.find_empty_cell(c, r, Some(idx));
1203                     self.nodes[idx].position = (c, r);
1204                 }
1205                 if let Some((src_id, dest_id)) = target {
1206                     let src_name = self.nodes.iter().find(|n| n.id == src_id).map(|n| n.name.clone());
1207                     let dest_ok = self.nodes.iter().any(|n| n.id == dest_id);
1208                     if let (Some(src_name), true) = (src_name, dest_ok) {
1209                         self.pending_splice = Some((self.nodes[idx].id.clone(), src_name, dest_id));
1210                     }
1211                 }
1212             }
1213         } else {
1214             self.dragging_idx = None;
1215         }
1216         self.dragging_id = None;
1217     }
1218 }
1219 
1220 impl GraphController for Graph {
1221     fn paint_grid(&self, rect: Rect, pc: &mut PaintCtx) {
1222         Graph::paint_grid(self, rect, pc)
1223     }
1224     fn set_nodes(&mut self, nodes: &[GraphNode]) {
1225         // Hover carries a node INDEX, so remap it by id across the rebuild
1226         // instead of clearing — hosts (the designer) re-sync nodes on EVERY
1227         // window event, so a clear here wipes the hover in the same event
1228         // pass that set it and port highlights never survive to a draw.
1229         // Exactly the id-remap `selected_idx` gets below.
1230         self.hovered_port = self.hovered_port.take().and_then(|(idx, pt, k)| {
1231             let id = &self.nodes.get(idx)?.id;
1232             let new_idx = nodes.iter().position(|n| &n.id == id)?;
1233             let count = match pt {
1234                 PortType::Input => nodes[new_idx].inputs,
1235                 PortType::Output => nodes[new_idx].outputs,
1236             };
1237             (k < count).then_some((new_idx, pt, k))
1238         });
1239         self.nodes = nodes.to_vec();
1240 
1241         // Sync selected_idx from selected_id
1242         if let Some(ref id) = self.selected_id {
1243             self.selected_idx = self.nodes.iter().position(|n| n.id == *id);
1244             if self.selected_idx.is_none() {
1245                 self.selected_id = None;
1246             }
1247         } else {
1248             self.selected_idx = None;
1249         }
1250 
1251         // Sync dragging_idx from dragging_id
1252         if let Some(ref id) = self.dragging_id {
1253             self.dragging_idx = self.nodes.iter().position(|n| n.id == *id);
1254             if self.dragging_idx.is_none() {
1255                 self.dragging_id = None;
1256                 self.drag_node_pos = None;
1257                 self.splice_target = None;
1258             }
1259         } else {
1260             self.dragging_idx = None;
1261             self.drag_node_pos = None;
1262             self.splice_target = None;
1263         }
1264 
1265         // double_clicked_id / double_click_timer survive deliberately: they
1266         // are keyed by node id, and clearing them here (as this used to)
1267         // guaranteed no double-click could ever complete — the host re-syncs
1268         // between the two presses. A stale id simply resolves to None.
1269         self.toggle_hovered_idx = None;
1270     }
1271     fn get_nodes(&self) -> Vec<GraphNode> { self.nodes.clone() }
1272     fn cell_corner_radius(&self) -> f32 { Graph::cell_corner_radius(self) }
1273     fn geometry_quads_tagged(&self, rect: Rect) -> Vec<TaggedQuad> { Graph::geometry_quads_tagged(self, rect) }
1274     fn drop_target_cell_rect(&self) -> Option<(f32, f32, f32, f32)> { Graph::drop_target_cell_rect(self) }
1275     fn selected_node(&self) -> Option<usize> { self.selected_idx }
1276     fn set_selected_node(&mut self, idx: Option<usize>) {
1277         self.selected_idx = idx;
1278         self.selected_id = idx.and_then(|i| self.nodes.get(i).map(|n| n.id.clone()));
1279     }
1280     fn double_clicked_node(&self) -> Option<usize> {
1281         self.double_clicked_id
1282             .as_ref()
1283             .and_then(|id| self.nodes.iter().position(|n| &n.id == id))
1284     }
1285     fn clear_double_clicked_node(&mut self) { self.double_clicked_id = None; }
1286     fn set_grid_snap_enabled(&mut self, enabled: bool) { self.grid_snap_enabled = enabled; }
1287     fn take_node_geom_toggle(&mut self) -> Option<(usize, bool)> { self.node_geom_toggled.take() }
1288     fn set_grid_snap(&mut self, gx: f32, gy: f32) { self.set_grid_sizes(gx, gy) }
1289     fn set_grid_pitch(&mut self, px: f32, py: f32) {
1290         self.pitch_x = px;
1291         self.pitch_y = py;
1292     }
1293     fn set_node_size(&mut self, w: f32, h: f32) { Graph::set_node_size(self, w, h) }
1294     /// Cell model: the cell is the node body, and the gap it had stays, so
1295     /// the two setters commute (a cell plus its gap is a pitch).
1296     fn set_grid_sizes(&mut self, gx: f32, gy: f32) {
1297         let (gap_row, gap_col) = self.skipped_sizes();
1298         self.set_node_size(gx, gy);
1299         self.pitch_x = gx + gap_col;
1300         self.pitch_y = gy + gap_row;
1301     }
1302     fn set_skipped_sizes(&mut self, row_h: f32, col_w: f32) {
1303         self.pitch_x = self.node_w + col_w;
1304         self.pitch_y = self.node_h + row_h;
1305     }
1306     fn set_grid_origin(&mut self, ox: f32, oy: f32) { self.grid_origin_x = ox; self.grid_origin_y = oy; }
1307     fn grid_origin(&self) -> (f32, f32) { (self.grid_origin_x, self.grid_origin_y) }
1308     fn set_show_network_grid(&mut self, show: bool) { self.show_network_grid = show; }
1309     fn take_pending_connection(&mut self) -> Option<(String, String)> {
1310         self.pending_connection.take()
1311     }
1312     fn take_pending_splice(&mut self) -> Option<(String, String, String)> {
1313         self.pending_splice.take()
1314     }
1315     fn cancel_connecting(&mut self) {
1316         self.connecting_from = None;
1317     }
1318     fn is_node_rect(&self, qx: f32, qy: f32, qw: f32, qh: f32) -> bool {
1319         self.is_node_rect(qx, qy, qw, qh)
1320     }
1321     fn node_at(&self, px: f32, py: f32) -> Option<usize> {
1322         self.node_at(px, py)
1323     }
1324 }
1325 
1326 #[cfg(test)]
1327 mod tests {
1328     use super::*;
1329     use crate::context::UiContext;
1330     use crate::widget::WidgetHost;
1331 
1332     /// A 100 x 60 lattice whose (0, 0) intersection is at (140, 120), so node
1333     /// a's 80 x 40 body is the rect (100, 100, 80, 40) and node b's, one cell
1334     /// down-right, is (200, 160, 80, 40).
1335     fn two_nodes() -> Adapted<Graph> {
1336         let mut g = Graph::new();
1337         WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
1338         g.set_grid_pitch(100.0, 60.0);
1339         g.set_node_size(80.0, 40.0);
1340         g.set_grid_origin(140.0, 120.0);
1341         g.set_grid_snap_enabled(true);
1342         let node = |id: &str, name: &str, col: f32, row: f32| GraphNode {
1343             id: id.into(),
1344             name: name.into(),
1345             position: (col, row),
1346             parameters: Vec::new(),
1347             geom_visible: true,
1348             node_type: String::new(),
1349             inputs: 1,
1350             outputs: 1,
1351         };
1352         g.set_nodes(&[node("a", "alpha", 0.0, 0.0), node("b", "beta", 1.0, 1.0)]);
1353         g
1354     }
1355 
1356     /// A node against the pane's right edge keeps its name: the label flips
1357     /// to the body's left when it would not fit on the right, whatever the
1358     /// name's length, and a node with room keeps the right-hand placement.
1359     #[test]
1360     fn a_node_at_the_right_edge_keeps_its_label_on_the_left() {
1361         let mut g = two_nodes();
1362         let node = |name: &str, col: f32| GraphNode {
1363             id: name.into(),
1364             name: name.into(),
1365             position: (col, 0.0),
1366             parameters: Vec::new(),
1367             geom_visible: true,
1368             node_type: String::new(),
1369             inputs: 1,
1370             outputs: 1,
1371         };
1372         // With the origin at 160, column 6's body spans 720..800 — flush
1373         // against the right edge of an 800 px pane, so a right-hand label
1374         // would BEGIN past the edge, which is exactly the screenshot that
1375         // found this. Column 2's spans 320..400: plenty of room.
1376         g.set_grid_origin(160.0, 120.0);
1377         g.set_nodes(&[node("w", 6.0), node("wrangle_with_a_long_name", 6.0), node("mid", 2.0)]);
1378         let rect = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
1379         let labels = g.node_labels(rect);
1380         assert_eq!(labels.len(), 3, "every node keeps a label: {:?}", labels.iter().map(|l| &l.text).collect::<Vec<_>>());
1381         let (nx, _, nw, _) = g.node_rect(0).unwrap();
1382         assert_eq!((nx, nx + nw), (720.0, 800.0));
1383         for name in ["w", "wrangle_with_a_long_name"] {
1384             let l = labels.iter().find(|l| l.text == name).unwrap();
1385             let w = TextLabel::estimate_width(name, l.font_size);
1386             assert!((l.x + w - (nx - 8.0)).abs() < 0.5, "{name} hangs off the left edge, right-aligned to it: x {} w {w}", l.x);
1387             assert!(l.x >= rect.x, "{name} stays inside the pane");
1388         }
1389         let mid = labels.iter().find(|l| l.text == "mid").unwrap();
1390         let (mx, _, mw, _) = g.node_rect(2).unwrap();
1391         assert_eq!(mid.x, mx + mw + 8.0, "a node with room keeps the right-hand placement");
1392     }
1393 
1394     /// A double-click's two presses always straddle a host node re-sync — the
1395     /// designer calls set_nodes on EVERY window event — so the detection state
1396     /// must survive set_nodes. It used to be wiped there wholesale, which made
1397     /// double-click structurally impossible outside unit tests.
1398     #[test]
1399     fn double_click_survives_the_between_press_node_resync() {
1400         let mut ctx = UiContext::new();
1401         let mut g = two_nodes();
1402         let (id, ptr) = (g.id(), g.as_ptr_mut());
1403         ctx.register_widget(id, ptr);
1404 
1405         // First press on node a, then the host re-syncs (same content),
1406         // then the second press: this is the real event sequence.
1407         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, &mut ctx));
1408         g.mouse_input(MouseButton::Left, ElementState::Released, 110.0, 120.0, &mut ctx);
1409         let nodes = g.get_nodes();
1410         g.set_nodes(&nodes);
1411         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, &mut ctx));
1412 
1413         assert_eq!(g.double_clicked_node(), Some(0), "double-click lost across set_nodes");
1414         g.clear_double_clicked_node();
1415         assert_eq!(g.double_clicked_node(), None);
1416     }
1417 
1418     /// Two presses on DIFFERENT nodes are not a double-click, id-keyed or not.
1419     #[test]
1420     fn presses_on_two_nodes_are_not_a_double_click() {
1421         let mut ctx = UiContext::new();
1422         let mut g = two_nodes();
1423         let (id, ptr) = (g.id(), g.as_ptr_mut());
1424         ctx.register_widget(id, ptr);
1425 
1426         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, &mut ctx));
1427         g.mouse_input(MouseButton::Left, ElementState::Released, 110.0, 120.0, &mut ctx);
1428         // Node b sits one grid step down-right of a.
1429         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 210.0, 180.0, &mut ctx));
1430         assert_eq!(g.double_clicked_node(), None);
1431     }
1432 
1433     #[test]
1434     fn node_press_selects_arms_drag_and_commit_snaps_to_grid() {
1435         let mut ctx = UiContext::new();
1436         let mut g = two_nodes();
1437         let (id, ptr) = (g.id(), g.as_ptr_mut());
1438         ctx.register_widget(id, ptr);
1439 
1440         // Node a occupies (100, 100, 80, 40). Press its body (away from ports/toggle).
1441         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, &mut ctx));
1442         assert_eq!(g.selected_node(), Some(0));
1443         assert!(g.is_dragging() && g.draggable());
1444 
1445         // Drag one pitch right (pitch_x = 100): snap puts the node at column 1, and cell
1446         // (1, 0) is free so it lands there.
1447         g.drag_begin(110.0, 120.0);
1448         assert!(g.drag_update(210.0, 120.0));
1449         assert!(g.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 120.0, &mut ctx));
1450         assert!(!g.is_dragging());
1451         assert_eq!(g.get_nodes()[0].position, (1.0, 0.0));
1452 
1453         // An empty-space press clears the selection and is NOT consumed (legacy contract).
1454         assert!(!g.mouse_input(MouseButton::Left, ElementState::Pressed, 700.0, 550.0, &mut ctx));
1455         assert_eq!(g.selected_node(), None);
1456     }
1457 
1458     /// Dropping a dragged node onto a wire splices it in: the drop reports
1459     /// (dragged id, the wire's upstream NAME, the wire's downstream id) for
1460     /// the host to rewire both Input params. A drop away from every wire
1461     /// reports nothing, and the handshake is take-once.
1462     #[test]
1463     fn node_dropped_on_a_wire_reports_a_splice() {
1464         let mut ctx = UiContext::new();
1465         let mut g = Graph::new();
1466         WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
1467         g.set_grid_pitch(100.0, 60.0);
1468         g.set_node_size(80.0, 40.0);
1469         g.set_grid_origin(140.0, 120.0);
1470         g.set_grid_snap_enabled(true);
1471         let node = |id: &str, name: &str, col: f32, row: f32, params: Vec<(String, String, String)>| GraphNode {
1472             id: id.into(),
1473             name: name.into(),
1474             position: (col, row),
1475             parameters: params,
1476             geom_visible: true,
1477             node_type: String::new(),
1478             inputs: 1,
1479             outputs: 1,
1480         };
1481         let p = |v: &str| vec![("Input".to_string(), v.to_string(), "text".to_string())];
1482         // alpha → beta wire runs through the empty cell (1, 0) between them;
1483         // gamma sits below, unwired.
1484         g.set_nodes(&[
1485             node("a", "alpha", 0.0, 0.0, Vec::new()),
1486             node("b", "beta", 2.0, 0.0, p("alpha")),
1487             node("c", "gamma", 0.0, 2.0, p("")),
1488         ]);
1489         let (id, ptr) = (g.id(), g.as_ptr_mut());
1490         ctx.register_widget(id, ptr);
1491 
1492         // Drag gamma's body onto the wire's horizontal run (cell (1, 0)).
1493         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 240.0, &mut ctx));
1494         g.drag_begin(110.0, 240.0);
1495         assert!(g.drag_update(210.0, 140.0));
1496         assert!(g.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 140.0, &mut ctx));
1497 
1498         let splice = GraphController::take_pending_splice(&mut *g);
1499         assert_eq!(
1500             splice,
1501             Some(("c".to_string(), "alpha".to_string(), "b".to_string())),
1502             "drop on the wire must report (dragged, upstream name, downstream id)"
1503         );
1504         assert_eq!(GraphController::take_pending_splice(&mut *g), None, "take-once");
1505 
1506         // A drop in open space reports nothing. Gamma landed in cell (1, 0)
1507         // — the free cell its splice drop resolved to — so drag it from
1508         // there down to open space clear of the wire.
1509         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 210.0, 110.0, &mut ctx));
1510         g.drag_begin(210.0, 110.0);
1511         assert!(g.drag_update(210.0, 230.0));
1512         assert!(g.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 230.0, &mut ctx));
1513         assert_eq!(GraphController::take_pending_splice(&mut *g), None);
1514     }
1515 
1516     /// The grid has one size per axis, the pitch, and a node is CENTRED on
1517     /// the intersection its position names — its body straddles the lines
1518     /// rather than filling a cell between them. The body's size is its own:
1519     /// changing the pitch moves nodes apart without resizing them.
1520     #[test]
1521     fn nodes_are_centred_on_lattice_intersections() {
1522         let mut g = two_nodes();
1523         assert_eq!(g.grid_pitch(), (100.0, 60.0));
1524         assert_eq!(g.node_size(), (80.0, 40.0));
1525         g.set_grid_pitch(200.0, 90.0);
1526         assert_eq!(g.node_size(), (80.0, 40.0), "the pitch does not size the node");
1527         g.set_grid_pitch(100.0, 60.0);
1528         // Node a is on the (140, 120) intersection: its 80 x 40 body is
1529         // centred there.
1530         let (x, y, w, h) = g.node_rect(0).unwrap();
1531         assert_eq!((x + w / 2.0, y + h / 2.0), (140.0, 120.0));
1532         assert_eq!((x, y, w, h), (100.0, 100.0, 80.0, 40.0));
1533         // Node b, at (1, 1), is one pitch away along each axis.
1534         let (x, y, w, h) = g.node_rect(1).unwrap();
1535         assert_eq!((x + w / 2.0, y + h / 2.0), (240.0, 180.0));
1536     }
1537 
1538     /// The cell-and-gap setters describe the same lattice — a cell plus its
1539     /// gap is a pitch, and the cell is the node body — in either order, so a
1540     /// host still speaking them gets exactly the geometry it asked for.
1541     #[test]
1542     fn cell_and_gap_setters_describe_the_same_lattice() {
1543         let mut g = Graph::new();
1544         g.set_grid_sizes(140.0, 70.0);
1545         g.set_skipped_sizes(35.0, 35.0);
1546         assert_eq!(g.grid_pitch(), (175.0, 105.0));
1547         assert_eq!(g.node_size(), (140.0, 70.0));
1548         assert_eq!(g.grid_sizes(), (140.0, 70.0));
1549         assert_eq!(g.skipped_sizes(), (35.0, 35.0));
1550 
1551         let mut h = Graph::new();
1552         h.set_skipped_sizes(35.0, 35.0);
1553         h.set_grid_sizes(140.0, 70.0);
1554         assert_eq!(h.grid_pitch(), (175.0, 105.0));
1555         assert_eq!(h.node_size(), (140.0, 70.0));
1556     }
1557 
1558     #[test]
1559     fn port_click_starts_and_completes_a_connection() {
1560         let mut ctx = UiContext::new();
1561         let mut g = two_nodes();
1562         let (id, ptr) = (g.id(), g.as_ptr_mut());
1563         ctx.register_widget(id, ptr);
1564 
1565         // Ports float OUTSIDE the node box (port_center): node a's output
1566         // hangs below the bottom-center of (100,100,80,40), node b's input
1567         // above the top-center of (200,160,80,40).
1568         let (ax, ay) = g.port_center(0, PortType::Output, 0).expect("node a output port");
1569         assert!(ay > 140.0, "output port sits below the node's bottom edge");
1570         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, ax, ay, &mut ctx));
1571         let (bx, by) = g.port_center(1, PortType::Input, 0).expect("node b input port");
1572         assert!(by < 160.0, "input port sits above the node's top edge");
1573         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, bx, by, &mut ctx));
1574 
1575         let pending = GraphController::take_pending_connection(&mut *g);
1576         assert_eq!(pending, Some(("b".to_string(), "alpha".to_string())));
1577     }
1578 
1579     #[test]
1580     fn dual_geometry_views_stay_consistent() {
1581         let mut g = two_nodes();
1582         let ctx = UiContext::new();
1583 
1584         // The plain view (designer path) and the rounded view (render_widget path) describe
1585         // the same quads: the rounded view adds only the widget background entry up front.
1586         let plain = WidgetHost::extra_quads(&g);
1587         let rounded = WidgetHost::all_rounded_quads(&g, &ctx);
1588         assert!(!plain.is_empty());
1589         assert_eq!(rounded.len(), plain.len() + 1);
1590         for ((px, py, pw, ph, pc), (rx, ry, rw, rh, _, rc, _)) in plain.iter().zip(rounded.iter().skip(1)) {
1591             assert_eq!((px, py, pw, ph, pc), (rx, ry, rw, rh, rc));
1592         }
1593 
1594         // Node bodies carry the node corner radius in the rounded view.
1595         let node_radius = crate::layout::graph_node_corner_radius();
1596         let node_entries: Vec<_> = rounded
1597             .iter()
1598             .filter(|(qx, qy, qw, qh, ..)| g.is_node_rect(*qx, *qy, *qw, *qh))
1599             .collect();
1600         assert_eq!(node_entries.len(), 2, "both node bodies present");
1601         for entry in node_entries {
1602             assert_eq!(entry.4, node_radius);
1603             assert_eq!(entry.6, (true, true, true, true));
1604         }
1605 
1606         // And `all_quads` stays empty so render_widget hosts (reading BOTH getters) never
1607         // draw the geometry twice — the legacy Graph override's contract.
1608         assert!(WidgetHost::all_quads(&g, &ctx).is_empty());
1609     }
1610 }