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

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

   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 //! [`Paint::paint`] emits the graph as rounded geometry (what `render_widget` hosts — cce-files —
   6 //! and the scene walk — cce-graph — consume); a host that draws the nodes in its own order (the
   7 //! designer) reads [`Graph::geometry_quads_tagged`] and calls [`Graph::paint_wires`] itself. Node-name
   8 //! text is clipped to the widget rect via [`Paint::text_bounds`]. All grid geometry is in absolute screen space
   9 //! (hosts pan by moving `grid_origin`); the widget rect only culls and clips.
  10 //!
  11 //! The WIRES are not quads: they are strokes in a [`WireStyle`] — orthogonal, rounded,
  12 //! bezier or straight — painted by [`Graph::paint_wires`], which `paint` calls after the
  13 //! grid and a host drawing the quads itself calls in the same place. Their colour, width
  14 //! and style are `wire_color`, `wire_size` and `wire_style` under
  15 //! `style.surface.graph.node`; until 2026-09-30 the first two were parsed and never read.
  16 //!
  17 //! The grid is a LATTICE OF LINES with one size per axis — the pitch, from the centre of
  18 //! one line to the centre of the next — and a node is centred on the intersection its
  19 //! `position` names: node (c, r) sits on `grid_origin + (c * pitch_x, r * pitch_y)`. The
  20 //! node body has a size of its own (`graph_node_width` / `graph_node_height`, scaled with
  21 //! 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,
  22 //! with a node filling its cell — and the cell-and-gap setters survive as a description of
  23 //! the same lattice for hosts that still speak it (a cell plus its gap is a pitch).
  24 
  25 use crate::colors;
  26 use crate::scene::layout::Rect;
  27 use crate::scene::paint::PaintCtx;
  28 use crate::widget::display::TextLabel;
  29 use crate::widget::{
  30     Adapted, ElementState, Event, EventCtx, GraphController, Input, Key, Layout, MouseButton,
  31     MouseScrollDelta, Paint,
  32 };
  33 
  34 #[derive(Clone, Copy, Debug, PartialEq)]
  35 pub enum PortType {
  36     Input,
  37     Output,
  38 }
  39 
  40 fn default_outputs() -> usize { 1 }
  41 
  42 /// One flat-geometry quad `(x, y, w, h, color, cell)` from
  43 /// [`Graph::geometry_quads_tagged`]: `cell` is `Some(corner flags)` for a grid
  44 /// cell — per-corner `(tl, tr, br, bl)` rounding that survived the pane clip —
  45 /// and `None` for everything else (wires, gaps, axes, nodes, toggles).
  46 pub type TaggedQuad = (f32, f32, f32, f32, [f32; 4], Option<(bool, bool, bool, bool)>);
  47 
  48 /// How a wire runs from an output port (the bottom of its node) to an input
  49 /// port (the top of the next): `style.surface.graph.node.wire_style` in
  50 /// config.kdl, by [`WireStyle::name`], unless a host sets one of its own
  51 /// ([`Graph::set_wire_style`]). Every style is drawn and hit-tested from the
  52 /// one path [`wire_path`] derives, so a splice drop lands on the wire you see.
  53 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
  54 pub enum WireStyle {
  55     /// Down, across at half the height, down: three straight runs meeting
  56     /// square. What every wire was before there was a choice.
  57     #[default]
  58     Orthogonal,
  59     /// The same three runs with their two bends rounded off.
  60     Rounded,
  61     /// One cubic curve that leaves the output heading down and arrives at
  62     /// the input heading down, so a wire running back up the graph loops.
  63     Bezier,
  64     /// One straight line, port to port.
  65     Straight,
  66 }
  67 
  68 impl WireStyle {
  69     pub const ALL: [WireStyle; 4] = [WireStyle::Orthogonal, WireStyle::Rounded, WireStyle::Bezier, WireStyle::Straight];
  70 
  71     /// The config spelling.
  72     pub fn name(self) -> &'static str {
  73         match self {
  74             WireStyle::Orthogonal => "orthogonal",
  75             WireStyle::Rounded => "rounded",
  76             WireStyle::Bezier => "bezier",
  77             WireStyle::Straight => "straight",
  78         }
  79     }
  80 
  81     /// The spelling a menu shows.
  82     pub fn label(self) -> &'static str {
  83         match self {
  84             WireStyle::Orthogonal => "Orthogonal",
  85             WireStyle::Rounded => "Rounded",
  86             WireStyle::Bezier => "Bezier",
  87             WireStyle::Straight => "Straight",
  88         }
  89     }
  90 
  91     /// Either spelling, any case.
  92     pub fn parse(s: &str) -> Option<WireStyle> {
  93         let s = s.trim();
  94         WireStyle::ALL.into_iter().find(|w| w.name().eq_ignore_ascii_case(s))
  95     }
  96 
  97     /// The configured style; orthogonal when the key is absent or names
  98     /// no style.
  99     pub fn configured() -> WireStyle {
 100         crate::layout::graph_wire_style().as_deref().and_then(WireStyle::parse).unwrap_or_default()
 101     }
 102 }
 103 
 104 /// One piece of a wire's path, in window px: a straight run, or an arc about
 105 /// a centre at a CENTRELINE radius from one angle to another (radians,
 106 /// screen space, so y runs down).
 107 #[derive(Clone, Copy, Debug, PartialEq)]
 108 enum WireSeg {
 109     Line((f32, f32), (f32, f32)),
 110     Arc { c: (f32, f32), r: f32, a0: f32, a1: f32 },
 111 }
 112 
 113 /// One DEVICE pixel in logical px.
 114 fn device_px() -> f32 {
 115     1.0 / crate::scale::scale_factor().max(1.0)
 116 }
 117 
 118 /// A wire of `size` logical px as it is drawn: (stroke width, alpha). The
 119 /// thinnest stroke is one device pixel (`px`), because the 2D pass has no
 120 /// antialiasing — an axis-aligned quad narrower than a pixel covers a row of
 121 /// pixel centres or none, and the wire would come and go as it moved. A
 122 /// thinner wire is drawn as that pixel at the share of it the wire would
 123 /// cover, so it reads thinner by reading fainter.
 124 fn wire_stroke(size: f32, px: f32) -> (f32, f32) {
 125     let px = px.max(f32::EPSILON);
 126     (size.max(px), (size / px).clamp(0.0, 1.0))
 127 }
 128 
 129 /// The path of a wire from `start` to `end` drawn `t` px thick. `bend` is
 130 /// the largest radius a Rounded bend takes and the least a Bezier lead
 131 /// runs straight down before curving — both scale with the node, so the
 132 /// shape keeps its proportions under zoom. `turn` is the height of an
 133 /// Orthogonal or Rounded wire's run across ([`Graph::wire_turn_y`]); `None`
 134 /// is halfway between the two ends.
 135 fn wire_path(style: WireStyle, start: (f32, f32), end: (f32, f32), t: f32, bend: f32, turn: Option<f32>) -> Vec<WireSeg> {
 136     let ((sx, sy), (ex, ey)) = (start, end);
 137     let my = turn.unwrap_or(sy + (ey - sy) / 2.0);
 138     let orthogonal = || {
 139         // Square joins with no overlap, so a translucent wire is one alpha
 140         // throughout: the across run is widened by half a thickness at each
 141         // end to fill the corners, and the down runs stop at its edge.
 142         let s = if ey >= sy { 1.0 } else { -1.0 };
 143         let mut out = Vec::with_capacity(3);
 144         let v1_end = my - s * t / 2.0;
 145         if s * (v1_end - sy) > 0.0 {
 146             out.push(WireSeg::Line((sx, sy), (sx, v1_end)));
 147         }
 148         out.push(WireSeg::Line((sx.min(ex) - t / 2.0, my), (sx.max(ex) + t / 2.0, my)));
 149         let v2_start = my + s * t / 2.0;
 150         if s * (ey - v2_start) > 0.0 {
 151             out.push(WireSeg::Line((ex, v2_start), (ex, ey)));
 152         }
 153         out
 154     };
 155     match style {
 156         WireStyle::Straight => vec![WireSeg::Line(start, end)],
 157         WireStyle::Orthogonal => orthogonal(),
 158         WireStyle::Rounded => {
 159             let (dx, dy) = (ex - sx, ey - sy);
 160             if dx.abs() < 0.5 {
 161                 return vec![WireSeg::Line(start, end)];
 162             }
 163             // The bends fit the legs they turn between: the run across
 164             // need not be halfway down.
 165             let r = bend.min(dx.abs() / 2.0).min((my - sy).abs()).min((ey - my).abs());
 166             // A bend tighter than half the stroke has no inside edge.
 167             if r < t / 2.0 {
 168                 return orthogonal();
 169             }
 170             // Bend at (bx, by) turning from heading u1 to heading u2.
 171             let bend_at = |bx: f32, by: f32, u1: (f32, f32), u2: (f32, f32)| {
 172                 let t1 = (bx - u1.0 * r, by - u1.1 * r);
 173                 let t2 = (bx + u2.0 * r, by + u2.1 * r);
 174                 let c = (t1.0 + u2.0 * r, t1.1 + u2.1 * r);
 175                 let a0 = (t1.1 - c.1).atan2(t1.0 - c.0);
 176                 let a1 = (t2.1 - c.1).atan2(t2.0 - c.0);
 177                 let mut sweep = a1 - a0;
 178                 if sweep > std::f32::consts::PI {
 179                     sweep -= std::f32::consts::TAU;
 180                 } else if sweep < -std::f32::consts::PI {
 181                     sweep += std::f32::consts::TAU;
 182                 }
 183                 (t1, t2, WireSeg::Arc { c, r, a0, a1: a0 + sweep })
 184             };
 185             let down = (0.0, dy.signum());
 186             let across = (dx.signum(), 0.0);
 187             let (p1a, p1b, arc1) = bend_at(sx, my, down, across);
 188             let (p2a, p2b, arc2) = bend_at(ex, my, across, down);
 189             vec![
 190                 WireSeg::Line(start, p1a),
 191                 arc1,
 192                 WireSeg::Line(p1b, p2a),
 193                 arc2,
 194                 WireSeg::Line(p2b, end),
 195             ]
 196         }
 197         WireStyle::Bezier => {
 198             let lead = ((ey - sy).abs() * 0.5).max(bend);
 199             let (c1, c2) = ((sx, sy + lead), (ex, ey - lead));
 200             // Fine enough that the flat-capped pieces meet without a visible
 201             // notch: about one piece per 6 px of the control net.
 202             let net = lead * 2.0 + ((c2.0 - c1.0).powi(2) + (c2.1 - c1.1).powi(2)).sqrt();
 203             let n = ((net / 6.0).ceil() as usize).clamp(8, 96);
 204             let at = |u: f32| {
 205                 let v = 1.0 - u;
 206                 let (a, b, c, d) = (v * v * v, 3.0 * v * v * u, 3.0 * v * u * u, u * u * u);
 207                 (a * sx + b * c1.0 + c * c2.0 + d * ex, a * sy + b * c1.1 + c * c2.1 + d * ey)
 208             };
 209             let mut prev = start;
 210             (1..=n)
 211                 .map(|i| {
 212                     let p = at(i as f32 / n as f32);
 213                     let seg = WireSeg::Line(prev, p);
 214                     prev = p;
 215                     seg
 216                 })
 217                 .collect()
 218         }
 219     }
 220 }
 221 
 222 /// Whether the segment a→b passes through the rect (x1, y1)-(x2, y2):
 223 /// Liang–Barsky clipping of the segment to the rect.
 224 fn segment_meets_rect(a: (f32, f32), b: (f32, f32), x1: f32, y1: f32, x2: f32, y2: f32) -> bool {
 225     let (dx, dy) = (b.0 - a.0, b.1 - a.1);
 226     let (mut lo, mut hi) = (0.0f32, 1.0f32);
 227     for (p, q) in [(-dx, a.0 - x1), (dx, x2 - a.0), (-dy, a.1 - y1), (dy, y2 - a.1)] {
 228         if p == 0.0 {
 229             if q < 0.0 {
 230                 return false;
 231             }
 232         } else {
 233             let r = q / p;
 234             if p < 0.0 {
 235                 lo = lo.max(r);
 236             } else {
 237                 hi = hi.min(r);
 238             }
 239             if lo > hi {
 240                 return false;
 241             }
 242         }
 243     }
 244     true
 245 }
 246 
 247 /// The nodes `node` reads, one per input port in order: the values of its
 248 /// parameters of type `node`, else its parameter named `input` alone (see
 249 /// `Graph::wire_pairs`). Empty is a port with nothing wired to it.
 250 pub fn node_wires(node: &GraphNode) -> Vec<String> {
 251     let typed: Vec<String> = node
 252         .parameters
 253         .iter()
 254         .filter(|(_, _, ty)| ty == "node")
 255         .map(|(_, value, _)| value.trim().to_string())
 256         .collect();
 257     if !typed.is_empty() {
 258         return typed;
 259     }
 260     node.parameters
 261         .iter()
 262         .find(|(name, _, _)| name.eq_ignore_ascii_case("input"))
 263         .map(|(_, value, _)| vec![value.clone()])
 264         .unwrap_or_default()
 265 }
 266 
 267 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
 268 pub struct GraphNode {
 269     #[serde(default)]
 270     pub id: String,
 271     pub name: String,
 272     pub position: (f32, f32), // (column, row)
 273     pub parameters: Vec<(String, String, String)>, // (name, value, type)
 274     pub geom_visible: bool,
 275     #[serde(default)]
 276     pub node_type: String,
 277     #[serde(default)]
 278     pub inputs: usize,
 279     #[serde(default = "default_outputs")]
 280     pub outputs: usize,
 281 }
 282 
 283 /// The widget's own corner style: the legacy `WidgetHost` defaults it inherited
 284 /// (`corner_radius` 12.0, bottom corners rounded).
 285 const WIDGET_RADIUS: f32 = 12.0;
 286 const WIDGET_CORNERS: (bool, bool, bool, bool) = (false, false, true, true);
 287 
 288 pub struct Graph {
 289     show_network_grid: bool,
 290     /// Whether nodes wear the geometry toggle (see `toggle_rect`). A host
 291     /// whose nodes have no geometry to show (cce-files' directory graph)
 292     /// turns it off with `set_show_toggles(false)`.
 293     show_toggles: bool,
 294     /// The pitch: centre of one grid line to the centre of the next, per
 295     /// axis. The grid's one size.
 296     pitch_x: f32,
 297     pitch_y: f32,
 298     /// The node body's size — its own (`graph_node_width` / `_height` at
 299     /// 100%), independent of the pitch; the cell-model setters set it too.
 300     node_w: f32,
 301     node_h: f32,
 302     /// The lattice intersection node (0, 0) is centred on, window-absolute.
 303     grid_origin_x: f32,
 304     grid_origin_y: f32,
 305     /// Smooth-scroll driver behind the pan origin: notches glide, a trackpad
 306     /// flick coasts across the unbounded canvas.
 307     pan_motion: crate::widget::ScrollMotion,
 308     nodes: Vec<GraphNode>,
 309     selected_idx: Option<usize>,
 310     selected_id: Option<String>,
 311     double_clicked_id: Option<String>,
 312     /// Keyed by node ID, not index: hosts (the designer) re-sync nodes on
 313     /// EVERY window event, and set_nodes used to wipe this state wholesale —
 314     /// the first press's timer never survived to the second press, so
 315     /// double-click detection could not fire at all. Same id-keyed survival
 316     /// as `selected_id` and the hovered-port remap.
 317     double_click_timer: Option<(web_time::Instant, String)>,
 318     grid_snap_enabled: bool,
 319     node_geom_toggled: Option<(usize, bool)>,
 320 
 321     // For dragging a node
 322     dragging_idx: Option<usize>,
 323     dragging_id: Option<String>,
 324     drag_ox: f32,
 325     drag_oy: f32,
 326     pub(crate) drag_node_pos: Option<(f32, f32)>,
 327 
 328     // Hover tracking
 329     toggle_hovered_idx: Option<usize>,
 330 
 331     network_opacity: f32,
 332     /// Node-domain opacity (bodies, wires, connectors) — independent of
 333     /// `network_opacity`, which fades the pane surface (grid cells/gaps).
 334     node_opacity: f32,
 335     grid_color: [f32; 3],
 336 
 337     // Connection state
 338     connecting_from: Option<(usize, PortType, usize)>,
 339     current_mouse_pos: (f32, f32),
 340     /// A connection finished by the pointer: (input node id, output node
 341     /// name, the input PORT it was dropped on).
 342     pending_connection: Option<(String, String, usize)>,
 343     hovered_port: Option<(usize, PortType, usize)>,
 344 
 345     /// The wire the in-flight node drag would splice into, as (src node id,
 346     /// dest node id) — ids, not indices, because hosts re-sync nodes on
 347     /// every window event and an index would go stale between drag_update
 348     /// and the release (the hovered_port lesson). Drawn highlighted while it
 349     /// holds; resolved into `pending_splice` on drop.
 350     splice_target: Option<(String, String)>,
 351     /// A completed splice drop for the host: (dragged node id, the wire's
 352     /// upstream node NAME — what Input params store, the wire's downstream
 353     /// node id). The host rewires: dragged.Input = upstream name,
 354     /// downstream.Input = dragged's name.
 355     pending_splice: Option<(String, String, String)>,
 356     /// Whether a node dropped onto another node SWAPS with it
 357     /// ([`Graph::set_swap_on_drop`]); off, it is walked to the nearest free
 358     /// cell, as it always was.
 359     swap_on_drop: bool,
 360     /// The node the in-flight drag would swap with — the one standing on
 361     /// the cell the ghost is over — by id, as `splice_target` is held.
 362     swap_target: Option<String>,
 363     /// A completed swap drop for the host: (dragged node id, the other
 364     /// node's id). The two have traded cells already; the host trades
 365     /// whatever else it keeps — wires, for the designer.
 366     pending_swap: Option<(String, String)>,
 367     /// The host's choice of wire style; `None` follows the config.
 368     wire_style: Option<WireStyle>,
 369 }
 370 
 371 impl Graph {
 372     pub fn new() -> Adapted<Graph> {
 373         crate::layout::lazy_init_style_registry();
 374 
 375         let pitch_x = crate::layout::graph_spacing_x();
 376         let pitch_y = crate::layout::graph_spacing_y();
 377         let node_w = crate::layout::graph_node_width();
 378         let node_h = crate::layout::graph_node_height();
 379         let grid_snap_enabled = crate::layout::graph_grid_snap();
 380 
 381         let grid_col = crate::color::graph_grid_color();
 382 
 383         Adapted::new(Graph {
 384             show_network_grid: false,
 385             show_toggles: true,
 386             pitch_x,
 387             pitch_y,
 388             node_w,
 389             node_h,
 390             grid_origin_x: 0.0,
 391             grid_origin_y: 0.0,
 392             pan_motion: crate::widget::ScrollMotion::new(),
 393             nodes: Vec::new(),
 394             selected_idx: None,
 395             selected_id: None,
 396             double_clicked_id: None,
 397             double_click_timer: None,
 398             grid_snap_enabled,
 399             node_geom_toggled: None,
 400             dragging_idx: None,
 401             dragging_id: None,
 402             drag_ox: 0.0,
 403             drag_oy: 0.0,
 404             drag_node_pos: None,
 405             toggle_hovered_idx: None,
 406             network_opacity: crate::color::graph_opacity(),
 407             node_opacity: crate::color::graph_node_opacity(),
 408             grid_color: grid_col,
 409             connecting_from: None,
 410             current_mouse_pos: (0.0, 0.0),
 411             pending_connection: None,
 412             hovered_port: None,
 413             splice_target: None,
 414             pending_splice: None,
 415             swap_on_drop: false,
 416             swap_target: None,
 417             pending_swap: None,
 418             wire_style: None,
 419         })
 420     }
 421 
 422     pub fn set_network_opacity(&mut self, opacity: f32) {
 423         self.network_opacity = opacity;
 424     }
 425     /// Show or hide every node's geometry toggle — the disc at a node's
 426     /// right end, and its hit target with it.
 427     pub fn set_show_toggles(&mut self, show: bool) {
 428         self.show_toggles = show;
 429     }
 430     pub fn set_node_opacity(&mut self, opacity: f32) {
 431         self.node_opacity = opacity;
 432     }
 433     /// The pitch: centre of one grid line to the centre of the next, per axis.
 434     pub fn grid_pitch(&self) -> (f32, f32) {
 435         (self.pitch_x, self.pitch_y)
 436     }
 437     /// The node body's size at the current zoom.
 438     pub fn node_size(&self) -> (f32, f32) {
 439         (self.node_w, self.node_h)
 440     }
 441     /// Set the node body's size — what the cell-model setters do too, since
 442     /// there the cell IS the node.
 443     pub fn set_node_size(&mut self, w: f32, h: f32) {
 444         self.node_w = w;
 445         self.node_h = h;
 446     }
 447     /// The cell-model view of the lattice: the node body (its "cell").
 448     pub fn grid_sizes(&self) -> (f32, f32) {
 449         (self.node_w, self.node_h)
 450     }
 451     /// The cell-model view of the lattice: what a pitch has beyond the node
 452     /// body, as (row gap, column gap) — the order `set_skipped_sizes` takes.
 453     pub fn skipped_sizes(&self) -> (f32, f32) {
 454         (self.pitch_y - self.node_h, self.pitch_x - self.node_w)
 455     }
 456     pub fn grid_origin(&self) -> (f32, f32) {
 457         (self.grid_origin_x, self.grid_origin_y)
 458     }
 459     pub fn grid_snap_enabled(&self) -> bool {
 460         self.grid_snap_enabled
 461     }
 462     pub fn set_grid_color(&mut self, color: [f32; 3]) {
 463         self.grid_color = color;
 464     }
 465 
 466     /// The top-left corner of a node body centred on lattice cell (col, row).
 467     fn cell_origin(&self, col: f32, row: f32) -> (f32, f32) {
 468         (
 469             self.grid_origin_x + col * self.pitch_x - self.node_w * 0.5,
 470             self.grid_origin_y + row * self.pitch_y - self.node_h * 0.5,
 471         )
 472     }
 473 
 474     /// The lattice cell whose intersection is nearest the CENTRE of a node
 475     /// body whose top-left is (nx, ny) — the one snapping rule, shared by the
 476     /// drag preview, the drop-target highlight and the drop itself. None on a
 477     /// degenerate pitch.
 478     fn nearest_cell(&self, nx: f32, ny: f32) -> Option<(f32, f32)> {
 479         if self.pitch_x <= 0.0 || self.pitch_y <= 0.0 {
 480             return None;
 481         }
 482         let c = ((nx + self.node_w * 0.5 - self.grid_origin_x) / self.pitch_x).round();
 483         let r = ((ny + self.node_h * 0.5 - self.grid_origin_y) / self.pitch_y).round();
 484         Some((c, r))
 485     }
 486 
 487     pub fn node_rect(&self, idx: usize) -> Option<(f32, f32, f32, f32)> {
 488         let node = self.nodes.get(idx)?;
 489         let at_cell = self.cell_origin(node.position.0, node.position.1);
 490         let (nx, ny) = if self.dragging_idx == Some(idx) {
 491             self.drag_node_pos.unwrap_or(at_cell)
 492         } else {
 493             at_cell
 494         };
 495         Some((nx, ny, self.node_w, self.node_h))
 496     }
 497 
 498     /// The rect the in-flight node drag will deposit its body on — hosts
 499     /// highlight it as the drop target. Runs the SAME resolution as
 500     /// `commit_drag` (nearest intersection, then `find_empty_cell` walks off
 501     /// occupied ones), so the highlight never lies about where the node
 502     /// actually lands. None outside a node drag.
 503     pub fn drop_target_cell_rect(&self) -> Option<(f32, f32, f32, f32)> {
 504         let idx = self.dragging_idx?;
 505         let (nx, ny) = self.drag_node_pos?;
 506         let (c, r) = self.nearest_cell(nx, ny)?;
 507         // A swap lands ON the other node's cell; anything else walks off
 508         // an occupied one.
 509         let (c, r) = if self.swap_target_idx().is_some() { (c, r) } else { self.find_empty_cell(c, r, Some(idx)) };
 510         let (x, y) = self.cell_origin(c, r);
 511         Some((x, y, self.node_w, self.node_h))
 512     }
 513 
 514     pub fn is_node_rect(&self, qx: f32, qy: f32, qw: f32, qh: f32) -> bool {
 515         for i in 0..self.nodes.len() {
 516             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
 517                 if (qx - nx).abs() < 0.1 && (qy - ny).abs() < 0.1 && (qw - nw).abs() < 0.1 && (qh - nh).abs() < 0.1 {
 518                     return true;
 519                 }
 520             }
 521         }
 522         false
 523     }
 524 
 525     /// The topmost node whose body contains (px, py), in the same
 526     /// window-absolute space `node_rect` reports (grid_origin = pane + pan).
 527     /// Reverse order so a later-drawn node wins where bodies overlap.
 528     pub fn node_at(&self, px: f32, py: f32) -> Option<usize> {
 529         for i in (0..self.nodes.len()).rev() {
 530             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
 531                 if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
 532                     return Some(i);
 533                 }
 534             }
 535         }
 536         None
 537     }
 538 
 539     /// How far a port's center floats off its node edge: the connector's own
 540     /// radius plus a small gap, so the circle sits fully OUTSIDE the node's
 541     /// bounding box rather than straddling its border.
 542     fn port_offset(scale_f: f32) -> f32 {
 543         (crate::layout::graph_connector_size() * scale_f).max(2.0) / 2.0 + 2.0 * scale_f
 544     }
 545 
 546     /// A port's center in graph coordinates — the ONE source for drawing,
 547     /// hover, click hit-testing, and wire endpoints, so they cannot drift.
 548     /// Inputs float above the node's top edge, outputs below its bottom.
 549     pub fn port_center(&self, idx: usize, port_type: PortType, k: usize) -> Option<(f32, f32)> {
 550         let (nx, ny, nw, nh) = self.node_rect(idx)?;
 551         let node = self.nodes.get(idx)?;
 552         let offset = Self::port_offset(nw / 80.0);
 553         match port_type {
 554             PortType::Input => (k < node.inputs)
 555                 .then(|| (nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32, ny - offset)),
 556             PortType::Output => (k < node.outputs)
 557                 .then(|| (nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32, ny + nh + offset)),
 558         }
 559     }
 560 
 561     pub fn toggle_rect(&self, idx: usize) -> Option<(f32, f32, f32, f32)> {
 562         if !self.show_toggles {
 563             return None;
 564         }
 565         if let Some(node) = self.nodes.get(idx) {
 566             // Settings containers have no geometry to toggle: utility nodes,
 567             // the designer's session node that now nests them, and the
 568             // per-node meta (preferences) node.
 569             if node.node_type == "utility" || node.node_type == "session" || node.node_type == "meta" {
 570                 return None;
 571             }
 572         }
 573         let (nx, ny, nw, nh) = self.node_rect(idx)?;
 574         let scale_f = nw / 80.0;
 575         let size = (18.0 * scale_f).clamp(6.0, 50.0);
 576         Some((nx + nw - size - 6.0 * scale_f, ny + (nh - size) / 2.0, size, size))
 577     }
 578 
 579     fn find_empty_cell(&self, start_x: f32, start_y: f32, skip_idx: Option<usize>) -> (f32, f32) {
 580         let x = start_x;
 581         let mut y = start_y;
 582         loop {
 583             let occupied = self.nodes.iter().enumerate().any(|(idx, node)| {
 584                 if Some(idx) == skip_idx {
 585                     false
 586                 } else {
 587                     (node.position.0 - x).abs() < 0.01 && (node.position.1 - y).abs() < 0.01
 588                 }
 589             });
 590             if occupied {
 591                 y += 1.0;
 592             } else {
 593                 break;
 594             }
 595         }
 596         (x, y)
 597     }
 598 
 599     /// Scale the lattice and the node bodies together; the limits are on the
 600     /// node width, as they always were.
 601     fn scale_by(&mut self, factor: f32) {
 602         self.pitch_x *= factor;
 603         self.pitch_y *= factor;
 604         self.node_w *= factor;
 605         self.node_h *= factor;
 606     }
 607 
 608     pub fn zoom_in(&mut self) {
 609         if self.node_w < 400.0 {
 610             self.scale_by(1.1);
 611         }
 612     }
 613 
 614     pub fn zoom_out(&mut self) {
 615         if self.node_w > 40.0 {
 616             self.scale_by(1.0 / 1.1);
 617         }
 618     }
 619 
 620     pub fn zoom_by_factor(&mut self, factor: f32) {
 621         let new_w = self.node_w * factor;
 622         if (40.0..=400.0).contains(&new_w) {
 623             self.scale_by(factor);
 624         }
 625     }
 626 
 627     /// The graph's plate, as the colour-typed host paints it: a
 628     /// near-transparent black, so the cells between the grid lines are
 629     /// whatever the graph is painted on, and under `graph_blur` a frosted
 630     /// material whose tint alpha IS the blur value — the knob doubles as
 631     /// the frost's opacity. Until 2026-09-29 a graph could fill itself
 632     /// with a cell colour of its own (`graph.cell_color`, chosen by
 633     /// `uniform_background`); both went together.
 634     fn bg_color(&self) -> [f32; 4] {
 635         use crate::scene::{Frost, Material, PlateRole};
 636         let c = [0.0, 0.0, 0.0, 0.01 * self.network_opacity];
 637         let blur_val = crate::layout::graph_blur();
 638         let m = if blur_val > 0.0 {
 639             Material::opaque([c[0], c[1], c[2], blur_val.abs() * self.network_opacity]).with_frost(Frost::from_style())
 640         } else {
 641             Material::opaque(c)
 642         };
 643         m.fill(PlateRole::Nested)
 644     }
 645 
 646     /// The corner radius of anything node-shaped at the current zoom — the
 647     /// hosts' empty-cell cursor and drop-target highlight read it. Clamped to
 648     /// a quarter sweep of the node body; 0 when the body is degenerate.
 649     pub fn cell_corner_radius(&self) -> f32 {
 650         // Pure GEOMETRY — no display gating: the cursor and the highlight
 651         // exist whether or not the lattice is drawn. Gating on
 652         // show_network_grid silently squared those
 653         // consumers whenever the grid was hidden.
 654         if self.node_w <= 0.0 || self.node_h <= 0.0 {
 655             return 0.0;
 656         }
 657         let body = self.node_w.min(self.node_h);
 658         // The NODE radius, so the cursor sitting on a node's cell traces the
 659         // same silhouette the node does.
 660         crate::layout::graph_node_corner_radius().min(body / 2.0)
 661     }
 662 
 663     /// The grid lines, flat, over whatever the graph is painted on — the
 664     /// pane plate. A lattice of lines one pitch apart in the grid colour at
 665     /// the network opacity, each centred on its coordinate (the pitch is
 666     /// measured centre to centre, and `graph_line_width` only thickens
 667     /// them), so the intersections are exactly where the node centres go.
 668     /// Plus the origin axes: the two lines through the (0, 0) intersection,
 669     /// 2px, in the axis colour. Gated on the grid's visibility only.
 670     /// (Rounded cells with grout between them came before
 671     /// the lattice; a node then FILLED a cell rather than sitting on a
 672     /// crossing.)
 673     pub fn paint_grid(&self, rect: Rect, pc: &mut PaintCtx) {
 674         if self.pitch_x <= 0.0 || self.pitch_y <= 0.0 {
 675             return;
 676         }
 677         let (min_x, min_y) = (rect.x, rect.y);
 678         let (max_x, max_y) = (rect.x + rect.width, rect.y + rect.height);
 679         let clipped = |qx: f32, qy: f32, qw: f32, qh: f32, c: [f32; 4], pc: &mut PaintCtx| {
 680             let x1 = qx.max(min_x);
 681             let y1 = qy.max(min_y);
 682             let x2 = (qx + qw).min(max_x);
 683             let y2 = (qy + qh).min(max_y);
 684             if x2 > x1 && y2 > y1 {
 685                 pc.quad(Rect { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }, c);
 686             }
 687         };
 688 
 689         let line = crate::layout::graph_line_width().max(0.0);
 690         if self.show_network_grid && line > 0.0 && self.pitch_x >= 4.0 && self.pitch_y >= 4.0 {
 691             let color = [self.grid_color[0], self.grid_color[1], self.grid_color[2], self.network_opacity];
 692             // The line indices that can cross the rect, one past each edge so
 693             // a line's own width never pops at the boundary.
 694             let c0 = ((min_x - self.grid_origin_x) / self.pitch_x).floor() as i32 - 1;
 695             let c1 = ((max_x - self.grid_origin_x) / self.pitch_x).ceil() as i32 + 1;
 696             for c in c0..=c1 {
 697                 let x = self.grid_origin_x + c as f32 * self.pitch_x;
 698                 clipped(x - line / 2.0, rect.y, line, rect.height, color, pc);
 699             }
 700             let r0 = ((min_y - self.grid_origin_y) / self.pitch_y).floor() as i32 - 1;
 701             let r1 = ((max_y - self.grid_origin_y) / self.pitch_y).ceil() as i32 + 1;
 702             for r in r0..=r1 {
 703                 let y = self.grid_origin_y + r as f32 * self.pitch_y;
 704                 clipped(rect.x, y - line / 2.0, rect.width, line, color, pc);
 705             }
 706         }
 707 
 708         // Origin axes: the lattice lines through the (0, 0) intersection.
 709         let axis = [0.0, 0.0, 0.0, self.network_opacity];
 710         let thickness = 2.0;
 711         clipped(rect.x, self.grid_origin_y - thickness / 2.0, rect.width, thickness, axis, pc);
 712         clipped(self.grid_origin_x - thickness / 2.0, rect.y, thickness, rect.height, axis, pc);
 713     }
 714 
 715     /// The wire style in effect: the host's, else the config's.
 716     pub fn wire_style(&self) -> WireStyle {
 717         self.wire_style.unwrap_or_else(WireStyle::configured)
 718     }
 719 
 720     /// The host's own choice of wire style, if it made one.
 721     pub fn chosen_wire_style(&self) -> Option<WireStyle> {
 722         self.wire_style
 723     }
 724 
 725     /// Choose the wire style for this graph; `None` goes back to the
 726     /// config's `wire_style`.
 727     pub fn set_wire_style(&mut self, style: Option<WireStyle>) {
 728         self.wire_style = style;
 729     }
 730 
 731     /// Whether a node dropped onto another node swaps places with it: the
 732     /// dragged node takes the other's cell and the other takes the dragged
 733     /// node's, and the host is told ([`GraphController::take_pending_swap`])
 734     /// so it can trade whatever else the two own. Off by default — a drop
 735     /// on an occupied cell walks to the nearest free one — so a host opts
 736     /// in. While the ghost is over a node that node is the swap target:
 737     /// coloured as the dragged node is, the drop target's cell, and it wins
 738     /// over a wire the ghost also touches (a node's own wires run into its
 739     /// body, so the two meet whenever a ghost is over a node).
 740     pub fn set_swap_on_drop(&mut self, on: bool) {
 741         self.swap_on_drop = on;
 742         if !on {
 743             self.swap_target = None;
 744         }
 745     }
 746 
 747     /// The node the in-flight drag would swap with, by index.
 748     pub fn swap_target_idx(&self) -> Option<usize> {
 749         let id = self.swap_target.as_ref()?;
 750         self.nodes.iter().position(|n| &n.id == id)
 751     }
 752 
 753     /// The node standing on the cell the dragged node's ghost at (nx, ny)
 754     /// is nearest — the one a drop there would swap with.
 755     fn swap_candidate(&self, dragged: usize, nx: f32, ny: f32) -> Option<usize> {
 756         if !self.swap_on_drop {
 757             return None;
 758         }
 759         let (c, r) = self.nearest_cell(nx, ny)?;
 760         self.nodes
 761             .iter()
 762             .enumerate()
 763             .find(|(i, n)| *i != dragged && n.position.0.round() == c && n.position.1.round() == r)
 764             .map(|(i, _)| i)
 765     }
 766 
 767     /// A wire's width as asked for: `graph_wire_size` px at 100%, scaled
 768     /// with the node body as the zoom scales it, and at most half a body.
 769     fn wire_size_at_zoom(&self) -> f32 {
 770         let base = crate::layout::graph_node_width();
 771         let zoom = if base > 0.0 { self.node_w / base } else { 1.0 };
 772         (crate::layout::graph_wire_size() * zoom).clamp(0.0, (self.node_h * 0.5).max(1.0))
 773     }
 774 
 775     /// A wire's stroke as drawn (see [`wire_stroke`]).
 776     fn wire_thickness(&self) -> f32 {
 777         wire_stroke(self.wire_size_at_zoom(), device_px()).0
 778     }
 779 
 780     /// The alpha a wire's colour is drawn at (see [`wire_stroke`]).
 781     fn wire_fade(&self) -> f32 {
 782         wire_stroke(self.wire_size_at_zoom(), device_px()).1
 783     }
 784 
 785     /// What a Rounded bend's radius and a Bezier's straight lead are made of.
 786     fn wire_bend(&self) -> f32 {
 787         self.node_h * 0.5
 788     }
 789 
 790     /// The wire from `src_idx`'s output to `dest_idx`'s input, as drawn.
 791     fn wire_segments(&self, src_idx: usize, dest_idx: usize, port: usize) -> Option<Vec<WireSeg>> {
 792         let (start, end) = self.wire_endpoints(src_idx, dest_idx, port)?;
 793         let turn = self.wire_turn_y(start, end);
 794         Some(wire_path(self.wire_style(), start, end, self.wire_thickness(), self.wire_bend(), turn))
 795     }
 796 
 797     /// Where a wire running DOWN turns across: on the first lattice line
 798     /// below its source — the line the row under the source stands on — so
 799     /// it leaves the source's column at once. Halfway down, which is where
 800     /// every wire used to turn, a wire spanning several rows ran straight
 801     /// down through whatever node stood under its source on the way: a wire
 802     /// from row -1 to row 3 turned on row 1's line, through the node there.
 803     /// `None` (halfway) where that line is not between the two ends with
 804     /// room for the bends — the nodes are in adjacent rows, and the turn
 805     /// belongs between the bodies — and for a wire running up, whose first
 806     /// line past its source is the source's own.
 807     fn wire_turn_y(&self, start: (f32, f32), end: (f32, f32)) -> Option<f32> {
 808         let (sy, ey) = (start.1, end.1);
 809         if self.pitch_y <= 0.0 || ey <= sy {
 810             return None;
 811         }
 812         let line = self.grid_origin_y + (((sy - self.grid_origin_y) / self.pitch_y).floor() + 1.0) * self.pitch_y;
 813         let room = self.wire_thickness().max(1.0);
 814         (line - sy >= room && ey - line >= room).then_some(line)
 815     }
 816 
 817     /// The wires, and the one being dragged out of a port, in the style in
 818     /// effect ([`Self::wire_style`]), `wire_color` at the node opacity and
 819     /// `wire_size` thick; the wire an in-flight node drag would splice into
 820     /// is in `wire_highlight_color` — the drop affordance. Clipped to `rect`.
 821     /// Hosts that draw the graph's quads themselves call it between
 822     /// [`Self::paint_grid`] and the node bodies.
 823     pub fn paint_wires(&self, rect: Rect, pc: &mut PaintCtx) {
 824         let t = self.wire_thickness();
 825         let stroke = |segs: &[WireSeg], color: [f32; 4], pc: &mut PaintCtx| {
 826             for seg in segs {
 827                 match *seg {
 828                     WireSeg::Line(a, b) => pc.vector(a.0, a.1, b.0, b.1, t, color, crate::scene::paint::Cap::Flat),
 829                     // The arc's radius is its OUTER edge; the path's is the
 830                     // centreline.
 831                     WireSeg::Arc { c, r, a0, a1 } => pc.arc(c.0, c.1, r + t / 2.0, t, a0, a1, color),
 832                 }
 833             }
 834         };
 835         let fade = self.node_opacity * self.wire_fade();
 836         let wc = crate::color::graph_wire_color();
 837         let wire_color = [wc[0], wc[1], wc[2], wc[3] * fade];
 838         let hl = crate::color::graph_wire_highlight_color();
 839         let splice_color = [hl[0], hl[1], hl[2], hl[3] * fade];
 840         pc.clip(rect, |pc| {
 841             for (src_idx, i, port) in self.wire_pairs() {
 842                 let Some(segs) = self.wire_segments(src_idx, i, port) else { continue };
 843                 let is_splice_target = self
 844                     .splice_target
 845                     .as_ref()
 846                     .is_some_and(|(s, d)| self.nodes[src_idx].id == *s && self.nodes[i].id == *d);
 847                 stroke(&segs, if is_splice_target { splice_color } else { wire_color }, pc);
 848             }
 849             // The connection being dragged out of a port.
 850             if let Some((node_idx, port_type, port_idx)) = self.connecting_from {
 851                 if let Some(start) = self.port_center(node_idx, port_type, port_idx) {
 852                     let segs = wire_path(self.wire_style(), start, self.current_mouse_pos, t, self.wire_bend(), None);
 853                     stroke(&segs, [1.0, 0.6, 0.0, 0.8], pc); // Golden orange preview
 854                 }
 855             }
 856         });
 857     }
 858 
 859     /// The node bodies / toggles as plain quads — the legacy
 860     /// `extra_quads` body, against `rect` instead of a stored rect. The [`TaggedQuad`] cell
 861     /// tag is always `None` now: the lattice is `paint_grid`'s, and it has no cells.
 862     pub fn geometry_quads_tagged(&self, rect: Rect) -> Vec<TaggedQuad> {
 863         let mut quads = Vec::new();
 864         let min_x = rect.x;
 865         let min_y = rect.y;
 866         let max_x = rect.x + rect.width;
 867         let max_y = rect.y + rect.height;
 868         // The wires and the connection preview are `paint_wires`' — strokes,
 869         // not quads, since only one of the styles is axis-aligned.
 870 
 871         // The grid lines and the origin axes are `paint_grid`'s — hosts that
 872         // draw these quads themselves call it at the same point in their walk.
 873 
 874         // Node bodies (culled, not clipped — legacy) + geometry toggles (clipped)
 875         for i in 0..self.nodes.len() {
 876             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
 877                 let scale_f = nw / 80.0;
 878                 let mut bg_color = if self.dragging_idx == Some(i) || self.swap_target_idx() == Some(i) {
 879                     colors::node_drag_color()
 880                 } else if self.selected_idx == Some(i) {
 881                     colors::node_selected_color()
 882                 } else {
 883                     colors::node_color()
 884                 };
 885                 bg_color[3] *= self.node_opacity;
 886                 if nx + nw > min_x && nx < max_x && ny + nh > min_y && ny < max_y {
 887                     quads.push((nx, ny, nw, nh, bg_color, None));
 888                 }
 889 
 890                 // The geometry toggle is a single-color circle now — it draws
 891                 // through the circles channel (see port_circles), not as
 892                 // quads: a filled dot when the geometry is visible, the same
 893                 // color faded when hidden. The old look was a two-tone square
 894                 // (state square inside a hover-tinted well).
 895                 let _ = scale_f;
 896             }
 897         }
 898 
 899         quads
 900     }
 901 
 902     /// The rounded view of the same geometry — the legacy `all_rounded_quads` conversion: the
 903     /// widget background, then each plain quad either as a grid cell (superellipse cell arcs),
 904     /// a node body (node corner radius, all corners), or with the widget's edge-corner
 905     /// resolution.
 906     fn rounded_geometry(&self, rect: Rect) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
 907         let mut rounded = Vec::new();
 908 
 909         let (w_tl, w_tr, w_br, w_bl) = WIDGET_CORNERS;
 910         rounded.push((rect.x, rect.y, rect.width, rect.height, WIDGET_RADIUS, self.bg_color(), WIDGET_CORNERS));
 911 
 912         let node_radius = crate::layout::graph_node_corner_radius();
 913         let cell_radius = self.cell_corner_radius();
 914         let (wx, wy, ww, wh) = (rect.x, rect.y, rect.width, rect.height);
 915 
 916         for (qx, qy, qw, qh, qc, cell) in self.geometry_quads_tagged(rect) {
 917             if self.is_node_rect(qx, qy, qw, qh) {
 918                 rounded.push((qx, qy, qw, qh, node_radius, qc, (true, true, true, true)));
 919             } else {
 920                 let tl = w_tl && qx <= wx + 1.5 && qy <= wy + 1.5;
 921                 let tr = w_tr && qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5;
 922                 let br = w_br && qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5;
 923                 let bl = w_bl && qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5;
 924 
 925                 if let Some((ctl, ctr, cbr, cbl)) = cell {
 926                     // A cell cut by the pane's own rounded corner wears the
 927                     // widget arc there; its interior corners keep the cell arc.
 928                     let r = if tl || tr || br || bl { cell_radius.max(WIDGET_RADIUS) } else { cell_radius };
 929                     rounded.push((qx, qy, qw, qh, r, qc, (ctl || tl, ctr || tr, cbr || br, cbl || bl)));
 930                 } else {
 931                     let r = if tl || tr || br || bl { WIDGET_RADIUS } else { 0.0 };
 932                     rounded.push((qx, qy, qw, qh, r, qc, (tl, tr, br, bl)));
 933                 }
 934             }
 935         }
 936 
 937         rounded
 938     }
 939 
 940     /// Input/output port circles, culled to the widget rect (legacy `extra_circles`).
 941     fn port_circles(&self, rect: Rect) -> Vec<(f32, f32, f32, [f32; 4])> {
 942         let mut circles = Vec::new();
 943         let min_x = rect.x;
 944         let min_y = rect.y;
 945         let max_x = rect.x + rect.width;
 946         let max_y = rect.y + rect.height;
 947 
 948         let mut push_circle_clipped = |cx: f32, cy: f32, r: f32, color: [f32; 4]| {
 949             if cx >= min_x && cx <= max_x && cy >= min_y && cy <= max_y {
 950                 circles.push((cx, cy, r, color));
 951             }
 952         };
 953 
 954         // Geometry toggles: one circle per toggleable node, a SINGLE color —
 955         // TOGGLE_ON at full alpha when visible, the same color faded when
 956         // hidden; hover grows the radius the way port dots do, so no second
 957         // hover tint is needed. Hit-testing stays toggle_rect's square (the
 958         // circle is inscribed in it).
 959         for i in 0..self.nodes.len() {
 960             if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
 961                 let cx = tx + tw / 2.0;
 962                 let cy = ty + th / 2.0;
 963                 let mut r = tw.min(th) / 2.0;
 964                 if self.toggle_hovered_idx == Some(i) {
 965                     r *= 1.15;
 966                 }
 967                 let mut c = colors::TOGGLE_ON;
 968                 if !self.nodes[i].geom_visible {
 969                     c[3] *= 0.25;
 970                 }
 971                 c[3] *= self.node_opacity;
 972                 push_circle_clipped(cx, cy, r, c);
 973             }
 974         }
 975 
 976         let conn_size = crate::layout::graph_connector_size();
 977         let mut conn_color = colors::graph_connector_color();
 978         let mut conn_hl_color = colors::graph_connector_highlight_color();
 979         conn_color[3] *= self.node_opacity;
 980         conn_hl_color[3] *= self.node_opacity;
 981 
 982         for i in 0..self.nodes.len() {
 983             if let Some((_, _, nw, _)) = self.node_rect(i) {
 984                 let scale_f = nw / 80.0;
 985                 let port_size = (conn_size * scale_f).max(2.0);
 986                 let base_r = port_size / 2.0;
 987 
 988                 let node = &self.nodes[i];
 989 
 990                 for (port_type, count) in
 991                     [(PortType::Input, node.inputs), (PortType::Output, node.outputs)]
 992                 {
 993                     for k in 0..count {
 994                         let Some((cx, cy)) = self.port_center(i, port_type, k) else { continue };
 995 
 996                         let is_hovered = self.hovered_port == Some((i, port_type, k));
 997                         let is_connecting = self.connecting_from == Some((i, port_type, k));
 998 
 999                         let (r, color) = if is_hovered || is_connecting {
1000                             (base_r * 1.4, conn_hl_color)
1001                         } else {
1002                             (base_r, conn_color)
1003                         };
1004                         push_circle_clipped(cx, cy, r, color);
1005                     }
1006                 }
1007             }
1008         }
1009         circles
1010     }
1011 
1012     /// Node-name labels beside each node, scaled with the grid, included only when they
1013     /// intersect the widget rect (legacy `text_labels`).
1014     /// A node's name hangs off its body's RIGHT edge — an 8 px gap and a
1015     /// 14 px font, both scaled with the body against its 80 px baseline —
1016     /// unless it would not fit there and fits on the LEFT, where it hangs
1017     /// off the left edge instead, right-aligned to it. A node parked against
1018     /// the pane's right edge used to draw with no name at all: the label
1019     /// began past the edge and the cull dropped it whole, whatever its
1020     /// length. Frame All assumes the right-hand placement, which is safe —
1021     /// after framing every label fits on the right and none flips.
1022     fn node_labels(&self, rect: Rect) -> Vec<TextLabel> {
1023         let mut labels = Vec::new();
1024         for (i, node) in self.nodes.iter().enumerate() {
1025             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
1026                 let scale_f = nw / 80.0;
1027                 let font_size = (14.0 * scale_f).clamp(6.0, 48.0);
1028                 let gap = 8.0 * scale_f;
1029                 let ly = crate::layout::align_text_y(ny, nh, font_size, 0.0);
1030                 let text_w = TextLabel::estimate_width(&node.name, font_size);
1031                 let right = nx + nw + gap;
1032                 let left = nx - gap - text_w;
1033                 let fits_right = right + text_w <= rect.x + rect.width;
1034                 let fits_left = left >= rect.x;
1035                 let lx = if !fits_right && fits_left { left } else { right };
1036                 if lx + text_w >= rect.x && lx < rect.x + rect.width && ly + font_size >= rect.y && ly < rect.y + rect.height {
1037                     labels.push(TextLabel {
1038                         text: node.name.clone(),
1039                         x: lx,
1040                         y: ly,
1041                         font_size,
1042                         color: [0xcc, 0xcc, 0xd4],
1043                     });
1044                 }
1045             }
1046         }
1047         labels
1048     }
1049 }
1050 
1051 fn read_zoom_bindings() -> (String, String) {
1052     let mut zoom_in_val = "=".to_string();
1053     let mut zoom_out_val = "-".to_string();
1054     let path = crate::config::get_config_path();
1055     if let Ok(content) = std::fs::read_to_string(&path) {
1056         if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
1057             if let Some(zoom_in) = val.pointer("/layout/zoom_in").and_then(|v| v.as_str()) {
1058                 zoom_in_val = zoom_in.to_string();
1059             }
1060             if let Some(zoom_out) = val.pointer("/layout/zoom_out").and_then(|v| v.as_str()) {
1061                 zoom_out_val = zoom_out.to_string();
1062             }
1063         }
1064     }
1065     (zoom_in_val, zoom_out_val)
1066 }
1067 
1068 impl Layout for Graph {}
1069 
1070 impl Paint for Graph {
1071     fn color(&self) -> [f32; 4] {
1072         self.bg_color()
1073     }
1074 
1075     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
1076         Some((WIDGET_RADIUS, WIDGET_CORNERS))
1077     }
1078 
1079     fn widget_font(&self) -> Option<String> {
1080         Some(crate::layout::graph_node_font())
1081     }
1082 
1083     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
1084         // The background is the first entry; the grid lines and then the
1085         // wires go over it before the nodes.
1086         for (i, (qx, qy, qw, qh, r, c, corners)) in self.rounded_geometry(rect).into_iter().enumerate() {
1087             ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, r, corners, c);
1088             if i == 0 {
1089                 self.paint_grid(rect, ctx);
1090                 self.paint_wires(rect, ctx);
1091             }
1092         }
1093         for (cx, cy, r, c) in self.port_circles(rect) {
1094             ctx.circle(cx, cy, r, c);
1095         }
1096         // Node names are arbitrary and the canvas is fixed, so a long name on a
1097         // node near the right edge used to draw off the graph entirely.
1098         let canvas = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
1099         for l in self.node_labels(rect) {
1100             ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, canvas);
1101         }
1102     }
1103 
1104     fn text_bounds(&self, rect: Rect) -> Option<[f32; 4]> {
1105         Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height])
1106     }
1107 }
1108 
1109 impl Input for Graph {
1110     /// Advances the pan glide/coast behind the grid origin. Idle is a no-op.
1111     fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
1112         self.pan_motion.reconcile(self.grid_origin_x, self.grid_origin_y);
1113         if !self.pan_motion.is_animating() {
1114             return false;
1115         }
1116         let free = crate::widget::Bounds::UNBOUNDED;
1117         let moved = self.pan_motion.tick(dt, free, free);
1118         self.grid_origin_x = self.pan_motion.x.pos();
1119         self.grid_origin_y = self.pan_motion.y.pos();
1120         moved || self.pan_motion.is_animating()
1121     }
1122 
1123     fn wants_tick(&self) -> bool {
1124         true
1125     }
1126 
1127     /// Legacy hit test excluded the right/bottom edges.
1128     fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
1129         x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
1130     }
1131 
1132     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
1133         match event {
1134             Event::PointerMove { x: px, y: py, .. } => {
1135                 let (px, py) = (*px, *py);
1136                 let mut changed = false;
1137                 if self.connecting_from.is_some() {
1138                     self.current_mouse_pos = (px, py);
1139                     changed = true;
1140                 }
1141                 let was_toggle_hovered = self.toggle_hovered_idx;
1142                 self.toggle_hovered_idx = None;
1143 
1144                 let was_hovered_port = self.hovered_port;
1145                 self.hovered_port = None;
1146 
1147                 let conn_act_r = crate::layout::graph_connector_activation_radius();
1148 
1149                 for i in 0..self.nodes.len() {
1150                     if let Some((_, _, nw, _)) = self.node_rect(i) {
1151                         let scale_f = nw / 80.0;
1152                         let hit_radius = (conn_act_r * scale_f).max(2.0);
1153                         let node = &self.nodes[i];
1154 
1155                         for (port_type, count) in
1156                             [(PortType::Input, node.inputs), (PortType::Output, node.outputs)]
1157                         {
1158                             for k in 0..count {
1159                                 let Some((cx, cy)) = self.port_center(i, port_type, k) else {
1160                                     continue;
1161                                 };
1162                                 if (px - cx).powi(2) + (py - cy).powi(2) <= hit_radius.powi(2) {
1163                                     self.hovered_port = Some((i, port_type, k));
1164                                 }
1165                             }
1166                         }
1167                     }
1168 
1169                     if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
1170                         if px >= tx && px < tx + tw && py >= ty && py < ty + th {
1171                             self.toggle_hovered_idx = Some(i);
1172                         }
1173                     }
1174                 }
1175 
1176                 if was_toggle_hovered != self.toggle_hovered_idx || was_hovered_port != self.hovered_port {
1177                     changed = true;
1178                 }
1179                 changed
1180             }
1181             Event::MouseLeave => {
1182                 let changed = self.hovered_port.is_some() || self.toggle_hovered_idx.is_some();
1183                 self.hovered_port = None;
1184                 self.toggle_hovered_idx = None;
1185                 changed
1186             }
1187             Event::MouseButton { button: MouseButton::Right, state: ElementState::Pressed, .. } => {
1188                 if self.connecting_from.is_some() {
1189                     self.connecting_from = None;
1190                     true
1191                 } else {
1192                     false
1193                 }
1194             }
1195             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
1196                 self.on_left_press(*x, *y, ectx)
1197             }
1198             Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
1199                 if self.dragging_idx.is_some() {
1200                     self.commit_drag();
1201                     true
1202                 } else {
1203                     false
1204                 }
1205             }
1206             Event::MouseWheel { delta, x: px, y: py, .. } => {
1207                 let _ = (px, py); // hit-gated by the adapter
1208                 let ctrl = ectx.ui.as_deref().is_some_and(|ui| ui.ctrl_pressed);
1209                 if ctrl {
1210                     match delta {
1211                         MouseScrollDelta::LineDelta(_x, y) => {
1212                             if *y > 0.0 {
1213                                 self.zoom_by_factor(1.1);
1214                             } else if *y < 0.0 {
1215                                 self.zoom_by_factor(1.0 / 1.1);
1216                             }
1217                             true
1218                         }
1219                         MouseScrollDelta::PixelDelta(pos) => {
1220                             let factor = 1.0 + (pos.y as f32 * 0.015);
1221                             self.zoom_by_factor(factor);
1222                             true
1223                         }
1224                     }
1225                 } else {
1226                     // Pan: the origin moves WITH the wheel sign (no negation —
1227                     // the canvas follows the gesture), across an unbounded plane.
1228                     let (dx, dy) = match delta {
1229                         MouseScrollDelta::LineDelta(x, y) => (*x * 15.0, *y * 15.0),
1230                         MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
1231                     };
1232                     let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
1233                     let free = crate::widget::Bounds::UNBOUNDED;
1234                     self.pan_motion.reconcile(self.grid_origin_x, self.grid_origin_y);
1235                     self.pan_motion.apply_px(dx, dy, discrete, free, free);
1236                     self.grid_origin_x = self.pan_motion.x.pos();
1237                     self.grid_origin_y = self.pan_motion.y.pos();
1238                     true
1239                 }
1240             }
1241             Event::KeyInput(key_event) => {
1242                 if key_event.state == ElementState::Pressed {
1243                     if let Key::Character(ref ch) = key_event.logical_key {
1244                         let (zoom_in_binding, zoom_out_binding) = read_zoom_bindings();
1245                         if ch == &zoom_in_binding {
1246                             self.zoom_in();
1247                             return true;
1248                         } else if ch == &zoom_out_binding {
1249                             self.zoom_out();
1250                             return true;
1251                         }
1252                     }
1253                 }
1254                 false
1255             }
1256             _ => false,
1257         }
1258     }
1259 
1260     fn scrollable(&self) -> bool {
1261         false
1262     }
1263 
1264     // The graph is "draggable" only once a left press landed on a node body (`on_left_press`
1265     // sets `dragging_idx`); hosts then re-init via drag_begin and stream drag_update.
1266     fn draggable(&self, _rect: Rect) -> bool {
1267         self.dragging_idx.is_some()
1268     }
1269     fn is_dragging(&self) -> bool {
1270         self.dragging_idx.is_some()
1271     }
1272 
1273     fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
1274         if let Some(idx) = self.dragging_idx {
1275             if let Some((nx, ny, _, _)) = self.node_rect(idx) {
1276                 self.drag_ox = px - nx;
1277                 self.drag_oy = py - ny;
1278                 self.drag_node_pos = Some((nx, ny));
1279             }
1280         }
1281     }
1282 
1283     fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
1284         if self.dragging_idx.is_some() {
1285             let nx = px - self.drag_ox;
1286             let ny = py - self.drag_oy;
1287 
1288             // Snapping centres the body on the nearest intersection a drop
1289             // could LAND on: an empty one, or another node's when a drop
1290             // there swaps the two (`set_swap_on_drop`); otherwise the
1291             // nearest free one, which is where `commit_drag` would walk it
1292             // — so the body never sits where it cannot stay. It snapped to
1293             // the nearest crossing whatever stood there until 2026-10-06,
1294             // and showed a node over another until the release moved it.
1295             let (nx, ny) = match self.nearest_cell(nx, ny) {
1296                 Some((c, r)) if self.grid_snap_enabled => {
1297                     let dragged = self.dragging_idx.unwrap_or(usize::MAX);
1298                     let (c, r) = if self.swap_candidate(dragged, nx, ny).is_some() {
1299                         (c, r)
1300                     } else {
1301                         self.find_empty_cell(c, r, self.dragging_idx)
1302                     };
1303                     self.cell_origin(c, r)
1304                 }
1305                 _ => (nx, ny),
1306             };
1307 
1308             self.drag_node_pos = Some((nx, ny));
1309             // A node under the ghost is a swap, which wins over a wire.
1310             self.swap_target = self
1311                 .dragging_idx
1312                 .and_then(|i| self.swap_candidate(i, nx, ny))
1313                 .map(|o| self.nodes[o].id.clone());
1314             // The wire the ghost sits on right now, held by id (hosts
1315             // re-sync between events) and drawn highlighted — the drop
1316             // affordance the user aims by.
1317             self.splice_target = if self.swap_target.is_some() {
1318                 None
1319             } else {
1320                 self.dragging_idx
1321                     .and_then(|i| self.splice_wire_at(i, nx, ny))
1322                     .map(|(s, d)| (self.nodes[s].id.clone(), self.nodes[d].id.clone()))
1323             };
1324             return true;
1325         }
1326         false
1327     }
1328 
1329     fn drag_end(&mut self) {
1330         self.commit_drag();
1331     }
1332 
1333 }
1334 
1335 impl Graph {
1336     /// The legacy left-press cascade: ports (start/complete a connection), a node-body
1337     /// fallback for an in-flight connection, geometry toggles, then node selection + drag
1338     /// arming; an empty-space press clears the selection and stays unconsumed.
1339     fn on_left_press(&mut self, px: f32, py: f32, ectx: &mut EventCtx) -> bool {
1340         for i in (0..self.nodes.len()).rev() {
1341             if let Some((_, _, nw, _)) = self.node_rect(i) {
1342                 let scale_f = nw / 80.0;
1343                 let conn_act_r = crate::layout::graph_connector_activation_radius();
1344                 let port_click_radius = (conn_act_r * scale_f).max(2.0);
1345                 let port_click_radius_sq = port_click_radius * port_click_radius;
1346 
1347                 let node = &self.nodes[i];
1348                 for (port_type, count) in
1349                     [(PortType::Input, node.inputs), (PortType::Output, node.outputs)]
1350                 {
1351                     for k in 0..count {
1352                         let Some((port_x, port_y)) = self.port_center(i, port_type, k) else {
1353                             continue;
1354                         };
1355                         let dx = px - port_x;
1356                         let dy = py - port_y;
1357                         if dx * dx + dy * dy <= port_click_radius_sq {
1358                             if let Some((src_idx, src_port_type, src_port_idx)) = self.connecting_from {
1359                                 // A click on the opposite port kind of ANOTHER
1360                                 // node completes the connection; anything else
1361                                 // cancels it.
1362                                 if src_idx != i && src_port_type != port_type {
1363                                     let (out_idx, in_idx, in_port) = if port_type == PortType::Input {
1364                                         (src_idx, i, k)
1365                                     } else {
1366                                         (i, src_idx, src_port_idx)
1367                                     };
1368                                     let output_node = &self.nodes[out_idx];
1369                                     let input_node = &self.nodes[in_idx];
1370                                     self.pending_connection =
1371                                         Some((input_node.id.clone(), output_node.name.clone(), in_port));
1372                                 }
1373                                 self.connecting_from = None;
1374                             } else {
1375                                 self.connecting_from = Some((i, port_type, k));
1376                                 self.current_mouse_pos = (px, py);
1377                             }
1378                             return true;
1379                         }
1380                     }
1381                 }
1382             }
1383         }
1384 
1385         // Actively connecting + clicked a target node body: connect to its closest compatible port
1386         if let Some((src_idx, src_port_type, src_port_idx)) = self.connecting_from {
1387             for i in (0..self.nodes.len()).rev() {
1388                 if src_idx != i {
1389                     if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
1390                         if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
1391                             let node = &self.nodes[i];
1392                             if src_port_type == PortType::Output && node.inputs > 0 {
1393                                 let output_node = &self.nodes[src_idx];
1394                                 let input_node = &self.nodes[i];
1395                                 // A body, not a port: its first input.
1396                                 self.pending_connection = Some((input_node.id.clone(), output_node.name.clone(), 0));
1397                                 self.connecting_from = None;
1398                                 return true;
1399                             } else if src_port_type == PortType::Input && node.outputs > 0 {
1400                                 let output_node = &self.nodes[i];
1401                                 let input_node = &self.nodes[src_idx];
1402                                 self.pending_connection = Some((input_node.id.clone(), output_node.name.clone(), src_port_idx));
1403                                 self.connecting_from = None;
1404                                 return true;
1405                             }
1406                         }
1407                     }
1408                 }
1409             }
1410         }
1411 
1412         if self.connecting_from.is_some() {
1413             self.connecting_from = None;
1414         }
1415 
1416         for i in (0..self.nodes.len()).rev() {
1417             if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
1418                 if px >= tx && px < tx + tw && py >= ty && py < ty + th {
1419                     self.nodes[i].geom_visible = !self.nodes[i].geom_visible;
1420                     self.node_geom_toggled = Some((i, self.nodes[i].geom_visible));
1421                     return true;
1422                 }
1423             }
1424             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
1425                 if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
1426                     let now = web_time::Instant::now();
1427                     let clicked_id = self.nodes[i].id.clone();
1428                     if let Some((prev_time, prev_id)) = self.double_click_timer.take() {
1429                         if prev_id == clicked_id && now.duration_since(prev_time) < std::time::Duration::from_millis(500) {
1430                             self.double_clicked_id = Some(clicked_id.clone());
1431                         }
1432                     }
1433                     self.double_click_timer = Some((now, clicked_id));
1434                     self.selected_idx = Some(i);
1435                     self.selected_id = Some(self.nodes[i].id.clone());
1436                     self.dragging_idx = Some(i);
1437                     self.dragging_id = Some(self.nodes[i].id.clone());
1438                     self.drag_ox = px - nx;
1439                     self.drag_oy = py - ny;
1440                     self.drag_node_pos = Some((nx, ny));
1441                     ectx.request_focus();
1442                     return true;
1443                 }
1444             }
1445         }
1446         self.selected_idx = None;
1447         self.selected_id = None;
1448         false
1449     }
1450 
1451     /// The wires the draw pass renders: (src idx, dest idx, input port) —
1452     /// the ONE derivation, shared with the splice hit test so the two cannot
1453     /// disagree about where a wire is.
1454     ///
1455     /// A node's wires are its parameters of type `node`, in order, the k-th
1456     /// into input port k: every one a host marks so, not just the first
1457     /// (the designer's Switch reads four, a Boolean two). A node with none
1458     /// so marked has its parameter NAMED `input` as its one wire, into port
1459     /// 0 — what every host passed before the type said it (cce-files,
1460     /// cce-graph).
1461     fn wire_pairs(&self) -> Vec<(usize, usize, usize)> {
1462         let mut out = Vec::new();
1463         for i in 0..self.nodes.len() {
1464             for (port, source) in node_wires(&self.nodes[i]).into_iter().enumerate() {
1465                 if source.is_empty() {
1466                     continue;
1467                 }
1468                 if let Some(src_idx) = self.nodes.iter().position(|n| n.name == source) {
1469                     out.push((src_idx, i, port));
1470                 }
1471             }
1472         }
1473         out
1474     }
1475 
1476     /// A wire's two attachment points — the port circles' centers, falling
1477     /// back to the node edge midpoints for portless nodes. The three-segment
1478     /// shape (down, across, down) derives from these in both the draw pass
1479     /// and [`Self::wire_segment_rects`].
1480     fn wire_endpoints(&self, src_idx: usize, dest_idx: usize, port: usize) -> Option<((f32, f32), (f32, f32))> {
1481         let (sx, sy, sw, sh) = self.node_rect(src_idx)?;
1482         let (ex, ey, ew, _eh) = self.node_rect(dest_idx)?;
1483         let start = self
1484             .port_center(src_idx, PortType::Output, 0)
1485             .unwrap_or((sx + sw / 2.0, sy + sh));
1486         // Into its own port; a wire past the node's ports (a host that
1487         // declared fewer) lands on the first, and a portless node's edge.
1488         let end = self
1489             .port_center(dest_idx, PortType::Input, port)
1490             .or_else(|| self.port_center(dest_idx, PortType::Input, 0))
1491             .unwrap_or((ex + ew / 2.0, ey));
1492         Some((start, end))
1493     }
1494 
1495     /// The wire the dragged node's ghost at (nx, ny) would splice into —
1496     /// the first pair (draw order) whose path, as drawn, touches the ghost rect,
1497     /// inflated by the wire activation radius so a near miss still takes.
1498     /// The dragged node's own wires never count (dropping a node on a wire
1499     /// it is already an end of is a move, not a rewire), and a node with no
1500     /// "Input" parameter or no output port cannot sit mid-chain. Only a
1501     /// wire into port 0 — the Input — is spliced into, since the splice
1502     /// rewires the Inputs.
1503     fn splice_wire_at(&self, idx: usize, nx: f32, ny: f32) -> Option<(usize, usize)> {
1504         let node = self.nodes.get(idx)?;
1505         let has_input = node
1506             .parameters
1507             .iter()
1508             .any(|(name, _, _)| name.eq_ignore_ascii_case("input"));
1509         if !has_input || node.outputs == 0 {
1510             return None;
1511         }
1512         let (_, _, nw, nh) = self.node_rect(idx)?;
1513         self.input_wire_meeting(nx, ny, nw, nh, Some(idx))
1514     }
1515 
1516     /// The wire into an Input (port 0) that runs through the body a node
1517     /// would have at lattice cell (`col`, `row`), as (upstream id,
1518     /// downstream id) — by the hit test a dragged node's splice drop uses,
1519     /// so a host placing a NEW node there (the designer's Add Node at its
1520     /// grid cursor) wires it in exactly where a drop would have. Whether
1521     /// the cell is free is the host's to ask; a node's own wires touch it.
1522     pub fn input_wire_through_cell(&self, col: f32, row: f32) -> Option<(String, String)> {
1523         let (x, y) = self.cell_origin(col, row);
1524         let (src, dest) = self.input_wire_meeting(x, y, self.node_w, self.node_h, None)?;
1525         Some((self.nodes[src].id.clone(), self.nodes[dest].id.clone()))
1526     }
1527 
1528     /// The first wire into a port 0 (draw order) whose path, as drawn,
1529     /// touches the body rect at (x, y), inflated by the wire activation
1530     /// radius so a near miss still takes. `skip`'s own wires never count.
1531     fn input_wire_meeting(&self, x: f32, y: f32, w: f32, h: f32, skip: Option<usize>) -> Option<(usize, usize)> {
1532         // Half the stroke on top of the activation radius, so the wire's
1533         // edge counts and not only its centreline.
1534         let pad = crate::layout::graph_wire_activation_radius().max(0.0) + self.wire_thickness() / 2.0;
1535         let (gx1, gy1) = (x - pad, y - pad);
1536         let (gx2, gy2) = (x + w + pad, y + h + pad);
1537         for (src, dest, port) in self.wire_pairs() {
1538             if Some(src) == skip || Some(dest) == skip || port != 0 {
1539                 continue;
1540             }
1541             let Some(segs) = self.wire_segments(src, dest, port) else { continue };
1542             let hit = segs.iter().any(|seg| match *seg {
1543                 WireSeg::Line(a, b) => segment_meets_rect(a, b, gx1, gy1, gx2, gy2),
1544                 // An arc, as the chords of its eighths.
1545                 WireSeg::Arc { c, r, a0, a1 } => (0..8).any(|k| {
1546                     let at = |u: f32| {
1547                         let a = a0 + (a1 - a0) * u;
1548                         (c.0 + r * a.cos(), c.1 + r * a.sin())
1549                     };
1550                     segment_meets_rect(at(k as f32 / 8.0), at((k + 1) as f32 / 8.0), gx1, gy1, gx2, gy2)
1551                 }),
1552             });
1553             if hit {
1554                 return Some((src, dest));
1555             }
1556         }
1557         None
1558     }
1559 
1560     /// Drop the in-flight node drag onto the nearest free intersection (legacy `drag_end`),
1561     /// resolving a held splice target into `pending_splice` for the host.
1562     fn commit_drag(&mut self) {
1563         let target = self.splice_target.take();
1564         let swap = self.swap_target_idx();
1565         self.swap_target = None;
1566         if let Some((nx, ny)) = self.drag_node_pos.take() {
1567             if let Some(idx) = self.dragging_idx.take() {
1568                 // A swap: the two trade cells — the dragged node's own
1569                 // position is still the cell it was picked up from.
1570                 if let Some(other) = swap.filter(|&o| o != idx) {
1571                     let from = self.nodes[idx].position;
1572                     self.nodes[idx].position = self.nodes[other].position;
1573                     self.nodes[other].position = from;
1574                     self.pending_swap = Some((self.nodes[idx].id.clone(), self.nodes[other].id.clone()));
1575                     self.dragging_id = None;
1576                     return;
1577                 }
1578                 if let Some((c, r)) = self.nearest_cell(nx, ny) {
1579                     let (c, r) = self.find_empty_cell(c, r, Some(idx));
1580                     self.nodes[idx].position = (c, r);
1581                 }
1582                 if let Some((src_id, dest_id)) = target {
1583                     let src_name = self.nodes.iter().find(|n| n.id == src_id).map(|n| n.name.clone());
1584                     let dest_ok = self.nodes.iter().any(|n| n.id == dest_id);
1585                     if let (Some(src_name), true) = (src_name, dest_ok) {
1586                         self.pending_splice = Some((self.nodes[idx].id.clone(), src_name, dest_id));
1587                     }
1588                 }
1589             }
1590         } else {
1591             self.dragging_idx = None;
1592         }
1593         self.dragging_id = None;
1594     }
1595 }
1596 
1597 impl GraphController for Graph {
1598     fn paint_grid(&self, rect: Rect, pc: &mut PaintCtx) {
1599         Graph::paint_grid(self, rect, pc)
1600     }
1601     fn paint_wires(&self, rect: Rect, pc: &mut PaintCtx) {
1602         Graph::paint_wires(self, rect, pc)
1603     }
1604     fn set_nodes(&mut self, nodes: &[GraphNode]) {
1605         // Hover carries a node INDEX, so remap it by id across the rebuild
1606         // instead of clearing — hosts (the designer) re-sync nodes on EVERY
1607         // window event, so a clear here wipes the hover in the same event
1608         // pass that set it and port highlights never survive to a draw.
1609         // Exactly the id-remap `selected_idx` gets below.
1610         self.hovered_port = self.hovered_port.take().and_then(|(idx, pt, k)| {
1611             let id = &self.nodes.get(idx)?.id;
1612             let new_idx = nodes.iter().position(|n| &n.id == id)?;
1613             let count = match pt {
1614                 PortType::Input => nodes[new_idx].inputs,
1615                 PortType::Output => nodes[new_idx].outputs,
1616             };
1617             (k < count).then_some((new_idx, pt, k))
1618         });
1619         self.nodes = nodes.to_vec();
1620 
1621         // Sync selected_idx from selected_id
1622         if let Some(ref id) = self.selected_id {
1623             self.selected_idx = self.nodes.iter().position(|n| n.id == *id);
1624             if self.selected_idx.is_none() {
1625                 self.selected_id = None;
1626             }
1627         } else {
1628             self.selected_idx = None;
1629         }
1630 
1631         // Sync dragging_idx from dragging_id
1632         if let Some(ref id) = self.dragging_id {
1633             self.dragging_idx = self.nodes.iter().position(|n| n.id == *id);
1634             if self.dragging_idx.is_none() {
1635                 self.dragging_id = None;
1636                 self.drag_node_pos = None;
1637                 self.splice_target = None;
1638                 self.swap_target = None;
1639             }
1640         } else {
1641             self.dragging_idx = None;
1642             self.drag_node_pos = None;
1643             self.splice_target = None;
1644             self.swap_target = None;
1645         }
1646 
1647         // double_clicked_id / double_click_timer survive deliberately: they
1648         // are keyed by node id, and clearing them here (as this used to)
1649         // guaranteed no double-click could ever complete — the host re-syncs
1650         // between the two presses. A stale id simply resolves to None.
1651         self.toggle_hovered_idx = None;
1652     }
1653     fn get_nodes(&self) -> Vec<GraphNode> { self.nodes.clone() }
1654     fn cell_corner_radius(&self) -> f32 { Graph::cell_corner_radius(self) }
1655     fn geometry_quads_tagged(&self, rect: Rect) -> Vec<TaggedQuad> { Graph::geometry_quads_tagged(self, rect) }
1656     fn drop_target_cell_rect(&self) -> Option<(f32, f32, f32, f32)> { Graph::drop_target_cell_rect(self) }
1657     fn selected_node(&self) -> Option<usize> { self.selected_idx }
1658     fn set_selected_node(&mut self, idx: Option<usize>) {
1659         self.selected_idx = idx;
1660         self.selected_id = idx.and_then(|i| self.nodes.get(i).map(|n| n.id.clone()));
1661     }
1662     fn double_clicked_node(&self) -> Option<usize> {
1663         self.double_clicked_id
1664             .as_ref()
1665             .and_then(|id| self.nodes.iter().position(|n| &n.id == id))
1666     }
1667     fn clear_double_clicked_node(&mut self) { self.double_clicked_id = None; }
1668     fn set_grid_snap_enabled(&mut self, enabled: bool) { self.grid_snap_enabled = enabled; }
1669     fn take_node_geom_toggle(&mut self) -> Option<(usize, bool)> { self.node_geom_toggled.take() }
1670     fn set_grid_snap(&mut self, gx: f32, gy: f32) { self.set_grid_sizes(gx, gy) }
1671     fn set_grid_pitch(&mut self, px: f32, py: f32) {
1672         self.pitch_x = px;
1673         self.pitch_y = py;
1674     }
1675     fn set_node_size(&mut self, w: f32, h: f32) { Graph::set_node_size(self, w, h) }
1676     /// Cell model: the cell is the node body, and the gap it had stays, so
1677     /// the two setters commute (a cell plus its gap is a pitch).
1678     fn set_grid_sizes(&mut self, gx: f32, gy: f32) {
1679         let (gap_row, gap_col) = self.skipped_sizes();
1680         self.set_node_size(gx, gy);
1681         self.pitch_x = gx + gap_col;
1682         self.pitch_y = gy + gap_row;
1683     }
1684     fn set_skipped_sizes(&mut self, row_h: f32, col_w: f32) {
1685         self.pitch_x = self.node_w + col_w;
1686         self.pitch_y = self.node_h + row_h;
1687     }
1688     fn set_grid_origin(&mut self, ox: f32, oy: f32) { self.grid_origin_x = ox; self.grid_origin_y = oy; }
1689     fn grid_origin(&self) -> (f32, f32) { (self.grid_origin_x, self.grid_origin_y) }
1690     fn set_show_network_grid(&mut self, show: bool) { self.show_network_grid = show; }
1691     fn take_pending_connection(&mut self) -> Option<(String, String)> {
1692         self.pending_connection.take().map(|(id, name, _)| (id, name))
1693     }
1694     fn take_pending_connection_to_port(&mut self) -> Option<(String, String, usize)> {
1695         self.pending_connection.take()
1696     }
1697     fn take_pending_swap(&mut self) -> Option<(String, String)> {
1698         self.pending_swap.take()
1699     }
1700     fn take_pending_splice(&mut self) -> Option<(String, String, String)> {
1701         self.pending_splice.take()
1702     }
1703     fn input_wire_through_cell(&self, col: f32, row: f32) -> Option<(String, String)> {
1704         Graph::input_wire_through_cell(self, col, row)
1705     }
1706     fn cancel_connecting(&mut self) {
1707         self.connecting_from = None;
1708     }
1709     fn is_node_rect(&self, qx: f32, qy: f32, qw: f32, qh: f32) -> bool {
1710         self.is_node_rect(qx, qy, qw, qh)
1711     }
1712     fn node_at(&self, px: f32, py: f32) -> Option<usize> {
1713         self.node_at(px, py)
1714     }
1715 }
1716 
1717 #[cfg(test)]
1718 mod tests {
1719     use super::*;
1720     use crate::context::UiContext;
1721     use crate::widget::WidgetHost;
1722 
1723     /// A 100 x 60 lattice whose (0, 0) intersection is at (140, 120), so node
1724     /// a's 80 x 40 body is the rect (100, 100, 80, 40) and node b's, one cell
1725     /// down-right, is (200, 160, 80, 40).
1726     fn two_nodes() -> Adapted<Graph> {
1727         let mut g = Graph::new();
1728         WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
1729         g.set_grid_pitch(100.0, 60.0);
1730         g.set_node_size(80.0, 40.0);
1731         g.set_grid_origin(140.0, 120.0);
1732         g.set_grid_snap_enabled(true);
1733         let node = |id: &str, name: &str, col: f32, row: f32| GraphNode {
1734             id: id.into(),
1735             name: name.into(),
1736             position: (col, row),
1737             parameters: Vec::new(),
1738             geom_visible: true,
1739             node_type: String::new(),
1740             inputs: 1,
1741             outputs: 1,
1742         };
1743         g.set_nodes(&[node("a", "alpha", 0.0, 0.0), node("b", "beta", 1.0, 1.0)]);
1744         g
1745     }
1746 
1747     /// A host can hide the geometry toggle: no node wears the disc, and
1748     /// nothing is there to hit.
1749     #[test]
1750     fn a_host_can_hide_the_geometry_toggles() {
1751         let mut g = two_nodes();
1752         assert!(g.toggle_rect(0).is_some(), "toggles show by default");
1753         g.set_show_toggles(false);
1754         assert!(g.toggle_rect(0).is_none() && g.toggle_rect(1).is_none());
1755         let rect = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
1756         let big = g.port_circles(rect).iter().filter(|c| c.2 > 5.0).count();
1757         assert_eq!(big, 0, "no toggle disc is drawn");
1758     }
1759 
1760     /// A node against the pane's right edge keeps its name: the label flips
1761     /// to the body's left when it would not fit on the right, whatever the
1762     /// name's length, and a node with room keeps the right-hand placement.
1763     #[test]
1764     fn a_node_at_the_right_edge_keeps_its_label_on_the_left() {
1765         let mut g = two_nodes();
1766         let node = |name: &str, col: f32| GraphNode {
1767             id: name.into(),
1768             name: name.into(),
1769             position: (col, 0.0),
1770             parameters: Vec::new(),
1771             geom_visible: true,
1772             node_type: String::new(),
1773             inputs: 1,
1774             outputs: 1,
1775         };
1776         // With the origin at 160, column 6's body spans 720..800 — flush
1777         // against the right edge of an 800 px pane, so a right-hand label
1778         // would BEGIN past the edge, which is exactly the screenshot that
1779         // found this. Column 2's spans 320..400: plenty of room.
1780         g.set_grid_origin(160.0, 120.0);
1781         g.set_nodes(&[node("w", 6.0), node("wrangle_with_a_long_name", 6.0), node("mid", 2.0)]);
1782         let rect = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
1783         let labels = g.node_labels(rect);
1784         assert_eq!(labels.len(), 3, "every node keeps a label: {:?}", labels.iter().map(|l| &l.text).collect::<Vec<_>>());
1785         let (nx, _, nw, _) = g.node_rect(0).unwrap();
1786         assert_eq!((nx, nx + nw), (720.0, 800.0));
1787         for name in ["w", "wrangle_with_a_long_name"] {
1788             let l = labels.iter().find(|l| l.text == name).unwrap();
1789             let w = TextLabel::estimate_width(name, l.font_size);
1790             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);
1791             assert!(l.x >= rect.x, "{name} stays inside the pane");
1792         }
1793         let mid = labels.iter().find(|l| l.text == "mid").unwrap();
1794         let (mx, _, mw, _) = g.node_rect(2).unwrap();
1795         assert_eq!(mid.x, mx + mw + 8.0, "a node with room keeps the right-hand placement");
1796     }
1797 
1798     /// A double-click's two presses always straddle a host node re-sync — the
1799     /// designer calls set_nodes on EVERY window event — so the detection state
1800     /// must survive set_nodes. It used to be wiped there wholesale, which made
1801     /// double-click structurally impossible outside unit tests.
1802     #[test]
1803     fn double_click_survives_the_between_press_node_resync() {
1804         let mut ctx = UiContext::new();
1805         let g = ctx.insert(two_nodes());
1806 
1807         // First press on node a, then the host re-syncs (same content),
1808         // then the second press: this is the real event sequence.
1809         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, ctx)).unwrap());
1810         ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Released, 110.0, 120.0, ctx)).unwrap();
1811         let nodes = ctx[g].get_nodes();
1812         ctx[g].set_nodes(&nodes);
1813         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, ctx)).unwrap());
1814 
1815         assert_eq!(ctx[g].double_clicked_node(), Some(0), "double-click lost across set_nodes");
1816         ctx[g].clear_double_clicked_node();
1817         assert_eq!(ctx[g].double_clicked_node(), None);
1818     }
1819 
1820     /// Two presses on DIFFERENT nodes are not a double-click, id-keyed or not.
1821     #[test]
1822     fn presses_on_two_nodes_are_not_a_double_click() {
1823         let mut ctx = UiContext::new();
1824         let g = ctx.insert(two_nodes());
1825 
1826         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, ctx)).unwrap());
1827         ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Released, 110.0, 120.0, ctx)).unwrap();
1828         // Node b sits one grid step down-right of a.
1829         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 210.0, 180.0, ctx)).unwrap());
1830         assert_eq!(ctx[g].double_clicked_node(), None);
1831     }
1832 
1833     #[test]
1834     fn node_press_selects_arms_drag_and_commit_snaps_to_grid() {
1835         let mut ctx = UiContext::new();
1836         let g = ctx.insert(two_nodes());
1837 
1838         // Node a occupies (100, 100, 80, 40). Press its body (away from ports/toggle).
1839         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, ctx)).unwrap());
1840         assert_eq!(ctx[g].selected_node(), Some(0));
1841         assert!(ctx[g].is_dragging() && ctx[g].draggable());
1842 
1843         // Drag one pitch right (pitch_x = 100): snap puts the node at column 1, and cell
1844         // (1, 0) is free so it lands there.
1845         ctx[g].drag_begin(110.0, 120.0);
1846         assert!(ctx[g].drag_update(210.0, 120.0));
1847         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 120.0, ctx)).unwrap());
1848         assert!(!ctx[g].is_dragging());
1849         assert_eq!(ctx[g].get_nodes()[0].position, (1.0, 0.0));
1850 
1851         // An empty-space press clears the selection and is NOT consumed (legacy contract).
1852         assert!(!ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 700.0, 550.0, ctx)).unwrap());
1853         assert_eq!(ctx[g].selected_node(), None);
1854     }
1855 
1856     /// Dropping a dragged node onto a wire splices it in: the drop reports
1857     /// (dragged id, the wire's upstream NAME, the wire's downstream id) for
1858     /// the host to rewire both Input params. A drop away from every wire
1859     /// reports nothing, and the handshake is take-once.
1860     #[test]
1861     fn node_dropped_on_a_wire_reports_a_splice() {
1862         for style in WireStyle::ALL {
1863             node_dropped_on_a_wire_reports_a_splice_in(style);
1864         }
1865     }
1866 
1867     /// With swap-on-drop, a node dropped on another node trades cells with
1868     /// it and reports the swap — over the wire between them, which the
1869     /// ghost also touches. Without it, the drop walks to a free cell as it
1870     /// always did.
1871     #[test]
1872     fn a_node_dropped_on_a_node_swaps_with_it() {
1873         let build = |swap: bool| {
1874             let mut ctx = UiContext::new();
1875             let g = ctx.insert(Graph::new());
1876             WidgetHost::set_rect(&mut ctx[g], 0.0, 0.0, 800.0, 600.0);
1877             ctx[g].set_grid_pitch(100.0, 60.0);
1878             ctx[g].set_node_size(80.0, 40.0);
1879             ctx[g].set_grid_origin(140.0, 120.0);
1880             ctx[g].set_grid_snap_enabled(true);
1881             ctx[g].set_swap_on_drop(swap);
1882             let node = |id: &str, name: &str, col: f32, row: f32, input: &str| GraphNode {
1883                 id: id.into(),
1884                 name: name.into(),
1885                 position: (col, row),
1886                 parameters: vec![("Input".to_string(), input.to_string(), "node".to_string())],
1887                 geom_visible: true,
1888                 node_type: String::new(),
1889                 inputs: 1,
1890                 outputs: 1,
1891             };
1892             // alpha above beta, beta reading alpha.
1893             ctx[g].set_nodes(&[node("a", "alpha", 0.0, 0.0, ""), node("b", "beta", 0.0, 2.0, "alpha")]);
1894             // Drag alpha by its middle onto beta's middle.
1895             assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 140.0, 120.0, ctx)).unwrap());
1896             ctx[g].drag_begin(140.0, 120.0);
1897             assert!(ctx[g].drag_update(140.0, 240.0));
1898             (g, ctx)
1899         };
1900 
1901         let (g, mut ctx) = build(true);
1902         assert_eq!(ctx[g].swap_target_idx(), Some(1), "beta is the swap target while the ghost is on it");
1903         assert_eq!(ctx[g].node_rect(0), ctx[g].node_rect(1), "the ghost sits on beta's cell, where a swap lands");
1904         assert_eq!(ctx[g].drop_target_cell_rect(), ctx[g].node_rect(1), "and its cell is where the drop lands");
1905         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Released, 140.0, 240.0, ctx)).unwrap());
1906         assert_eq!(GraphController::take_pending_swap(&mut *ctx[g]), Some(("a".to_string(), "b".to_string())));
1907         assert_eq!(GraphController::take_pending_swap(&mut *ctx[g]), None, "take-once");
1908         assert_eq!(GraphController::take_pending_splice(&mut *ctx[g]), None, "a swap is not a splice");
1909         let nodes = GraphController::get_nodes(&*ctx[g]);
1910         assert_eq!((nodes[0].position, nodes[1].position), ((0.0, 2.0), (0.0, 0.0)), "the two traded cells");
1911 
1912         let (g, mut ctx) = build(false);
1913         assert_eq!(ctx[g].swap_target_idx(), None);
1914         // No swap to make: the ghost snaps to the free cell a drop walks to,
1915         // not over beta, where it could not stay.
1916         let ghost = ctx[g].node_rect(0).unwrap();
1917         assert_ne!(Some(ghost), ctx[g].node_rect(1), "the ghost does not sit on a taken cell");
1918         assert_eq!(Some(ghost), ctx[g].drop_target_cell_rect(), "it sits where the drop lands");
1919         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Released, 140.0, 240.0, ctx)).unwrap());
1920         assert_eq!(GraphController::take_pending_swap(&mut *ctx[g]), None);
1921         let nodes = GraphController::get_nodes(&*ctx[g]);
1922         assert_eq!(nodes[1].position, (0.0, 2.0), "beta stays");
1923         assert_ne!(nodes[0].position, (0.0, 2.0), "alpha walks off the taken cell");
1924     }
1925 
1926     fn node_dropped_on_a_wire_reports_a_splice_in(style: WireStyle) {
1927         let mut ctx = UiContext::new();
1928         let g = ctx.insert(Graph::new());
1929         ctx[g].set_wire_style(Some(style));
1930         WidgetHost::set_rect(&mut ctx[g], 0.0, 0.0, 800.0, 600.0);
1931         ctx[g].set_grid_pitch(100.0, 60.0);
1932         ctx[g].set_node_size(80.0, 40.0);
1933         ctx[g].set_grid_origin(140.0, 120.0);
1934         ctx[g].set_grid_snap_enabled(true);
1935         let node = |id: &str, name: &str, col: f32, row: f32, params: Vec<(String, String, String)>| GraphNode {
1936             id: id.into(),
1937             name: name.into(),
1938             position: (col, row),
1939             parameters: params,
1940             geom_visible: true,
1941             node_type: String::new(),
1942             inputs: 1,
1943             outputs: 1,
1944         };
1945         let p = |v: &str| vec![("Input".to_string(), v.to_string(), "text".to_string())];
1946         // alpha → beta wire runs through the empty cell (1, 0) between them;
1947         // gamma sits below, unwired.
1948         ctx[g].set_nodes(&[
1949             node("a", "alpha", 0.0, 0.0, Vec::new()),
1950             node("b", "beta", 2.0, 0.0, p("alpha")),
1951             node("c", "gamma", 0.0, 2.0, p("")),
1952         ]);
1953 
1954         // Drag gamma's body onto the wire's horizontal run (cell (1, 0)).
1955         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 240.0, ctx)).unwrap());
1956         ctx[g].drag_begin(110.0, 240.0);
1957         assert!(ctx[g].drag_update(210.0, 140.0));
1958         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 140.0, ctx)).unwrap());
1959 
1960         let splice = GraphController::take_pending_splice(&mut *ctx[g]);
1961         assert_eq!(
1962             splice,
1963             Some(("c".to_string(), "alpha".to_string(), "b".to_string())),
1964             "{style:?}: drop on the wire must report (dragged, upstream name, downstream id)"
1965         );
1966         assert_eq!(GraphController::take_pending_splice(&mut *ctx[g]), None, "take-once");
1967 
1968         // A drop in open space reports nothing. Gamma landed in cell (1, 0)
1969         // — the free cell its splice drop resolved to — so drag it from
1970         // there down to open space clear of the wire.
1971         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 210.0, 110.0, ctx)).unwrap());
1972         ctx[g].drag_begin(210.0, 110.0);
1973         assert!(ctx[g].drag_update(210.0, 230.0));
1974         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 230.0, ctx)).unwrap());
1975         assert_eq!(GraphController::take_pending_splice(&mut *ctx[g]), None);
1976     }
1977 
1978     /// A cell a wire runs through names that wire, in every style; a cell
1979     /// clear of every wire names none.
1980     #[test]
1981     fn a_cell_on_a_wire_names_the_wire() {
1982         for style in WireStyle::ALL {
1983             let mut g = Graph::new();
1984             g.set_wire_style(Some(style));
1985             WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
1986             g.set_grid_pitch(100.0, 60.0);
1987             g.set_node_size(80.0, 40.0);
1988             g.set_grid_origin(140.0, 120.0);
1989             let node = |id: &str, name: &str, col: f32, row: f32, input: &str| GraphNode {
1990                 id: id.into(),
1991                 name: name.into(),
1992                 position: (col, row),
1993                 parameters: vec![("Input".to_string(), input.to_string(), "node".to_string())],
1994                 geom_visible: true,
1995                 node_type: String::new(),
1996                 inputs: 1,
1997                 outputs: 1,
1998             };
1999             // alpha above beta, a cell between them.
2000             g.set_nodes(&[node("a", "alpha", 0.0, 0.0, ""), node("b", "beta", 0.0, 2.0, "alpha")]);
2001             assert_eq!(
2002                 g.input_wire_through_cell(0.0, 1.0),
2003                 Some(("a".to_string(), "b".to_string())),
2004                 "{style:?}"
2005             );
2006             assert_eq!(g.input_wire_through_cell(2.0, 1.0), None, "{style:?}: off to the side");
2007         }
2008     }
2009 
2010     /// Where a piece of a wire begins and ends.
2011     fn seg_ends(seg: &WireSeg) -> ((f32, f32), (f32, f32)) {
2012         match *seg {
2013             WireSeg::Line(a, b) => (a, b),
2014             WireSeg::Arc { c, r, a0, a1 } => (
2015                 (c.0 + r * a0.cos(), c.1 + r * a0.sin()),
2016                 (c.0 + r * a1.cos(), c.1 + r * a1.sin()),
2017             ),
2018         }
2019     }
2020 
2021     fn near(a: (f32, f32), b: (f32, f32)) -> bool {
2022         (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01
2023     }
2024 
2025     /// Every style but the orthogonal one is one unbroken run from the
2026     /// output port to the input port, down the graph and back up it: each
2027     /// piece begins where the last ended. The orthogonal one is three runs
2028     /// meeting square, which touch without overlapping so a translucent wire
2029     /// is one alpha throughout — the across run filling the corners.
2030     #[test]
2031     fn a_wire_thinner_than_a_device_pixel_is_that_pixel_fainter() {
2032         // At 2x a device pixel is half a logical one.
2033         assert_eq!(wire_stroke(3.0, 0.5), (3.0, 1.0));
2034         assert_eq!(wire_stroke(0.5, 0.5), (0.5, 1.0));
2035         assert_eq!(wire_stroke(0.25, 0.5), (0.5, 0.5));
2036         // At 1x the pixel is a whole one, and nothing draws narrower.
2037         assert_eq!(wire_stroke(0.5, 1.0), (1.0, 0.5));
2038         assert_eq!(wire_stroke(0.0, 1.0), (1.0, 0.0));
2039     }
2040 
2041     #[test]
2042     fn every_wire_style_runs_from_port_to_port() {
2043         let t = 6.0;
2044         let cases = [((100.0, 100.0), (300.0, 260.0)), ((300.0, 260.0), (100.0, 100.0)), ((100.0, 100.0), (100.0, 300.0))];
2045         for (start, end) in cases {
2046             for style in [WireStyle::Rounded, WireStyle::Bezier, WireStyle::Straight] {
2047                 let segs = wire_path(style, start, end, t, 20.0, None);
2048                 assert!(near(seg_ends(&segs[0]).0, start), "{style:?} {start:?}->{end:?} starts at the output");
2049                 assert!(near(seg_ends(segs.last().unwrap()).1, end), "{style:?} {start:?}->{end:?} ends at the input");
2050                 for w in segs.windows(2) {
2051                     assert!(near(seg_ends(&w[0]).1, seg_ends(&w[1]).0), "{style:?} {start:?}->{end:?} is unbroken");
2052                 }
2053             }
2054             let segs = wire_path(WireStyle::Rounded, start, end, t, 20.0, None);
2055             if start.0 != end.0 {
2056                 assert_eq!(segs.iter().filter(|s| matches!(s, WireSeg::Arc { .. })).count(), 2, "two rounded bends");
2057             }
2058         }
2059 
2060         let segs = wire_path(WireStyle::Orthogonal, (100.0, 100.0), (300.0, 260.0), t, 20.0, None);
2061         assert_eq!(
2062             segs,
2063             vec![
2064                 WireSeg::Line((100.0, 100.0), (100.0, 177.0)),
2065                 WireSeg::Line((97.0, 180.0), (303.0, 180.0)),
2066                 WireSeg::Line((300.0, 183.0), (300.0, 260.0)),
2067             ]
2068         );
2069     }
2070 
2071     /// A wire running down several rows turns on the first lattice line
2072     /// below its source, not halfway: from row -1 to row 3 it turned on
2073     /// row 1's line, after running down through the node standing there.
2074     /// Between adjacent rows there is no line between the bodies, and the
2075     /// wire turns halfway as it always did.
2076     #[test]
2077     fn a_wire_turns_on_the_first_line_below_its_source() {
2078         let mut g = Graph::new();
2079         WidgetHost::set_rect(&mut g, 0.0, 0.0, 1200.0, 900.0);
2080         g.set_grid_pitch(140.0, 70.0);
2081         g.set_node_size(80.0, 40.0);
2082         g.set_grid_origin(100.0, 200.0);
2083         let node = |name: &str, col: f32, row: f32, wires: &[&str]| GraphNode {
2084             id: name.into(),
2085             name: name.into(),
2086             position: (col, row),
2087             parameters: wires.iter().enumerate().map(|(k, w)| (format!("in{k}"), w.to_string(), "node".to_string())).collect(),
2088             geom_visible: true,
2089             node_type: String::new(),
2090             inputs: wires.len().max(1),
2091             outputs: 1,
2092         };
2093         // The simnet the user found it in: input1 above pull1, relax1
2094         // reading both, a column to the right and four rows down.
2095         g.set_nodes(&[
2096             node("input1", 3.0, -1.0, &[]),
2097             node("pull1", 3.0, 1.0, &["input1"]),
2098             node("relax1", 4.0, 3.0, &["pull1", "input1"]),
2099         ]);
2100         let line = |row: f32| 200.0 + row * 70.0;
2101         let across = |segs: &[WireSeg]| {
2102             segs.iter()
2103                 .find_map(|s| match *s {
2104                     WireSeg::Line(a, b) if (a.1 - b.1).abs() < 0.01 && (a.0 - b.0).abs() > 1.0 => Some(a.1),
2105                     _ => None,
2106                 })
2107                 .expect("a run across")
2108         };
2109         for style in [WireStyle::Orthogonal, WireStyle::Rounded] {
2110             g.set_wire_style(Some(style));
2111             // input1 (row -1) into relax1's second port (row 3): row 0's line.
2112             let segs = g.wire_segments(0, 2, 1).unwrap();
2113             assert_eq!(across(&segs), line(0.0), "{style:?}: the first line below the source");
2114             // Nothing of it stands in pull1's body, under the source.
2115             let (px, py, pw, ph) = g.node_rect(1).unwrap();
2116             assert!(
2117                 !segs.iter().any(|s| matches!(*s, WireSeg::Line(a, b) if segment_meets_rect(a, b, px, py, px + pw, py + ph))),
2118                 "{style:?}: the wire runs through pull1"
2119             );
2120             // pull1 (row 1) into relax1 (row 3): row 2's line, as before.
2121             assert_eq!(across(&g.wire_segments(1, 2, 0).unwrap()), line(2.0));
2122         }
2123         // Adjacent rows: no line between the bodies, so halfway.
2124         g.set_nodes(&[node("a", 0.0, 0.0, &[]), node("b", 1.0, 1.0, &["a"])]);
2125         g.set_wire_style(Some(WireStyle::Orthogonal));
2126         let (start, end) = g.wire_endpoints(0, 1, 0).unwrap();
2127         assert_eq!(across(&g.wire_segments(0, 1, 0).unwrap()), start.1 + (end.1 - start.1) / 2.0);
2128         // A wire running up turns halfway too.
2129         g.set_nodes(&[node("a", 0.0, 3.0, &[]), node("b", 1.0, 0.0, &["a"])]);
2130         let (start, end) = g.wire_endpoints(0, 1, 0).unwrap();
2131         assert_eq!(across(&g.wire_segments(0, 1, 0).unwrap()), start.1 + (end.1 - start.1) / 2.0);
2132     }
2133 
2134     #[test]
2135     fn a_wire_style_is_named_either_way_in_any_case() {
2136         for style in WireStyle::ALL {
2137             assert_eq!(WireStyle::parse(style.name()), Some(style));
2138             assert_eq!(WireStyle::parse(style.label()), Some(style));
2139             assert_eq!(WireStyle::parse(&style.name().to_uppercase()), Some(style));
2140         }
2141         assert_eq!(WireStyle::parse("wiggly"), None);
2142     }
2143 
2144     /// The grid has one size per axis, the pitch, and a node is CENTRED on
2145     /// the intersection its position names — its body straddles the lines
2146     /// rather than filling a cell between them. The body's size is its own:
2147     /// changing the pitch moves nodes apart without resizing them.
2148     #[test]
2149     fn nodes_are_centred_on_lattice_intersections() {
2150         let mut g = two_nodes();
2151         assert_eq!(g.grid_pitch(), (100.0, 60.0));
2152         assert_eq!(g.node_size(), (80.0, 40.0));
2153         g.set_grid_pitch(200.0, 90.0);
2154         assert_eq!(g.node_size(), (80.0, 40.0), "the pitch does not size the node");
2155         g.set_grid_pitch(100.0, 60.0);
2156         // Node a is on the (140, 120) intersection: its 80 x 40 body is
2157         // centred there.
2158         let (x, y, w, h) = g.node_rect(0).unwrap();
2159         assert_eq!((x + w / 2.0, y + h / 2.0), (140.0, 120.0));
2160         assert_eq!((x, y, w, h), (100.0, 100.0, 80.0, 40.0));
2161         // Node b, at (1, 1), is one pitch away along each axis.
2162         let (x, y, w, h) = g.node_rect(1).unwrap();
2163         assert_eq!((x + w / 2.0, y + h / 2.0), (240.0, 180.0));
2164     }
2165 
2166     /// The cell-and-gap setters describe the same lattice — a cell plus its
2167     /// gap is a pitch, and the cell is the node body — in either order, so a
2168     /// host still speaking them gets exactly the geometry it asked for.
2169     #[test]
2170     fn cell_and_gap_setters_describe_the_same_lattice() {
2171         let mut g = Graph::new();
2172         g.set_grid_sizes(140.0, 70.0);
2173         g.set_skipped_sizes(35.0, 35.0);
2174         assert_eq!(g.grid_pitch(), (175.0, 105.0));
2175         assert_eq!(g.node_size(), (140.0, 70.0));
2176         assert_eq!(g.grid_sizes(), (140.0, 70.0));
2177         assert_eq!(g.skipped_sizes(), (35.0, 35.0));
2178 
2179         let mut h = Graph::new();
2180         h.set_skipped_sizes(35.0, 35.0);
2181         h.set_grid_sizes(140.0, 70.0);
2182         assert_eq!(h.grid_pitch(), (175.0, 105.0));
2183         assert_eq!(h.node_size(), (140.0, 70.0));
2184     }
2185 
2186     #[test]
2187     fn port_click_starts_and_completes_a_connection() {
2188         let mut ctx = UiContext::new();
2189         let g = ctx.insert(two_nodes());
2190 
2191         // Ports float OUTSIDE the node box (port_center): node a's output
2192         // hangs below the bottom-center of (100,100,80,40), node b's input
2193         // above the top-center of (200,160,80,40).
2194         let (ax, ay) = ctx[g].port_center(0, PortType::Output, 0).expect("node a output port");
2195         assert!(ay > 140.0, "output port sits below the node's bottom edge");
2196         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, ax, ay, ctx)).unwrap());
2197         let (bx, by) = ctx[g].port_center(1, PortType::Input, 0).expect("node b input port");
2198         assert!(by < 160.0, "input port sits above the node's top edge");
2199         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, bx, by, ctx)).unwrap());
2200 
2201         let pending = GraphController::take_pending_connection(&mut *ctx[g]);
2202         assert_eq!(pending, Some(("b".to_string(), "alpha".to_string())));
2203     }
2204 
2205     /// Every parameter a host types `node` is a wire, the k-th into input
2206     /// port k, and a connection dropped on a port says which; a host that
2207     /// types none keeps its one `input` wire.
2208     #[test]
2209     fn every_node_parameter_is_a_wire_into_its_own_port() {
2210         let mut ctx = UiContext::new();
2211         let g = ctx.insert(two_nodes());
2212         let wire = |n: &str, v: &str| (n.to_string(), v.to_string(), "node".to_string());
2213         let node = |id: &str, col: f32, row: f32, parameters: Vec<(String, String, String)>, inputs: usize| GraphNode {
2214             id: id.into(),
2215             name: id.into(),
2216             position: (col, row),
2217             parameters,
2218             geom_visible: true,
2219             node_type: String::new(),
2220             inputs,
2221             outputs: 1,
2222         };
2223         ctx[g].set_nodes(&[
2224             node("a", 0.0, 0.0, vec![], 0),
2225             node("b", 2.0, 0.0, vec![], 0),
2226             node("sw", 1.0, 2.0, vec![wire("Input", "a"), wire("Input 2", "b"), wire("Input 3", ""), ("Index".into(), "1".into(), "spinbox".into())], 3),
2227             node("old", 3.0, 2.0, vec![("input".into(), "b".into(), "string".into())], 1),
2228         ]);
2229         assert_eq!(ctx[g].wire_pairs(), vec![(0, 2, 0), (1, 2, 1), (1, 3, 0)], "two wires into the switch, each its port; the untyped host's one");
2230         let (_, end) = ctx[g].wire_endpoints(1, 2, 1).unwrap();
2231         assert_eq!(Some(end), ctx[g].port_center(2, PortType::Input, 1), "into its own port");
2232 
2233         // Dropped on the switch's third port: port 2.
2234         let (ax, ay) = ctx[g].port_center(0, PortType::Output, 0).unwrap();
2235         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, ax, ay, ctx)).unwrap());
2236         let (px, py) = ctx[g].port_center(2, PortType::Input, 2).unwrap();
2237         assert!(ctx.lend_h(g, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, px, py, ctx)).unwrap());
2238         assert_eq!(GraphController::take_pending_connection_to_port(&mut *ctx[g]), Some(("sw".to_string(), "a".to_string(), 2)));
2239     }
2240 
2241     #[test]
2242     fn the_painted_geometry_is_the_tagged_geometry_rounded() {
2243         use crate::widget::WidgetHostExt;
2244         let g = two_nodes();
2245 
2246         // What a host drawing the nodes itself reads (the designer) and what the graph paints
2247         // describe the same quads: the paint adds only the widget background up front.
2248         let plain: Vec<_> = g
2249             .geometry_quads_tagged(g.content_rect())
2250             .into_iter()
2251             .map(|(qx, qy, qw, qh, qc, _)| (qx, qy, qw, qh, qc))
2252             .collect();
2253         let rounded: Vec<_> = g
2254             .painted_prims()
2255             .into_iter()
2256             .filter_map(|prim| match prim {
2257                 crate::scene::paint::Prim::RoundedRect { rect, radius, corners, color } => {
2258                     Some((rect.x, rect.y, rect.width, rect.height, radius, color, corners))
2259                 }
2260                 _ => None,
2261             })
2262             .collect();
2263         assert!(!plain.is_empty());
2264         assert_eq!(rounded.len(), plain.len() + 1);
2265         for ((px, py, pw, ph, pc), (rx, ry, rw, rh, _, rc, _)) in plain.iter().zip(rounded.iter().skip(1)) {
2266             assert_eq!((px, py, pw, ph, pc), (rx, ry, rw, rh, rc));
2267         }
2268 
2269         // Node bodies carry the node corner radius.
2270         let node_radius = crate::layout::graph_node_corner_radius();
2271         let node_entries: Vec<_> = rounded
2272             .iter()
2273             .filter(|(qx, qy, qw, qh, ..)| g.is_node_rect(*qx, *qy, *qw, *qh))
2274             .collect();
2275         assert_eq!(node_entries.len(), 2, "both node bodies present");
2276         for entry in node_entries {
2277             assert_eq!(entry.4, node_radius);
2278             assert_eq!(entry.6, (true, true, true, true));
2279         }
2280     }
2281 }