tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
widgets/nodegraph/src/lib.rs
Historia
Quién tocó qué
crudo
1//! `llimphi-widget-nodegraph` — lienzo de nodos con pins y cables
2//! Bezier sobre Llimphi.
3//!
4//! Modelo declarativo de un grafo dirigido: cada frame, el caller pasa
5//! la lista actual de [`NodeSpec`]s + [`Wire`]s y el widget pinta:
6//!
7//! - el lienzo (fondo lleno);
8//! - cada nodo como un rect con título arriba y pins a los lados
9//! (entradas a la izquierda, salidas a la derecha);
10//! - los cables entre `(node_a, output_pin_a)` y `(node_b, input_pin_b)`
11//! como Bezier cúbicas con tangentes horizontales (mismo look que
12//! `pluma-editor-llimphi::multilienzo_editor::carril_editor`).
13//!
14//! El widget no mantiene estado: el caller acumula posición de nodos +
15//! cables en su `Model` y le pasa handlers para los dos eventos
16//! interactivos:
17//!
18//! - **mover un nodo** — `on_drag_node(node_id, phase, dx, dy)` se
19//! invoca al arrastrar la title bar de un nodo. El handler suma el
20//! delta a la posición persistida.
21//! - **conectar dos pins** — al arrastrar desde un pin de salida y
22//! soltar sobre un pin de entrada, `on_connect(from_node, from_out,
23//! to_node, to_in)` se invoca para que el caller materialice el
24//! `Wire` en su modelo.
25//!
26//! Reusable por: pluma (visualizador DAG), nakui (fórmulas yupay),
27//! tullpu (ajustes no destructivos), dominium (sistemas), takiy
28//! (cadena de audio), pluma-notebook (kernel-DAG visual).
29
30#![forbid(unsafe_code)]
31
32use std::collections::{HashMap, HashSet, VecDeque};
33use std::sync::Arc;
34
35use llimphi_ui::llimphi_layout::taffy::{
36 prelude::{length, percent, Dimension, Position, Size, Style},
37 Rect,
38};
39use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Stroke};
40use llimphi_ui::llimphi_raster::peniko::Color;
41use llimphi_ui::llimphi_text::Alignment;
42use llimphi_ui::{DragPhase, Shadow, View};
43
44/// Identificador opaco de un nodo. El caller asigna estos valores; el
45/// widget los pasa de vuelta sin interpretarlos.
46pub type NodeId = u32;
47/// Índice del pin dentro de la lista `inputs` o `outputs` del nodo.
48pub type PinIdx = u16;
49
50/// Especificación de un nodo del grafo. El caller construye uno por
51/// nodo en cada `view`. Las posiciones son en pixels relativas al rect
52/// del lienzo.
53#[derive(Debug, Clone)]
54pub struct NodeSpec {
55 pub id: NodeId,
56 pub label: String,
57 /// Esquina superior-izquierda del nodo, en coordenadas del lienzo.
58 pub x: f32,
59 pub y: f32,
60 /// Labels de los pins de entrada. Cantidad = altura mínima del nodo.
61 pub inputs: Vec<String>,
62 /// Labels de los pins de salida.
63 pub outputs: Vec<String>,
64}
65
66/// Cable entre el pin de salida de un nodo y el pin de entrada de otro.
67/// El widget no valida ciclos ni direcciones — esa política vive en el
68/// caller.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct Wire {
71 pub from_node: NodeId,
72 pub from_output: PinIdx,
73 pub to_node: NodeId,
74 pub to_input: PinIdx,
75}
76
77/// Arista **simple** nodo→nodo, sin pins. A diferencia de [`Wire`] (que engancha
78/// en puertos concretos de dataflow), una arista se ancla en el **centro
79/// vertical** de cada nodo y en el **borde horizontal más cercano** al otro
80/// nodo (como `wire_nearest_side`, pero sin depender de que existan pins). Es la
81/// forma de primera clase para grafos de relación / contención / DAG genérico
82/// donde los nodos no tienen puertos — antes había que fabricar pins dummy de
83/// string vacío (churay, navigator, anim-studio lo hacían así).
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Edge {
86 pub from_node: NodeId,
87 pub to_node: NodeId,
88}
89
90/// Tinte opcional de un nodo resaltado. Cada campo `Some` sobrescribe
91/// el color correspondiente de la paleta global para *ese* nodo; los
92/// `None` heredan la paleta. Sirve para que el caller marque un subgrafo
93/// (p.ej. el cono afectado por un morfismo) sin tocar el resto.
94#[derive(Debug, Clone, Copy, Default)]
95pub struct NodeTint {
96 pub bg_node: Option<Color>,
97 pub bg_title: Option<Color>,
98 pub fg_title: Option<Color>,
99}
100
101/// Paleta del lienzo. Hereda del [`llimphi_theme::Theme`] semántico.
102#[derive(Debug, Clone, Copy)]
103pub struct NodegraphPalette {
104 pub bg_canvas: Color,
105 pub bg_node: Color,
106 pub bg_title: Color,
107 pub fg_title: Color,
108 pub fg_pin_label: Color,
109 pub pin_input: Color,
110 pub pin_output: Color,
111 pub pin_drop_hover: Color,
112 pub wire: Color,
113 pub border: Color,
114}
115
116impl Default for NodegraphPalette {
117 fn default() -> Self {
118 Self::from_theme(&llimphi_theme::Theme::dark())
119 }
120}
121
122impl NodegraphPalette {
123 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
124 Self {
125 bg_canvas: t.bg_app,
126 bg_node: t.bg_panel,
127 bg_title: t.bg_panel_alt,
128 fg_title: t.fg_text,
129 fg_pin_label: t.fg_muted,
130 pin_input: t.accent,
131 pin_output: t.accent,
132 pin_drop_hover: t.bg_selected,
133 wire: t.accent,
134 border: t.border,
135 }
136 }
137}
138
139/// Geometría del nodo y de los pins.
140#[derive(Debug, Clone, Copy)]
141pub struct NodegraphMetrics {
142 pub node_width: f32,
143 pub title_height: f32,
144 pub pin_row_height: f32,
145 pub pin_radius: f32,
146 pub pin_label_size: f32,
147 pub title_text_size: f32,
148 pub wire_stroke: f32,
149 pub node_radius: f64,
150 /// Si `true`, cada cable **sale y entra por el lado más cercano** al otro
151 /// nodo (derecha→izquierda si el destino está a la derecha, izquierda→derecha
152 /// si está a la izquierda), en vez de siempre salida-derecha → entrada-
153 /// izquierda. Evita cables que dan la vuelta al nodo cuando el grafo no es
154 /// un DAG estricto de izquierda a derecha (p.ej. un grafo de relaciones).
155 /// Como cada nodo tiene un pin a cada lado, el cable engancha en un pin real
156 /// de ambos extremos. Default `false` (semántica direccional clásica).
157 pub wire_nearest_side: bool,
158 /// Elevación (blur de la sombra en px) de cada nodo sobre el fondo. `0.0`
159 /// (default) = sin sombra, para no cambiar el look de los consumidores
160 /// clásicos; el cockpit de DAG la enciende. Escala con el zoom.
161 pub node_elevacion: f32,
162 /// Si `true`, las **aristas simples** (`Edge`) se rutean verticalmente:
163 /// anclan en el borde superior/inferior de cada nodo con tangente vertical
164 /// (para un DAG apilado de arriba hacia abajo). `false` (default) = ruteo
165 /// horizontal clásico. No afecta a los cables con pin (`Wire`).
166 pub vertical: bool,
167}
168
169impl Default for NodegraphMetrics {
170 fn default() -> Self {
171 Self {
172 node_width: 160.0,
173 title_height: 22.0,
174 pin_row_height: 18.0,
175 pin_radius: 5.0,
176 pin_label_size: 10.0,
177 title_text_size: 11.0,
178 wire_stroke: 1.6,
179 node_radius: 4.0,
180 wire_nearest_side: false,
181 node_elevacion: 0.0,
182 vertical: false,
183 }
184 }
185}
186
187impl NodegraphMetrics {
188 /// Alto total del rect que ocupa un nodo con `n_in` entradas y
189 /// `n_out` salidas. El cuerpo crece con el lado que tenga más pins.
190 pub fn node_height(&self, n_in: usize, n_out: usize) -> f32 {
191 let rows = n_in.max(n_out).max(1) as f32;
192 self.title_height + rows * self.pin_row_height + 6.0
193 }
194
195 /// Centro Y absoluto de un pin de entrada del nodo cuyo top-left es
196 /// `(_x, node_y)`. Sirve también para outputs (misma alineación).
197 pub fn input_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
198 node_y
199 + self.title_height
200 + 3.0
201 + (pin as f32 + 0.5) * self.pin_row_height
202 }
203
204 pub fn output_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
205 self.input_pin_y(node_y, pin)
206 }
207
208 /// Escala todas las longitudes por `zoom` (para el render con cámara). Los
209 /// tamaños de fuente escalan también, así el zoom es real (no un stretch).
210 fn scaled(&self, zoom: f32) -> Self {
211 Self {
212 node_width: self.node_width * zoom,
213 title_height: self.title_height * zoom,
214 pin_row_height: self.pin_row_height * zoom,
215 pin_radius: self.pin_radius * zoom,
216 pin_label_size: self.pin_label_size * zoom,
217 title_text_size: self.title_text_size * zoom,
218 wire_stroke: self.wire_stroke * zoom,
219 node_radius: self.node_radius * zoom as f64,
220 wire_nearest_side: self.wire_nearest_side,
221 node_elevacion: self.node_elevacion * zoom,
222 vertical: self.vertical,
223 }
224 }
225}
226
227/// Cámara del lienzo: desplazamiento (pan) + escala (zoom). El caller la
228/// guarda en su `Model`; el widget transforma las coordenadas de canvas de
229/// cada nodo/cable a coordenadas de pantalla con `screen = canvas * zoom +
230/// pan`. `Default` = identidad (sin pan, zoom 1) → render idéntico al clásico.
231#[derive(Debug, Clone, Copy, PartialEq)]
232pub struct Camera {
233 pub pan_x: f32,
234 pub pan_y: f32,
235 pub zoom: f32,
236}
237
238impl Default for Camera {
239 fn default() -> Self {
240 Self { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 }
241 }
242}
243
244/// Zoom mínimo/máximo del lienzo.
245pub const ZOOM_MIN: f32 = 0.25;
246pub const ZOOM_MAX: f32 = 4.0;
247
248impl Camera {
249 /// Coordenada de pantalla de un punto de canvas.
250 pub fn apply(&self, x: f32, y: f32) -> (f32, f32) {
251 (x * self.zoom + self.pan_x, y * self.zoom + self.pan_y)
252 }
253
254 /// Coordenada de canvas de un punto de pantalla (inversa de [`apply`]).
255 pub fn invert(&self, sx: f32, sy: f32) -> (f32, f32) {
256 let z = self.zoom.max(f32::EPSILON);
257 ((sx - self.pan_x) / z, (sy - self.pan_y) / z)
258 }
259}
260
261/// Nuevo zoom tras un `delta` de rueda (típico `dy`), multiplicativo y
262/// clampeado a `[ZOOM_MIN, ZOOM_MAX]`. `delta > 0` acerca. Pura y testeable.
263pub fn zoom_step(zoom: f32, wheel_delta: f32) -> f32 {
264 // Factor exponencial suave: cada tick escala ~10 %.
265 let factor = (wheel_delta * 0.1).exp();
266 (zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX)
267}
268
269/// Aplica un nuevo zoom manteniendo **fijo el centro del viewport** (el punto
270/// de canvas bajo el centro no se mueve en pantalla). Devuelve la cámara
271/// ajustada. El caller pasa el tamaño del viewport (que conoce de su layout),
272/// igual que el contrato del `scroll` widget. Sin esto, el zoom "escapa" hacia
273/// la esquina.
274pub fn zoom_at_center(cam: Camera, new_zoom: f32, viewport_w: f32, viewport_h: f32) -> Camera {
275 let new_zoom = new_zoom.clamp(ZOOM_MIN, ZOOM_MAX);
276 let (cx, cy) = (viewport_w * 0.5, viewport_h * 0.5);
277 // Punto de canvas actualmente bajo el centro.
278 let (canvas_cx, canvas_cy) = cam.invert(cx, cy);
279 // Reponer el pan para que ese mismo punto siga bajo el centro con el zoom nuevo.
280 Camera {
281 pan_x: cx - canvas_cx * new_zoom,
282 pan_y: cy - canvas_cy * new_zoom,
283 zoom: new_zoom,
284 }
285}
286
287// ─────────────────────────────────────────────────────────────────────────
288// Navegación de DAGs — layout automático, encuadre y conos (puros, testeables)
289// ─────────────────────────────────────────────────────────────────────────
290
291/// Posición asignada por [`layout_por_rango`] a un nodo.
292#[derive(Debug, Clone, Copy, PartialEq)]
293pub struct LayoutPos {
294 pub id: NodeId,
295 pub x: f32,
296 pub y: f32,
297}
298
299/// Opciones del layout por rango topológico.
300#[derive(Debug, Clone, Copy)]
301pub struct LayoutRango {
302 /// Margen desde el origen del lienzo.
303 pub margin: f32,
304 /// Separación horizontal extra entre columnas (rangos).
305 pub col_gap: f32,
306 /// Separación vertical entre filas dentro de un rango.
307 pub row_gap: f32,
308 /// Si `true`, los rangos apilan de **arriba hacia abajo** (eje principal Y) y
309 /// los nodos de un rango se reparten en X. `false` (default) = rangos de
310 /// izquierda a derecha. En ambos casos cada rango se **centra** respecto del
311 /// más poblado (autoequilibrado).
312 pub vertical: bool,
313}
314
315impl Default for LayoutRango {
316 fn default() -> Self {
317 Self { margin: 24.0, col_gap: 36.0, row_gap: 12.0, vertical: false }
318 }
319}
320
321/// Asigna posiciones a un DAG por **rango topológico**: la columna de un nodo =
322/// longitud del camino más largo desde una raíz (rank), la fila = su orden
323/// (estable) dentro del rango. `edges` son aristas dirigidas `(from, to)`. Es la
324/// única implementación de este layout — antes vivía atada a
325/// `pluma-notebook-graph`. Determinista y pura. Nodos en ciclo o inalcanzables
326/// caen al rango 0 (mejor esfuerzo, sin colgarse).
327pub fn layout_por_rango(
328 ids: &[NodeId],
329 edges: &[(NodeId, NodeId)],
330 metrics: &NodegraphMetrics,
331 opts: LayoutRango,
332) -> Vec<LayoutPos> {
333 let n = ids.len();
334 let index: HashMap<NodeId, usize> = ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
335
336 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
337 let mut indeg = vec![0usize; n];
338 for &(f, t) in edges {
339 if let (Some(&fi), Some(&ti)) = (index.get(&f), index.get(&t)) {
340 adj[fi].push(ti);
341 indeg[ti] += 1;
342 }
343 }
344
345 // Kahn + longest-path para el rank.
346 let mut rank = vec![0i32; n];
347 let mut remaining = indeg.clone();
348 let mut queue: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
349 while let Some(u) = queue.pop_front() {
350 for &v in &adj[u] {
351 if rank[u] + 1 > rank[v] {
352 rank[v] = rank[u] + 1;
353 }
354 remaining[v] -= 1;
355 if remaining[v] == 0 {
356 queue.push_back(v);
357 }
358 }
359 }
360
361 let max_rank = rank.iter().copied().max().unwrap_or(0);
362 let n_ranks = (max_rank + 1) as usize;
363
364 // Conteo por rango, para centrar cada rango respecto del más poblado.
365 let mut count = vec![0i32; n_ranks];
366 for &r in &rank {
367 count[r as usize] += 1;
368 }
369 let max_count = count.iter().copied().max().unwrap_or(1).max(1);
370
371 let node_h = metrics.node_height(1, 1);
372 // Eje principal = entre rangos; eje cruzado = entre nodos de un rango.
373 let (main_step, cross_step) = if opts.vertical {
374 (node_h + opts.row_gap, metrics.node_width + opts.col_gap)
375 } else {
376 (metrics.node_width + opts.col_gap, node_h + opts.row_gap)
377 };
378
379 let mut row_in_rank = vec![0i32; n_ranks];
380 let mut out = Vec::with_capacity(n);
381 for (i, &id) in ids.iter().enumerate() {
382 let r = rank[i] as usize;
383 let row = row_in_rank[r];
384 row_in_rank[r] += 1;
385 // Autoequilibrado: media diferencia de población respecto del rango mayor.
386 let cross_offset = (max_count - count[r]) as f32 * cross_step * 0.5;
387 let main = opts.margin + rank[i] as f32 * main_step;
388 let cross = opts.margin + cross_offset + row as f32 * cross_step;
389 let (x, y) = if opts.vertical { (cross, main) } else { (main, cross) };
390 out.push(LayoutPos { id, x, y });
391 }
392 out
393}
394
395/// Cámara que **encuadra** todos los nodos en un viewport dado, con un pequeño
396/// margen relativo. Sin nodos → [`Camera::default`]. El zoom se clampa a
397/// `[ZOOM_MIN, ZOOM_MAX]`. Pura; base del botón "encuadrar" y del arranque
398/// centrado. Espeja el contrato de [`zoom_at_center`] (el caller pasa el tamaño
399/// del viewport que conoce de su layout).
400pub fn fit_to_view(
401 nodes: &[NodeSpec],
402 metrics: &NodegraphMetrics,
403 viewport_w: f32,
404 viewport_h: f32,
405) -> Camera {
406 if nodes.is_empty() || viewport_w <= 0.0 || viewport_h <= 0.0 {
407 return Camera::default();
408 }
409 let mut min_x = f32::INFINITY;
410 let mut min_y = f32::INFINITY;
411 let mut max_x = f32::NEG_INFINITY;
412 let mut max_y = f32::NEG_INFINITY;
413 for n in nodes {
414 let h = metrics.node_height(n.inputs.len(), n.outputs.len());
415 min_x = min_x.min(n.x);
416 min_y = min_y.min(n.y);
417 max_x = max_x.max(n.x + metrics.node_width);
418 max_y = max_y.max(n.y + h);
419 }
420 let content_w = (max_x - min_x).max(1.0);
421 let content_h = (max_y - min_y).max(1.0);
422 // 0.9 deja un respiro alrededor del contenido.
423 let zoom = ((viewport_w / content_w).min(viewport_h / content_h) * 0.9)
424 .clamp(ZOOM_MIN, ZOOM_MAX);
425 let content_cx = (min_x + max_x) * 0.5;
426 let content_cy = (min_y + max_y) * 0.5;
427 Camera {
428 pan_x: viewport_w * 0.5 - content_cx * zoom,
429 pan_y: viewport_h * 0.5 - content_cy * zoom,
430 zoom,
431 }
432}
433
434/// Nodos **aguas-arriba** de `id` (sus ancestros por `edges` dirigidas
435/// `(from, to)`), incluyendo `id`. Ordenado y sin duplicados. Sirve para
436/// resaltar qué toca *cercenar* o *conciliar* antes de confirmar.
437pub fn cono_ascendente(edges: &[(NodeId, NodeId)], id: NodeId) -> Vec<NodeId> {
438 let mut visited: HashSet<NodeId> = HashSet::new();
439 visited.insert(id);
440 let mut stack = vec![id];
441 while let Some(cur) = stack.pop() {
442 for &(f, t) in edges {
443 if t == cur && visited.insert(f) {
444 stack.push(f);
445 }
446 }
447 }
448 let mut out: Vec<NodeId> = visited.into_iter().collect();
449 out.sort_unstable();
450 out
451}
452
453/// Nodos **aguas-abajo** de `id` (sus descendientes por `edges`), incluyendo
454/// `id`. Ordenado y sin duplicados.
455pub fn cono_descendente(edges: &[(NodeId, NodeId)], id: NodeId) -> Vec<NodeId> {
456 let mut visited: HashSet<NodeId> = HashSet::new();
457 visited.insert(id);
458 let mut stack = vec![id];
459 while let Some(cur) = stack.pop() {
460 for &(f, t) in edges {
461 if f == cur && visited.insert(t) {
462 stack.push(t);
463 }
464 }
465 }
466 let mut out: Vec<NodeId> = visited.into_iter().collect();
467 out.sort_unstable();
468 out
469}
470
471type DragNodeFn<Msg> =
472 Arc<dyn Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
473type ConnectFn<Msg> = Arc<
474 dyn Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync,
475>;
476
477/// Codifica `(node_id, pin_idx)` en el `u64` que viaja como payload del
478/// drag de un pin. 32 bits superiores = node_id, 16 bits inferiores =
479/// pin_idx.
480#[inline]
481fn encode_payload(node: NodeId, pin: PinIdx) -> u64 {
482 ((node as u64) << 32) | (pin as u64)
483}
484
485#[inline]
486fn decode_payload(payload: u64) -> (NodeId, PinIdx) {
487 let node = (payload >> 32) as NodeId;
488 let pin = (payload & 0xFFFF) as PinIdx;
489 (node, pin)
490}
491
492/// Construye el lienzo de nodos. `on_drag_node` se invoca con el delta
493/// del cursor cuando el usuario arrastra la title bar de un nodo (las
494/// fases `Move` y `End` se reenvían tal cual). `on_connect` se invoca
495/// cuando el usuario suelta un cable iniciado en un pin de salida
496/// sobre un pin de entrada de otro nodo.
497pub fn nodegraph_view<Msg, FDrag, FConnect>(
498 nodes: &[NodeSpec],
499 wires: &[Wire],
500 palette: &NodegraphPalette,
501 metrics: &NodegraphMetrics,
502 on_drag_node: FDrag,
503 on_connect: FConnect,
504) -> View<Msg>
505where
506 Msg: Clone + Send + Sync + 'static,
507 FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
508 FConnect:
509 Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
510{
511 nodegraph_view_ex::<Msg, FDrag, FConnect, fn(NodeId) -> Option<Msg>>(
512 nodes,
513 wires,
514 palette,
515 metrics,
516 on_drag_node,
517 on_connect,
518 None,
519 )
520}
521
522/// Variante extendida con un handler opcional de click derecho sobre
523/// la title bar de cada nodo. Permite a la app montar acciones por-nodo
524/// (estilo "ejecutar desde aquí" en un notebook reactivo, o "duplicar
525/// este nodo" en un editor de cadena de audio) sin esperar a que el
526/// widget tenga un menú contextual propio.
527///
528/// `on_right_click_node` se evalúa una vez por nodo al construir la
529/// vista — si devuelve `Some(msg)`, el runtime emite ese `Msg` al hacer
530/// right-click sobre la title bar; `None` deja al nodo sin acción
531/// contextual.
532pub fn nodegraph_view_ex<Msg, FDrag, FConnect, FRight>(
533 nodes: &[NodeSpec],
534 wires: &[Wire],
535 palette: &NodegraphPalette,
536 metrics: &NodegraphMetrics,
537 on_drag_node: FDrag,
538 on_connect: FConnect,
539 on_right_click_node: Option<FRight>,
540) -> View<Msg>
541where
542 Msg: Clone + Send + Sync + 'static,
543 FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
544 FConnect:
545 Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
546 FRight: Fn(NodeId) -> Option<Msg>,
547{
548 nodegraph_view_styled(
549 nodes,
550 wires,
551 palette,
552 metrics,
553 on_drag_node,
554 on_connect,
555 on_right_click_node,
556 None,
557 None,
558 )
559}
560
561/// Variante con realce: además de los handlers, acepta dos closures de
562/// estilo evaluados en construcción —`node_tint(id)` tiñe nodos puntuales
563/// y `wire_tint(&Wire)` recolorea cables— para que el caller marque un
564/// subgrafo (cono afectado, ruta crítica, celda con error…) sin tocar la
565/// paleta global. Ambos `None` = render idéntico a [`nodegraph_view`].
566#[allow(clippy::too_many_arguments)]
567pub fn nodegraph_view_styled<Msg, FDrag, FConnect, FRight>(
568 nodes: &[NodeSpec],
569 wires: &[Wire],
570 palette: &NodegraphPalette,
571 metrics: &NodegraphMetrics,
572 on_drag_node: FDrag,
573 on_connect: FConnect,
574 on_right_click_node: Option<FRight>,
575 node_tint: Option<&dyn Fn(NodeId) -> Option<NodeTint>>,
576 wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
577) -> View<Msg>
578where
579 Msg: Clone + Send + Sync + 'static,
580 FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
581 FConnect:
582 Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
583 FRight: Fn(NodeId) -> Option<Msg>,
584{
585 let on_drag: DragNodeFn<Msg> = Arc::new(on_drag_node);
586 let on_connect: ConnectFn<Msg> = Arc::new(on_connect);
587
588 let painted = precompute_wires(nodes, wires, metrics, palette.wire, wire_tint);
589 let stroke_px = metrics.wire_stroke;
590
591 let mut children: Vec<View<Msg>> = Vec::with_capacity(nodes.len() + 1);
592
593 // Capa 0 — cables (van detrás de los nodos).
594 children.push(wires_layer(painted, stroke_px, 0.0));
595
596 // Capa 1..N — nodos.
597 for node in nodes {
598 let right_click_msg = on_right_click_node
599 .as_ref()
600 .and_then(|f| f(node.id));
601 let tint = node_tint.and_then(|f| f(node.id));
602 children.push(node_view(
603 node,
604 palette,
605 metrics,
606 &on_drag,
607 &on_connect,
608 right_click_msg,
609 tint,
610 false,
611 None,
612 ));
613 }
614
615 View::new(Style {
616 size: Size {
617 width: percent(1.0_f32),
618 height: percent(1.0_f32),
619 },
620 ..Default::default()
621 })
622 .fill(palette.bg_canvas)
623 .clip(true)
624 .children(children)
625}
626
627// ─────────────────────────────────────────────────────────────────────────
628// Variante con cámara (pan/zoom) + selección — `nodegraph_view_spec`
629// ─────────────────────────────────────────────────────────────────────────
630
631type NodeMsgFn<Msg> = Arc<dyn Fn(NodeId) -> Option<Msg> + Send + Sync>;
632type PanFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
633type ZoomFn<Msg> = Arc<dyn Fn(f32) -> Option<Msg> + Send + Sync>;
634
635/// Especificación completa del lienzo **navegable**: además de mover nodos y
636/// conectar pins, soporta **cámara** (pan/zoom), **selección** de nodos y
637/// **deselección** al clickear el fondo. Es el editor de grafo real (vs. el
638/// visualizador de las funciones `nodegraph_view*`, que son este spec con
639/// cámara identidad y sin selección).
640///
641/// Todo lo interactivo es opcional: deja los `None` y `Camera::default()` para
642/// obtener el comportamiento clásico. Las coordenadas de `nodes` son de
643/// **canvas** (lógicas); el widget las proyecta a pantalla con la cámara. El
644/// delta de arrastre de un nodo se devuelve ya **en coordenadas de canvas**
645/// (dividido por el zoom), así el caller lo suma directo a `node.x/y`.
646pub struct NodegraphSpec<'a, Msg: Clone + Send + Sync + 'static> {
647 pub nodes: &'a [NodeSpec],
648 pub wires: &'a [Wire],
649 /// Aristas simples nodo→nodo (sin pins), pintadas junto a los cables. Para
650 /// grafos de relación/DAG genérico; dejar `&[]` para el clásico de pins.
651 pub edges: &'a [Edge],
652 pub palette: &'a NodegraphPalette,
653 pub metrics: &'a NodegraphMetrics,
654 pub camera: Camera,
655 pub on_drag_node: DragNodeFn<Msg>,
656 pub on_connect: ConnectFn<Msg>,
657 pub on_right_click_node: Option<NodeMsgFn<Msg>>,
658 pub node_tint: Option<&'a dyn Fn(NodeId) -> Option<NodeTint>>,
659 pub wire_tint: Option<&'a dyn Fn(&Wire) -> Option<Color>>,
660 /// Recolorea aristas simples (p.ej. el cono resaltado). `None` = paleta.
661 pub edge_tint: Option<&'a dyn Fn(&Edge) -> Option<Color>>,
662 /// Fase 0..1 del **flujo** animado de las aristas (un guion sutil que corre
663 /// ancestro→descendiente). `0.0` = sin flujo. La app la avanza con un tick.
664 pub flujo: f32,
665 /// Predicado de selección: `true` = el nodo se pinta con borde de acento.
666 pub selected: Option<&'a dyn Fn(NodeId) -> bool>,
667 /// Click en el cuerpo de un nodo → `on_select(id)`.
668 pub on_select_node: Option<NodeMsgFn<Msg>>,
669 /// Click en el fondo vacío del lienzo → este `Msg` (deseleccionar).
670 pub on_canvas_click: Option<Msg>,
671 /// Arrastre del fondo vacío → `on_pan(phase, dx, dy)` (delta de pantalla).
672 pub on_pan: Option<PanFn<Msg>>,
673 /// Rueda sobre el lienzo → `on_zoom(dy)` (el caller resuelve con
674 /// [`zoom_step`]/[`zoom_at_center`]).
675 pub on_zoom: Option<ZoomFn<Msg>>,
676}
677
678/// Compone el lienzo navegable a partir de un [`NodegraphSpec`].
679pub fn nodegraph_view_spec<Msg>(spec: NodegraphSpec<'_, Msg>) -> View<Msg>
680where
681 Msg: Clone + Send + Sync + 'static,
682{
683 let NodegraphSpec {
684 nodes,
685 wires,
686 edges,
687 palette,
688 metrics,
689 camera,
690 on_drag_node,
691 on_connect,
692 on_right_click_node,
693 node_tint,
694 wire_tint,
695 edge_tint,
696 flujo,
697 selected,
698 on_select_node,
699 on_canvas_click,
700 on_pan,
701 on_zoom,
702 } = spec;
703
704 let zoom = camera.zoom.max(f32::EPSILON);
705 // Métricas escaladas + nodos en coordenadas de PANTALLA (canvas·zoom+pan).
706 let scaled = metrics.scaled(zoom);
707 let screen_nodes: Vec<NodeSpec> = nodes
708 .iter()
709 .map(|n| {
710 let (sx, sy) = camera.apply(n.x, n.y);
711 NodeSpec { x: sx, y: sy, ..n.clone() }
712 })
713 .collect();
714
715 // El delta de arrastre llega en px de pantalla; lo pasamos a canvas (÷zoom)
716 // antes de dárselo al caller, así suma directo a node.x/y lógicos.
717 let on_drag_canvas: DragNodeFn<Msg> = {
718 let inner = on_drag_node.clone();
719 Arc::new(move |id, phase, dx, dy| (inner)(id, phase, dx / zoom, dy / zoom))
720 };
721
722 let mut painted = precompute_wires(&screen_nodes, wires, &scaled, palette.wire, wire_tint);
723 painted.extend(precompute_edges(&screen_nodes, edges, &scaled, palette.wire, edge_tint));
724 let stroke_px = scaled.wire_stroke;
725
726 let mut children: Vec<View<Msg>> = Vec::with_capacity(nodes.len() + 1);
727
728 // Capa 0 — cables + captura de click/deselección en el fondo. El click sin
729 // mover deselecciona; el arrastre (pan) lo toma el root draggable.
730 let mut wires = wires_layer(painted, stroke_px, flujo);
731 if let Some(msg) = on_canvas_click {
732 wires = wires.on_click(msg);
733 }
734 children.push(wires);
735
736 // Capa 1..N — nodos (en coords de pantalla), con selección.
737 for node in &screen_nodes {
738 let right_click_msg = on_right_click_node.as_ref().and_then(|f| f(node.id));
739 let tint = node_tint.and_then(|f| f(node.id));
740 let is_sel = selected.map(|f| f(node.id)).unwrap_or(false);
741 let select_msg = on_select_node.as_ref().and_then(|f| f(node.id));
742 children.push(node_view(
743 node,
744 palette,
745 &scaled,
746 &on_drag_canvas,
747 &on_connect,
748 right_click_msg,
749 tint,
750 is_sel,
751 select_msg,
752 ));
753 }
754
755 let mut root = View::new(Style {
756 size: Size {
757 width: percent(1.0_f32),
758 height: percent(1.0_f32),
759 },
760 ..Default::default()
761 })
762 .fill(palette.bg_canvas)
763 .clip(true)
764 .children(children);
765
766 // Pan: arrastre del fondo (los nodos/pins, draggables internos, ganan).
767 if let Some(pan) = on_pan {
768 root = root.draggable(move |phase, dx, dy| (pan)(phase, dx, dy));
769 }
770 // Zoom con la rueda.
771 if let Some(zoomf) = on_zoom {
772 root = root.on_scroll(move |_dx, dy| (zoomf)(dy));
773 }
774 root
775}
776
777#[derive(Debug, Clone, Copy)]
778struct WirePainted {
779 x1: f32,
780 y1: f32,
781 x2: f32,
782 y2: f32,
783 /// Signo de la tangente horizontal en cada extremo (+1 sale hacia la
784 /// derecha, -1 hacia la izquierda). Permite curvar el Bezier hacia afuera
785 /// del lado por el que engancha cada punta.
786 d1: f32,
787 d2: f32,
788 color: Color,
789 /// Si `true`, la tangente del Bezier es vertical (aristas apiladas
790 /// arriba→abajo) en vez de horizontal.
791 vertical: bool,
792}
793
794fn precompute_wires(
795 nodes: &[NodeSpec],
796 wires: &[Wire],
797 metrics: &NodegraphMetrics,
798 default_color: Color,
799 wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
800) -> Vec<WirePainted> {
801 let mut out = Vec::with_capacity(wires.len());
802 for w in wires {
803 let from = nodes.iter().find(|n| n.id == w.from_node);
804 let to = nodes.iter().find(|n| n.id == w.to_node);
805 if let (Some(a), Some(b)) = (from, to) {
806 let y1 = metrics.output_pin_y(a.y, w.from_output);
807 let y2 = metrics.input_pin_y(b.y, w.to_input);
808 // Modo direccional (default): salida por la derecha de `a`, entrada
809 // por la izquierda de `b`. Modo `nearest`: el destino a la izquierda
810 // invierte los lados para que el cable no dé la vuelta.
811 let destino_a_la_derecha = b.x + metrics.node_width * 0.5 >= a.x + metrics.node_width * 0.5;
812 let (x1, d1, x2, d2) = if !metrics.wire_nearest_side || destino_a_la_derecha {
813 (a.x + metrics.node_width, 1.0, b.x, -1.0)
814 } else {
815 (a.x, -1.0, b.x + metrics.node_width, 1.0)
816 };
817 let color = wire_tint.and_then(|f| f(w)).unwrap_or(default_color);
818 out.push(WirePainted { x1, y1, x2, y2, d1, d2, color, vertical: false });
819 }
820 }
821 out
822}
823
824/// Geometría de las aristas simples: ancla en el centro vertical de cada nodo y
825/// en el borde horizontal del lado más cercano al otro nodo (sin depender de
826/// pins). Comparte el tipo pintado con los cables, así viajan en la misma capa.
827fn precompute_edges(
828 nodes: &[NodeSpec],
829 edges: &[Edge],
830 metrics: &NodegraphMetrics,
831 default_color: Color,
832 edge_tint: Option<&dyn Fn(&Edge) -> Option<Color>>,
833) -> Vec<WirePainted> {
834 let mut out = Vec::with_capacity(edges.len());
835 for e in edges {
836 let from = nodes.iter().find(|n| n.id == e.from_node);
837 let to = nodes.iter().find(|n| n.id == e.to_node);
838 if let (Some(a), Some(b)) = (from, to) {
839 let a_h = metrics.node_height(a.inputs.len(), a.outputs.len());
840 let b_h = metrics.node_height(b.inputs.len(), b.outputs.len());
841 let color = edge_tint.and_then(|f| f(e)).unwrap_or(default_color);
842 let painted = if metrics.vertical {
843 // Vertical: ancla en el centro-x, borde superior/inferior más
844 // cercano al otro nodo; tangente vertical.
845 let ax = a.x + metrics.node_width * 0.5;
846 let bx = b.x + metrics.node_width * 0.5;
847 let a_cy = a.y + a_h * 0.5;
848 let b_cy = b.y + b_h * 0.5;
849 let (y1, d1, y2, d2) = if b_cy >= a_cy {
850 (a.y + a_h, 1.0, b.y, -1.0)
851 } else {
852 (a.y, -1.0, b.y + b_h, 1.0)
853 };
854 WirePainted { x1: ax, y1, x2: bx, y2, d1, d2, color, vertical: true }
855 } else {
856 // Horizontal: centro-y, borde izquierdo/derecho más cercano.
857 let ay = a.y + a_h * 0.5;
858 let by = b.y + b_h * 0.5;
859 let a_cx = a.x + metrics.node_width * 0.5;
860 let b_cx = b.x + metrics.node_width * 0.5;
861 let (x1, d1, x2, d2) = if b_cx >= a_cx {
862 (a.x + metrics.node_width, 1.0, b.x, -1.0)
863 } else {
864 (a.x, -1.0, b.x + metrics.node_width, 1.0)
865 };
866 WirePainted { x1, y1: ay, x2, y2: by, d1, d2, color, vertical: false }
867 };
868 out.push(painted);
869 }
870 }
871 out
872}
873
874fn wires_layer<Msg>(wires: Vec<WirePainted>, stroke_px: f32, flujo: f32) -> View<Msg>
875where
876 Msg: Clone + 'static,
877{
878 let nodo = View::new(Style {
879 position: Position::Absolute,
880 inset: Rect {
881 left: length(0.0_f32),
882 top: length(0.0_f32),
883 right: length(0.0_f32),
884 bottom: length(0.0_f32),
885 },
886 size: Size {
887 width: percent(1.0_f32),
888 height: percent(1.0_f32),
889 },
890 ..Default::default()
891 });
892 if wires.is_empty() {
893 return nodo;
894 }
895 nodo.paint_with(move |scene, _ts, rect| {
896 let stroke = Stroke::new(stroke_px as f64);
897 // Flujo (opt-in): un guion animado, MUY sutil, corriendo por cada arista
898 // en el sentido ancestro→descendiente. `flujo` es la fase 0..1 que la app
899 // avanza con un tick; el offset del dash la traduce en movimiento.
900 let flujo_dash = if flujo > 0.0 {
901 let dash = (stroke_px as f64 * 3.0).max(5.0);
902 let gap = dash * 2.4;
903 let period = dash + gap;
904 let offset = -(flujo as f64) * period;
905 Some(Stroke::new((stroke_px * 0.9) as f64).with_dashes(offset, [dash, gap]))
906 } else {
907 None
908 };
909 for w in &wires {
910 // Bezier cúbica con tangentes horizontales — mismo patrón
911 // que las hebras de pluma-editor-llimphi. `d1`/`d2` orientan la
912 // tangente hacia afuera del lado por el que engancha cada punta.
913 let x1 = (rect.x + w.x1) as f64;
914 let y1 = (rect.y + w.y1) as f64;
915 let x2 = (rect.x + w.x2) as f64;
916 let y2 = (rect.y + w.y2) as f64;
917 let mut path = BezPath::new();
918 path.move_to((x1, y1));
919 if w.vertical {
920 // Tangente vertical: los controles se separan en Y.
921 let mag = (y2 - y1).abs().max(40.0) * 0.5;
922 path.curve_to((x1, y1 + w.d1 as f64 * mag), (x2, y2 + w.d2 as f64 * mag), (x2, y2));
923 } else {
924 // Tangente horizontal: los controles se separan en X.
925 let dx = ((w.x2 - w.x1).abs().max(40.0) * 0.5) as f64;
926 path.curve_to((x1 + w.d1 as f64 * dx, y1), (x2 + w.d2 as f64 * dx, y2), (x2, y2));
927 }
928 scene.stroke(&stroke, Affine::IDENTITY, w.color, None, &path);
929 // Guion de flujo: versión aclarada y semitransparente del color de la
930 // arista, para que se lea como un brillo que corre sin tapar el cable.
931 if let Some(dash) = &flujo_dash {
932 let c = w.color.components;
933 let flow_color = Color {
934 components: [(c[0] + 1.0) * 0.5, (c[1] + 1.0) * 0.5, (c[2] + 1.0) * 0.5, 0.5],
935 ..w.color
936 };
937 scene.stroke(dash, Affine::IDENTITY, flow_color, None, &path);
938 }
939 }
940 })
941}
942
943#[allow(clippy::too_many_arguments)]
944fn node_view<Msg>(
945 node: &NodeSpec,
946 palette: &NodegraphPalette,
947 metrics: &NodegraphMetrics,
948 on_drag: &DragNodeFn<Msg>,
949 on_connect: &ConnectFn<Msg>,
950 on_right_click_msg: Option<Msg>,
951 tint: Option<NodeTint>,
952 selected: bool,
953 on_select_msg: Option<Msg>,
954) -> View<Msg>
955where
956 Msg: Clone + Send + Sync + 'static,
957{
958 let n_in = node.inputs.len();
959 let n_out = node.outputs.len();
960 let height = metrics.node_height(n_in, n_out);
961
962 // Colores efectivos: el tinte sobrescribe la paleta por-campo.
963 let tint = tint.unwrap_or_default();
964 let bg_node = tint.bg_node.unwrap_or(palette.bg_node);
965 let bg_title = tint.bg_title.unwrap_or(palette.bg_title);
966 let fg_title = tint.fg_title.unwrap_or(palette.fg_title);
967
968 let node_id = node.id;
969 let drag = on_drag.clone();
970 let mut title_bar = View::new(Style {
971 size: Size {
972 width: percent(1.0_f32),
973 height: length(metrics.title_height),
974 },
975 flex_shrink: 0.0,
976 padding: Rect {
977 left: length(8.0_f32),
978 right: length(8.0_f32),
979 top: length(0.0_f32),
980 bottom: length(0.0_f32),
981 },
982 ..Default::default()
983 })
984 .fill(bg_title)
985 .text_aligned(
986 node.label.clone(),
987 metrics.title_text_size,
988 fg_title,
989 Alignment::Start,
990 )
991 .draggable(move |phase, dx, dy| (drag)(node_id, phase, dx, dy));
992
993 if let Some(msg) = on_right_click_msg {
994 title_bar = title_bar.on_right_click(msg);
995 }
996
997 let mut pin_layer_children: Vec<View<Msg>> = Vec::with_capacity(n_in + n_out);
998 for (i, label) in node.inputs.iter().enumerate() {
999 pin_layer_children.push(pin_view(
1000 node_id,
1001 i as PinIdx,
1002 PinKind::Input,
1003 label,
1004 palette,
1005 metrics,
1006 on_connect.clone(),
1007 ));
1008 }
1009 for (i, label) in node.outputs.iter().enumerate() {
1010 pin_layer_children.push(pin_view(
1011 node_id,
1012 i as PinIdx,
1013 PinKind::Output,
1014 label,
1015 palette,
1016 metrics,
1017 on_connect.clone(),
1018 ));
1019 }
1020 let pin_layer = View::new(Style {
1021 position: Position::Absolute,
1022 inset: Rect {
1023 left: length(0.0_f32),
1024 top: length(metrics.title_height),
1025 right: length(0.0_f32),
1026 bottom: length(0.0_f32),
1027 },
1028 size: Size {
1029 width: percent(1.0_f32),
1030 height: Dimension::auto(),
1031 },
1032 ..Default::default()
1033 })
1034 .children(pin_layer_children);
1035
1036 let mut root = View::new(Style {
1037 position: Position::Absolute,
1038 inset: Rect {
1039 left: length(node.x),
1040 top: length(node.y),
1041 right: length(0.0_f32),
1042 bottom: length(0.0_f32),
1043 },
1044 size: Size {
1045 width: length(metrics.node_width),
1046 height: length(height),
1047 },
1048 ..Default::default()
1049 })
1050 .fill(bg_node)
1051 .radius(metrics.node_radius)
1052 .children(vec![title_bar, pin_layer]);
1053
1054 // Elevación: sombra suave sobre el fondo (opt-in por métrica).
1055 if metrics.node_elevacion > 0.0 {
1056 let blur = metrics.node_elevacion as f64;
1057 root = root.shadow(Shadow::soft(90, blur).offset(0.0, blur * 0.22));
1058 }
1059
1060 // Selección: borde de acento alrededor del nodo elegido.
1061 if selected {
1062 root = root.border(2.0, palette.wire);
1063 }
1064 // Click en el cuerpo del nodo (no en el título draggable ni en los pins) →
1065 // seleccionar. El árbitro de hit-test da prioridad a los hijos interactivos.
1066 if let Some(msg) = on_select_msg {
1067 root = root.on_click(msg);
1068 }
1069 root
1070}
1071
1072#[derive(Debug, Clone, Copy)]
1073enum PinKind {
1074 Input,
1075 Output,
1076}
1077
1078fn pin_view<Msg>(
1079 node_id: NodeId,
1080 pin_idx: PinIdx,
1081 kind: PinKind,
1082 label: &str,
1083 palette: &NodegraphPalette,
1084 metrics: &NodegraphMetrics,
1085 on_connect: ConnectFn<Msg>,
1086) -> View<Msg>
1087where
1088 Msg: Clone + Send + Sync + 'static,
1089{
1090 let y_top = pin_idx as f32 * metrics.pin_row_height;
1091 let row_h = metrics.pin_row_height;
1092 let r = metrics.pin_radius;
1093 let diam = r * 2.0;
1094
1095 let (pin_left, pin_right, dot_color, label_align) = match kind {
1096 PinKind::Input => (
1097 Some(length(-r)),
1098 None,
1099 palette.pin_input,
1100 Alignment::Start,
1101 ),
1102 PinKind::Output => (
1103 None,
1104 Some(length(-r)),
1105 palette.pin_output,
1106 Alignment::End,
1107 ),
1108 };
1109
1110 let mut dot = View::new(Style {
1111 position: Position::Absolute,
1112 inset: Rect {
1113 left: pin_left.unwrap_or_else(|| length(0.0_f32)),
1114 top: length((row_h - diam) * 0.5),
1115 right: pin_right.unwrap_or_else(|| length(0.0_f32)),
1116 bottom: length(0.0_f32),
1117 },
1118 size: Size {
1119 width: length(diam),
1120 height: length(diam),
1121 },
1122 ..Default::default()
1123 })
1124 .fill(dot_color)
1125 .radius(r as f64);
1126
1127 match kind {
1128 PinKind::Output => {
1129 dot = dot
1130 .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
1131 .drag_payload(encode_payload(node_id, pin_idx));
1132 }
1133 PinKind::Input => {
1134 let to_node = node_id;
1135 let to_pin = pin_idx;
1136 let cb = on_connect.clone();
1137 dot = dot
1138 .on_drop(move |payload: u64| {
1139 let (from_node, from_pin) = decode_payload(payload);
1140 (cb)(from_node, from_pin, to_node, to_pin)
1141 })
1142 .drop_hover_fill(palette.pin_drop_hover);
1143 }
1144 }
1145
1146 let label_view = View::new(Style {
1147 position: Position::Absolute,
1148 inset: Rect {
1149 left: length(diam + 4.0),
1150 top: length(0.0_f32),
1151 right: length(diam + 4.0),
1152 bottom: length(0.0_f32),
1153 },
1154 size: Size {
1155 width: Dimension::auto(),
1156 height: length(row_h),
1157 },
1158 ..Default::default()
1159 })
1160 .text_aligned(
1161 label.to_string(),
1162 metrics.pin_label_size,
1163 palette.fg_pin_label,
1164 label_align,
1165 );
1166
1167 View::new(Style {
1168 position: Position::Absolute,
1169 inset: Rect {
1170 left: length(0.0_f32),
1171 top: length(y_top),
1172 right: length(0.0_f32),
1173 bottom: length(0.0_f32),
1174 },
1175 size: Size {
1176 width: percent(1.0_f32),
1177 height: length(row_h),
1178 },
1179 ..Default::default()
1180 })
1181 .children(vec![label_view, dot])
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187
1188 #[test]
1189 fn camera_apply_e_invert_son_inversas() {
1190 let cam = Camera { pan_x: 30.0, pan_y: -10.0, zoom: 2.0 };
1191 let (sx, sy) = cam.apply(50.0, 40.0);
1192 assert!((sx - (50.0 * 2.0 + 30.0)).abs() < 1e-3);
1193 assert!((sy - (40.0 * 2.0 - 10.0)).abs() < 1e-3);
1194 let (cx, cy) = cam.invert(sx, sy);
1195 assert!((cx - 50.0).abs() < 1e-3 && (cy - 40.0).abs() < 1e-3);
1196 }
1197
1198 #[test]
1199 fn zoom_step_multiplica_y_clampa() {
1200 // Acercar sube, alejar baja.
1201 assert!(zoom_step(1.0, 1.0) > 1.0);
1202 assert!(zoom_step(1.0, -1.0) < 1.0);
1203 // Clamp en los extremos.
1204 assert_eq!(zoom_step(ZOOM_MAX, 5.0), ZOOM_MAX);
1205 assert_eq!(zoom_step(ZOOM_MIN, -5.0), ZOOM_MIN);
1206 }
1207
1208 #[test]
1209 fn zoom_at_center_mantiene_fijo_el_centro() {
1210 let cam = Camera::default();
1211 let (vw, vh) = (800.0_f32, 600.0_f32);
1212 // Punto de canvas bajo el centro antes de zoomear.
1213 let before = cam.invert(vw * 0.5, vh * 0.5);
1214 let cam2 = zoom_at_center(cam, 2.5, vw, vh);
1215 assert!((cam2.zoom - 2.5).abs() < 1e-3);
1216 // El mismo punto de canvas sigue proyectándose al centro.
1217 let (sx, sy) = cam2.apply(before.0, before.1);
1218 assert!((sx - vw * 0.5).abs() < 1e-2, "cx fijo (dio {sx})");
1219 assert!((sy - vh * 0.5).abs() < 1e-2, "cy fijo (dio {sy})");
1220 }
1221
1222 #[test]
1223 fn scaled_metrics_escala_todo_por_zoom() {
1224 let m = NodegraphMetrics::default();
1225 let s = m.scaled(2.0);
1226 assert!((s.node_width - m.node_width * 2.0).abs() < 1e-3);
1227 assert!((s.title_text_size - m.title_text_size * 2.0).abs() < 1e-3);
1228 assert!((s.node_radius - m.node_radius * 2.0).abs() < 1e-9);
1229 }
1230
1231 #[test]
1232 fn payload_roundtrip() {
1233 for (n, p) in [
1234 (0u32, 0u16),
1235 (1, 0),
1236 (0, 1),
1237 (42, 7),
1238 (u32::MAX, u16::MAX),
1239 (123_456, 65_535),
1240 ] {
1241 let enc = encode_payload(n, p);
1242 let (n2, p2) = decode_payload(enc);
1243 assert_eq!((n, p), (n2, p2), "payload {enc} → ({n2}, {p2})");
1244 }
1245 }
1246
1247 #[test]
1248 fn metrics_node_height_grows_with_max_side() {
1249 let m = NodegraphMetrics::default();
1250 assert_eq!(m.node_height(3, 1), m.node_height(1, 3));
1251 let min = m.title_height + m.pin_row_height + 6.0;
1252 assert_eq!(m.node_height(0, 0), min);
1253 }
1254
1255 #[test]
1256 fn pin_y_progression() {
1257 let m = NodegraphMetrics::default();
1258 let y0 = m.input_pin_y(100.0, 0);
1259 let y1 = m.input_pin_y(100.0, 1);
1260 let y2 = m.input_pin_y(100.0, 2);
1261 assert!(y1 - y0 > 0.0, "pins crecen hacia abajo");
1262 assert!((y2 - y1) - (y1 - y0) < 1e-3, "espaciado uniforme");
1263 }
1264
1265 #[test]
1266 fn precompute_skips_dangling_wires() {
1267 let nodes = vec![NodeSpec {
1268 id: 1,
1269 label: "solo".into(),
1270 x: 0.0,
1271 y: 0.0,
1272 inputs: vec!["in".into()],
1273 outputs: vec!["out".into()],
1274 }];
1275 let wires = vec![Wire {
1276 from_node: 99,
1277 from_output: 0,
1278 to_node: 1,
1279 to_input: 0,
1280 }];
1281 let m = NodegraphMetrics::default();
1282 let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None);
1283 assert!(pre.is_empty());
1284 }
1285
1286 fn dos_nodos(xa: f32, xb: f32) -> Vec<NodeSpec> {
1287 vec![
1288 NodeSpec { id: 1, label: "a".into(), x: xa, y: 0.0, inputs: vec!["".into()], outputs: vec!["".into()] },
1289 NodeSpec { id: 2, label: "b".into(), x: xb, y: 0.0, inputs: vec!["".into()], outputs: vec!["".into()] },
1290 ]
1291 }
1292 const UN_CABLE: Wire = Wire { from_node: 1, from_output: 0, to_node: 2, to_input: 0 };
1293 const NEGRO: Color = Color::from_rgba8(0, 0, 0, 255);
1294
1295 #[test]
1296 fn direccional_siempre_sale_derecha_entra_izquierda() {
1297 // Modo clásico: aunque el destino esté a la IZQUIERDA, el cable sale por
1298 // la derecha de `a` y entra por la izquierda de `b` (semántica DAG).
1299 let m = NodegraphMetrics::default();
1300 let pre = precompute_wires(&dos_nodos(300.0, 0.0), &[UN_CABLE], &m, NEGRO, None);
1301 assert!((pre[0].x1 - (300.0 + m.node_width)).abs() < 1e-3);
1302 assert!((pre[0].x2 - 0.0).abs() < 1e-3);
1303 assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0));
1304 }
1305
1306 #[test]
1307 fn nearest_side_invierte_lados_si_destino_a_la_izquierda() {
1308 // Con `wire_nearest_side`, el destino a la izquierda hace que el cable
1309 // salga por la IZQUIERDA de `a` (x1 = a.x) y entre por la DERECHA de `b`
1310 // (x2 = b.x + node_width) — sin dar la vuelta al nodo.
1311 let m = NodegraphMetrics { wire_nearest_side: true, ..Default::default() };
1312 let pre = precompute_wires(&dos_nodos(300.0, 0.0), &[UN_CABLE], &m, NEGRO, None);
1313 assert!((pre[0].x1 - 300.0).abs() < 1e-3);
1314 assert!((pre[0].x2 - m.node_width).abs() < 1e-3);
1315 assert_eq!((pre[0].d1, pre[0].d2), (-1.0, 1.0));
1316 }
1317
1318 #[test]
1319 fn nearest_side_coincide_con_direccional_si_destino_a_la_derecha() {
1320 // Destino a la derecha: `nearest` no cambia nada respecto del direccional.
1321 let m = NodegraphMetrics { wire_nearest_side: true, ..Default::default() };
1322 let pre = precompute_wires(&dos_nodos(0.0, 300.0), &[UN_CABLE], &m, NEGRO, None);
1323 assert!((pre[0].x1 - m.node_width).abs() < 1e-3);
1324 assert!((pre[0].x2 - 300.0).abs() < 1e-3);
1325 assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0));
1326 }
1327
1328 #[test]
1329 fn precompute_resolves_existing_wires() {
1330 let nodes = vec![
1331 NodeSpec {
1332 id: 1,
1333 label: "a".into(),
1334 x: 0.0,
1335 y: 0.0,
1336 inputs: vec![],
1337 outputs: vec!["out".into()],
1338 },
1339 NodeSpec {
1340 id: 2,
1341 label: "b".into(),
1342 x: 200.0,
1343 y: 50.0,
1344 inputs: vec!["in".into()],
1345 outputs: vec![],
1346 },
1347 ];
1348 let wires = vec![Wire {
1349 from_node: 1,
1350 from_output: 0,
1351 to_node: 2,
1352 to_input: 0,
1353 }];
1354 let m = NodegraphMetrics::default();
1355 let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None);
1356 assert_eq!(pre.len(), 1);
1357 assert!((pre[0].x1 - m.node_width).abs() < 1e-3);
1358 assert!((pre[0].x2 - 200.0).abs() < 1e-3);
1359 }
1360
1361 // ── Aristas simples (F0.1) ────────────────────────────────────────────
1362
1363 fn nodo_sin_pins(id: NodeId, x: f32, y: f32) -> NodeSpec {
1364 NodeSpec { id, label: format!("n{id}"), x, y, inputs: vec![], outputs: vec![] }
1365 }
1366
1367 #[test]
1368 fn edge_ancla_en_centro_y_lado_cercano() {
1369 let m = NodegraphMetrics::default();
1370 // b a la derecha de a → sale por la derecha de a, entra por la izq de b.
1371 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 300.0, 40.0)];
1372 let edges = vec![Edge { from_node: 1, to_node: 2 }];
1373 let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None);
1374 assert_eq!(pre.len(), 1);
1375 let half = m.node_height(0, 0) * 0.5;
1376 assert!((pre[0].x1 - m.node_width).abs() < 1e-3, "sale por borde derecho de a");
1377 assert!((pre[0].y1 - half).abs() < 1e-3, "ancla en centro vertical de a");
1378 assert!((pre[0].x2 - 300.0).abs() < 1e-3, "entra por borde izquierdo de b");
1379 assert!((pre[0].y2 - (40.0 + half)).abs() < 1e-3, "centro vertical de b");
1380 assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0));
1381 }
1382
1383 #[test]
1384 fn edge_lado_cercano_si_destino_a_la_izquierda() {
1385 let m = NodegraphMetrics::default();
1386 let nodes = vec![nodo_sin_pins(1, 300.0, 0.0), nodo_sin_pins(2, 0.0, 0.0)];
1387 let edges = vec![Edge { from_node: 1, to_node: 2 }];
1388 let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None);
1389 // Destino a la izquierda: sale por la izq de a, entra por la der de b.
1390 assert!((pre[0].x1 - 300.0).abs() < 1e-3);
1391 assert!((pre[0].x2 - m.node_width).abs() < 1e-3);
1392 assert_eq!((pre[0].d1, pre[0].d2), (-1.0, 1.0));
1393 }
1394
1395 #[test]
1396 fn edge_colgante_se_descarta() {
1397 let m = NodegraphMetrics::default();
1398 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0)];
1399 let edges = vec![Edge { from_node: 1, to_node: 99 }];
1400 assert!(precompute_edges(&nodes, &edges, &m, NEGRO, None).is_empty());
1401 }
1402
1403 // ── Layout por rango topológico (F0.3) ────────────────────────────────
1404
1405 #[test]
1406 fn layout_cadena_lineal_columnas_crecientes() {
1407 let m = NodegraphMetrics::default();
1408 let ids = vec![1u32, 2, 3];
1409 let edges = vec![(1, 2), (2, 3)];
1410 let pos = layout_por_rango(&ids, &edges, &m, LayoutRango::default());
1411 assert!(pos[0].x < pos[1].x && pos[1].x < pos[2].x, "cada eslabón una columna más");
1412 // Rangos distintos ⇒ cada uno en la fila 0 de su columna (mismo y).
1413 assert!((pos[0].y - pos[1].y).abs() < 1e-3);
1414 }
1415
1416 #[test]
1417 fn layout_diamante_merge_en_rango_mas_profundo() {
1418 // a→b, a→c, b→d, c→d : d debe caer en el rango 2 (max de sus padres +1).
1419 let m = NodegraphMetrics::default();
1420 let ids = vec![10u32, 11, 12, 13]; // a,b,c,d
1421 let edges = vec![(10, 11), (10, 12), (11, 13), (12, 13)];
1422 let pos = layout_por_rango(&ids, &edges, &m, LayoutRango::default());
1423 let col = |id: u32| pos.iter().find(|p| p.id == id).unwrap().x;
1424 let opts = LayoutRango::default();
1425 let col_w = m.node_width + opts.col_gap;
1426 assert!((col(10) - opts.margin).abs() < 1e-3, "a en rango 0");
1427 assert!((col(11) - (opts.margin + col_w)).abs() < 1e-3, "b en rango 1");
1428 assert!((col(13) - (opts.margin + 2.0 * col_w)).abs() < 1e-3, "d (merge) en rango 2");
1429 }
1430
1431 #[test]
1432 fn layout_ciclo_no_cuelga() {
1433 let m = NodegraphMetrics::default();
1434 let ids = vec![1u32, 2];
1435 let edges = vec![(1, 2), (2, 1)];
1436 // No debe colgarse; ambos caen a rango 0 (mejor esfuerzo).
1437 let pos = layout_por_rango(&ids, &edges, &m, LayoutRango::default());
1438 assert_eq!(pos.len(), 2);
1439 }
1440
1441 // ── Encuadre y conos (F0.5) ───────────────────────────────────────────
1442
1443 #[test]
1444 fn fit_to_view_centra_y_clampa() {
1445 let m = NodegraphMetrics::default();
1446 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 400.0, 300.0)];
1447 let (vw, vh) = (800.0_f32, 600.0_f32);
1448 let cam = fit_to_view(&nodes, &m, vw, vh);
1449 assert!(cam.zoom >= ZOOM_MIN && cam.zoom <= ZOOM_MAX);
1450 // El centro del contenido se proyecta al centro del viewport.
1451 let mut min_x = f32::INFINITY; let mut max_x = f32::NEG_INFINITY;
1452 let mut min_y = f32::INFINITY; let mut max_y = f32::NEG_INFINITY;
1453 for n in &nodes {
1454 let h = m.node_height(0, 0);
1455 min_x = min_x.min(n.x); max_x = max_x.max(n.x + m.node_width);
1456 min_y = min_y.min(n.y); max_y = max_y.max(n.y + h);
1457 }
1458 let (ccx, ccy) = ((min_x + max_x) * 0.5, (min_y + max_y) * 0.5);
1459 let (sx, sy) = cam.apply(ccx, ccy);
1460 assert!((sx - vw * 0.5).abs() < 1e-2 && (sy - vh * 0.5).abs() < 1e-2);
1461 }
1462
1463 #[test]
1464 fn fit_to_view_sin_nodos_es_identidad() {
1465 let m = NodegraphMetrics::default();
1466 assert_eq!(fit_to_view(&[], &m, 800.0, 600.0), Camera::default());
1467 }
1468
1469 #[test]
1470 fn cono_ascendente_y_descendente_en_diamante() {
1471 // a→b, a→c, b→d, c→d
1472 let edges = vec![(10u32, 11), (10, 12), (11, 13), (12, 13)];
1473 let mut asc = cono_ascendente(&edges, 13); // ancestros de d + d
1474 asc.sort_unstable();
1475 assert_eq!(asc, vec![10, 11, 12, 13]);
1476 let mut desc = cono_descendente(&edges, 10); // descendientes de a + a
1477 desc.sort_unstable();
1478 assert_eq!(desc, vec![10, 11, 12, 13]);
1479 // Una hoja no tiene descendientes salvo ella misma.
1480 assert_eq!(cono_descendente(&edges, 13), vec![13]);
1481 // Una raíz no tiene ancestros salvo ella misma.
1482 assert_eq!(cono_ascendente(&edges, 10), vec![10]);
1483 }
1484
1485 // ── Orientación vertical (F0 visual) ──────────────────────────────────
1486
1487 #[test]
1488 fn edge_vertical_ancla_arriba_y_abajo() {
1489 let m = NodegraphMetrics { vertical: true, ..Default::default() };
1490 // b debajo de a → sale por el borde inferior de a, entra por el superior de b.
1491 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 40.0, 300.0)];
1492 let pre = precompute_edges(&nodes, &[Edge { from_node: 1, to_node: 2 }], &m, NEGRO, None);
1493 let h = m.node_height(0, 0);
1494 assert!((pre[0].x1 - m.node_width * 0.5).abs() < 1e-3, "centro-x de a");
1495 assert!((pre[0].y1 - h).abs() < 1e-3, "borde inferior de a");
1496 assert!((pre[0].x2 - (40.0 + m.node_width * 0.5)).abs() < 1e-3, "centro-x de b");
1497 assert!((pre[0].y2 - 300.0).abs() < 1e-3, "borde superior de b");
1498 assert!(pre[0].vertical);
1499 assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0));
1500 }
1501
1502 #[test]
1503 fn layout_vertical_apila_rangos_en_y() {
1504 let m = NodegraphMetrics::default();
1505 let ids = vec![1u32, 2, 3];
1506 let edges = vec![(1, 2), (2, 3)];
1507 let opts = LayoutRango { vertical: true, ..Default::default() };
1508 let pos = layout_por_rango(&ids, &edges, &m, opts);
1509 // Cadena: cada eslabón un rango más abajo (y crece), misma columna (x igual).
1510 assert!(pos[0].y < pos[1].y && pos[1].y < pos[2].y);
1511 assert!((pos[0].x - pos[1].x).abs() < 1e-3);
1512 }
1513}