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 }