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