tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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
32
33// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
34// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
35// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
36#![no_std]
37
38extern crate alloc;
39
40use alloc::string::String;
41use alloc::vec::Vec;
42use alloc::vec;
43use alloc::string::ToString;
44// `hashbrown` ES el `HashMap` de std por dentro; `VecDeque` vive en `alloc`.
45// Nada de esto necesitaba un sistema operativo — sólo estaba nombrado como si.
46use alloc::collections::VecDeque;
47use hashbrown::{HashMap, HashSet};
48use alloc::sync::Arc;
49
50use llimphi_compositor::llimphi_layout::taffy::{
51 prelude::{length, percent, Dimension, Position, Size, Style},
52 Rect,
53};
54use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Stroke};
55use llimphi_compositor::peniko::Color;
56use llimphi_compositor::llimphi_text::Alignment;
57use llimphi_compositor::{DragPhase, Shadow, View};
58
59/// Identificador opaco de un nodo. El caller asigna estos valores; el
60/// widget los pasa de vuelta sin interpretarlos.
61pub type NodeId = u32;
62/// Índice del pin dentro de la lista `inputs` o `outputs` del nodo.
63pub type PinIdx = u16;
64
65/// Especificación de un nodo del grafo. El caller construye uno por
66/// nodo en cada `view`. Las posiciones son en pixels relativas al rect
67/// del lienzo.
68#[derive(Debug, Clone)]
69pub struct NodeSpec {
70 pub id: NodeId,
71 pub label: String,
72 /// Esquina superior-izquierda del nodo, en coordenadas del lienzo.
73 pub x: f32,
74 pub y: f32,
75 /// Labels de los pins de entrada. Cantidad = altura mínima del nodo.
76 pub inputs: Vec<String>,
77 /// Labels de los pins de salida.
78 pub outputs: Vec<String>,
79}
80
81/// **Cuerpo embebido** de un nodo: lo que se dibuja debajo de la fila de pins.
82///
83/// Sin esto un nodo sólo puede decir su nombre, y el grafo obliga a mirar a
84/// otro lado para saber qué produce cada paso. Con esto un nodo muestra su
85/// resultado adentro: krama pinta el valor con su unidad, tullpu podría pintar
86/// el preview del ajuste, takiy la onda de su eslabón, pluma-notebook el output
87/// de la celda.
88///
89/// # Por qué no vive en [`NodeSpec`]
90///
91/// `NodeSpec` es `Debug + Clone` y **no** es genérico en `Msg`; una `View<Msg>`
92/// adentro lo volvería genérico y rompería sus 12 sitios de construcción en el
93/// árbol. El cuerpo entra por [`NodegraphSpec::cuerpo`], que es un closure —
94/// exactamente la misma forma que ya tienen `node_tint`, `selected` y
95/// `wire_tint`, y que sólo cuesta un `None` a su único consumidor externo.
96pub struct CuerpoNodo<Msg: Clone + Send + Sync + 'static> {
97 /// Alto reservado bajo los pins, en coordenadas de **canvas** (el widget lo
98 /// escala por el zoom). El nodo crece esa cantidad.
99 pub alto: f32,
100 /// Lo que llena ese rect. Un cuerpo hecho con `View::paint_with` escala
101 /// solo con el zoom porque recibe el rect ya escalado; uno hecho de texto
102 /// no — por eso el closure recibe el zoom y puede dimensionar la fuente.
103 pub vista: View<Msg>,
104}
105
106/// Cable entre el pin de salida de un nodo y el pin de entrada de otro.
107/// El widget no valida ciclos ni direcciones — esa política vive en el
108/// caller.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct Wire {
111 pub from_node: NodeId,
112 pub from_output: PinIdx,
113 pub to_node: NodeId,
114 pub to_input: PinIdx,
115}
116
117/// **Bit que marca un payload de puño** (arrastrar la punta de una arista) para
118/// distinguirlo del de un pin o un borde (conectar algo nuevo). Los dos viajan
119/// por el mismo canal —un `u64`— y el destino tiene que saber cuál recibió:
120/// tratar una reconexión como una conexión nueva dejaría la arista vieja
121/// colgada y una segunda encima.
122const MARCA_PUNO: u64 = 1 << 63;
123
124/// Radio del puño de una punta, en px de pantalla. Chico pero agarrable.
125const PUNO_R: f32 = 5.0;
126
127/// **Cuál de las dos puntas de una arista.**
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum ExtremoArista {
130 /// La punta que sale del nodo origen.
131 Desde,
132 /// La que llega al destino.
133 Hasta,
134}
135
136/// Lo que [`nodegraph_view_spec_ext`] agrega sobre [`nodegraph_view_spec`].
137///
138/// Va en un struct aparte y no en [`NodegraphSpec`] porque el spec lo
139/// construyen **siete** crates con literal completo: un campo más los rompe a
140/// todos. `Default` es el lienzo de siempre.
141pub struct NodegraphExtras<Msg> {
142 /// **Mientras se arrastra una punta.** `on_tirar_puno(i, extremo, fase, x, y)`
143 /// con `(x, y)` **absoluto en coordenadas del lienzo**, no un delta: el
144 /// caller lo guarda tal cual y lo devuelve por [`Self::tirando`], y así no
145 /// hay dos acumuladores que se puedan desfasar.
146 ///
147 /// Es lo que hace que el gesto se vea. Sin esto el puño se podía arrastrar y
148 /// **no pasaba nada en pantalla** hasta soltarlo: el operador lo reportó como
149 /// «las uniones no se pueden reacomodar», que es exactamente lo que parece
150 /// cuando un gesto no tiene respuesta.
151 pub on_tirar_puno: Option<
152 alloc::sync::Arc<
153 dyn Fn(usize, ExtremoArista, DragPhase, f32, f32) -> Option<Msg> + Send + Sync,
154 >,
155 >,
156
157 /// **La punta que se está arrastrando ahora**, como el caller la tiene:
158 /// `(arista, extremo, x, y)` en coordenadas del lienzo. El widget pinta el
159 /// cable fantasma hasta ahí y resalta el nodo que lo recibiría.
160 pub tirando: Option<(usize, ExtremoArista, f32, f32)>,
161
162 /// **Dónde quedó cada punta**, por índice de arista: `(desde, hasta)` en
163 /// coordenadas normalizadas del nodo (0..1 de ancho y de alto). `None` en una
164 /// punta = la deriva la geometría, que es el default y lo que hace cualquier
165 /// arista nueva.
166 pub anclas: Option<
167 alloc::sync::Arc<
168 dyn Fn(usize) -> (Option<(f32, f32)>, Option<(f32, f32)>) + Send + Sync,
169 >,
170 >,
171
172 /// **Soltar una punta sobre un nodo.** Sale en `DragPhase::End` cuando el
173 /// punto cae dentro de un nodo: `on_reconectar(i, extremo, nodo, ancla)`, con
174 /// el ancla ya normalizada al nodo que la recibe. El caller borra la vieja y
175 /// pone la nueva — el widget no tiene estado.
176 ///
177 /// **Lo resuelve el widget y no el caller** porque la geometría del borde es
178 /// del widget: con el caller decidiendo a quién le cayó, habría dos cuentas
179 /// del mismo rectángulo y el cable terminaría en un lugar distinto del que
180 /// se soltó.
181 ///
182 /// Sólo aparece con `NodegraphMetrics::conectar_por_borde`: sin eso el
183 /// grafo es de pins y una punta suelta no significa nada.
184 pub on_reconectar: Option<
185 alloc::sync::Arc<
186 dyn Fn(usize, ExtremoArista, NodeId, (f32, f32)) -> Option<Msg> + Send + Sync,
187 >,
188 >,
189}
190
191impl<Msg> Default for NodegraphExtras<Msg> {
192 fn default() -> Self {
193 Self { on_tirar_puno: None, tirando: None, anclas: None, on_reconectar: None }
194 }
195}
196
197/// Arista **simple** nodo→nodo, sin pins. A diferencia de [`Wire`] (que engancha
198/// en puertos concretos de dataflow), una arista se ancla en el **centro
199/// vertical** de cada nodo y en el **borde horizontal más cercano** al otro
200/// nodo (como `wire_nearest_side`, pero sin depender de que existan pins). Es la
201/// forma de primera clase para grafos de relación / contención / DAG genérico
202/// donde los nodos no tienen puertos — antes había que fabricar pins dummy de
203/// string vacío (churay, navigator, anim-studio lo hacían así).
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub struct Edge {
206 pub from_node: NodeId,
207 pub to_node: NodeId,
208}
209
210/// Tinte opcional de un nodo resaltado. Cada campo `Some` sobrescribe
211/// el color correspondiente de la paleta global para *ese* nodo; los
212/// `None` heredan la paleta. Sirve para que el caller marque un subgrafo
213/// (p.ej. el cono afectado por un morfismo) sin tocar el resto.
214#[derive(Debug, Clone, Copy, Default)]
215pub struct NodeTint {
216 pub bg_node: Option<Color>,
217 pub bg_title: Option<Color>,
218 pub fg_title: Option<Color>,
219}
220
221/// Paleta del lienzo. Hereda del [`llimphi_theme::Theme`] semántico.
222#[derive(Debug, Clone, Copy)]
223pub struct NodegraphPalette {
224 pub bg_canvas: Color,
225 pub bg_node: Color,
226 pub bg_title: Color,
227 pub fg_title: Color,
228 pub fg_pin_label: Color,
229 pub pin_input: Color,
230 pub pin_output: Color,
231 pub pin_drop_hover: Color,
232 pub wire: Color,
233 pub border: Color,
234}
235
236impl Default for NodegraphPalette {
237 fn default() -> Self {
238 Self::from_theme(&llimphi_theme::Theme::dark())
239 }
240}
241
242impl NodegraphPalette {
243 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
244 Self {
245 bg_canvas: t.bg_app,
246 bg_node: t.bg_panel,
247 bg_title: t.bg_panel_alt,
248 fg_title: t.fg_text,
249 fg_pin_label: t.fg_muted,
250 pin_input: t.accent,
251 pin_output: t.accent,
252 pin_drop_hover: t.bg_selected,
253 wire: t.accent,
254 border: t.border,
255 }
256 }
257}
258
259/// Geometría del nodo y de los pins.
260#[derive(Debug, Clone, Copy)]
261pub struct NodegraphMetrics {
262 pub node_width: f32,
263 pub title_height: f32,
264 pub pin_row_height: f32,
265 pub pin_radius: f32,
266 pub pin_label_size: f32,
267 pub title_text_size: f32,
268 pub wire_stroke: f32,
269 pub node_radius: f64,
270 /// Si `true`, cada cable **sale y entra por el lado más cercano** al otro
271 /// nodo (derecha→izquierda si el destino está a la derecha, izquierda→derecha
272 /// si está a la izquierda), en vez de siempre salida-derecha → entrada-
273 /// izquierda. Evita cables que dan la vuelta al nodo cuando el grafo no es
274 /// un DAG estricto de izquierda a derecha (p.ej. un grafo de relaciones).
275 /// Como cada nodo tiene un pin a cada lado, el cable engancha en un pin real
276 /// de ambos extremos. Default `false` (semántica direccional clásica).
277 pub wire_nearest_side: bool,
278 /// Elevación (blur de la sombra en px) de cada nodo sobre el fondo. `0.0`
279 /// (default) = sin sombra, para no cambiar el look de los consumidores
280 /// clásicos; el cockpit de DAG la enciende. Escala con el zoom.
281 pub node_elevacion: f32,
282 /// Si `true`, las **aristas simples** (`Edge`) se rutean verticalmente:
283 /// anclan en el borde superior/inferior de cada nodo con tangente vertical
284 /// (para un DAG apilado de arriba hacia abajo). `false` (default) = ruteo
285 /// horizontal clásico. No afecta a los cables con pin (`Wire`).
286 pub vertical: bool,
287 /// **Conectar arrastrando de un nodo a otro, sin pins.**
288 ///
289 /// Con `true`, el CUERPO de cada nodo —todo menos su barra de título, que
290 /// sigue siendo para moverlo— es a la vez fuente de arrastre y destino de
291 /// soltado: se tira desde cualquier punto de uno y se suelta en cualquier
292 /// punto de otro, y sale `on_connect(de, 0, a, 0)`.
293 ///
294 /// Está para los grafos de **relación**, donde los nodos no tienen puertos y
295 /// obligar a apuntarle a un punto de seis píxeles es pedirle puntería a
296 /// alguien que sólo quiere decir «estos dos tienen que ver». Los pins
297 /// siguen funcionando igual si el nodo los declara; con `inputs`/`outputs`
298 /// vacíos, el nodo queda limpio y esto es la única forma de conectarlo.
299 ///
300 /// `false` —el default— es el widget de siempre.
301 pub conectar_por_borde: bool,
302
303}
304
305impl Default for NodegraphMetrics {
306 fn default() -> Self {
307 Self {
308 node_width: 160.0,
309 title_height: 22.0,
310 pin_row_height: 18.0,
311 pin_radius: 5.0,
312 pin_label_size: 10.0,
313 title_text_size: 11.0,
314 wire_stroke: 1.6,
315 node_radius: 4.0,
316 wire_nearest_side: false,
317 node_elevacion: 0.0,
318 vertical: false,
319 conectar_por_borde: false,
320 }
321 }
322}
323
324impl NodegraphMetrics {
325 /// Alto total del rect que ocupa un nodo con `n_in` entradas y
326 /// `n_out` salidas. El cuerpo crece con el lado que tenga más pins.
327 pub fn node_height(&self, n_in: usize, n_out: usize) -> f32 {
328 let rows = n_in.max(n_out).max(1) as f32;
329 self.title_height + rows * self.pin_row_height + 6.0
330 }
331
332 /// Centro Y absoluto de un pin de entrada del nodo cuyo top-left es
333 /// `(_x, node_y)`. Sirve también para outputs (misma alineación).
334 pub fn input_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
335 node_y
336 + self.title_height
337 + 3.0
338 + (pin as f32 + 0.5) * self.pin_row_height
339 }
340
341 pub fn output_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
342 self.input_pin_y(node_y, pin)
343 }
344
345 /// Escala todas las longitudes por `zoom` (para el render con cámara). Los
346 /// tamaños de fuente escalan también, así el zoom es real (no un stretch).
347 fn scaled(&self, zoom: f32) -> Self {
348 Self {
349 node_width: self.node_width * zoom,
350 title_height: self.title_height * zoom,
351 pin_row_height: self.pin_row_height * zoom,
352 pin_radius: self.pin_radius * zoom,
353 pin_label_size: self.pin_label_size * zoom,
354 title_text_size: self.title_text_size * zoom,
355 wire_stroke: self.wire_stroke * zoom,
356 node_radius: self.node_radius * zoom as f64,
357 wire_nearest_side: self.wire_nearest_side,
358 node_elevacion: self.node_elevacion * zoom,
359 vertical: self.vertical,
360 // No es una longitud: el zoom no cambia cómo se conecta.
361 conectar_por_borde: self.conectar_por_borde,
362 }
363 }
364}
365
366/// Cámara del lienzo: desplazamiento (pan) + escala (zoom). El caller la
367/// guarda en su `Model`; el widget transforma las coordenadas de canvas de
368/// cada nodo/cable a coordenadas de pantalla con `screen = canvas * zoom +
369/// pan`. `Default` = identidad (sin pan, zoom 1) → render idéntico al clásico.
370#[derive(Debug, Clone, Copy, PartialEq)]
371pub struct Camera {
372 pub pan_x: f32,
373 pub pan_y: f32,
374 pub zoom: f32,
375}
376
377impl Default for Camera {
378 fn default() -> Self {
379 Self { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 }
380 }
381}
382
383/// Zoom mínimo/máximo del lienzo.
384pub const ZOOM_MIN: f32 = 0.25;
385pub const ZOOM_MAX: f32 = 4.0;
386
387impl Camera {
388 /// Coordenada de pantalla de un punto de canvas.
389 pub fn apply(&self, x: f32, y: f32) -> (f32, f32) {
390 (x * self.zoom + self.pan_x, y * self.zoom + self.pan_y)
391 }
392
393 /// Coordenada de canvas de un punto de pantalla (inversa de [`apply`]).
394 pub fn invert(&self, sx: f32, sy: f32) -> (f32, f32) {
395 let z = self.zoom.max(f32::EPSILON);
396 ((sx - self.pan_x) / z, (sy - self.pan_y) / z)
397 }
398}
399
400/// Nuevo zoom tras un `wheel_delta` **de la rueda ya normalizada por Llimphi**,
401/// multiplicativo y clampeado a `[ZOOM_MIN, ZOOM_MAX]`. Pura y testeable.
402///
403/// **El signo es el de Llimphi, no el de la aritmética.** El runner de ventana
404/// invierte la `y` de winit (`input.rs::handle_mouse_wheel`) para que el delta
405/// sea un *desplazamiento de contenido*: rueda hacia adelante → `dy` NEGATIVO,
406/// que es lo que un scroll necesita para subir. Un zoom quiere lo contrario —
407/// rueda hacia adelante ACERCA, la convención de Maps, Figma y Blender—, así
408/// que acá el delta se niega. El propio motor ya lo hacía en su camino de
409/// `ctrl+rueda` (`1.1^(-dy)` en `process_wheel`): esta función era el único
410/// lugar que usaba el signo crudo, y por eso el zoom salía al revés.
411///
412/// Estaba mal en los **trece** llamadores a la vez —ninguno negaba el delta—,
413/// que es la razón de arreglarlo acá y no en cada app: el parámetro se llama
414/// `wheel_delta`, así que respetar el sentido del gesto es contrato de esta
415/// función. Reportado en metal sobre nnawi: *«el wheel está al revés»*.
416pub fn zoom_step(zoom: f32, wheel_delta: f32) -> f32 {
417 // Factor exponencial suave: cada tick escala ~10 %.
418 let factor = (-wheel_delta * 0.1).exp();
419 (zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX)
420}
421
422/// Aplica un nuevo zoom manteniendo **fijo el centro del viewport** (el punto
423/// de canvas bajo el centro no se mueve en pantalla). Devuelve la cámara
424/// ajustada. El caller pasa el tamaño del viewport (que conoce de su layout),
425/// igual que el contrato del `scroll` widget. Sin esto, el zoom "escapa" hacia
426/// la esquina.
427pub fn zoom_at_center(cam: Camera, new_zoom: f32, viewport_w: f32, viewport_h: f32) -> Camera {
428 let new_zoom = new_zoom.clamp(ZOOM_MIN, ZOOM_MAX);
429 let (cx, cy) = (viewport_w * 0.5, viewport_h * 0.5);
430 // Punto de canvas actualmente bajo el centro.
431 let (canvas_cx, canvas_cy) = cam.invert(cx, cy);
432 // Reponer el pan para que ese mismo punto siga bajo el centro con el zoom nuevo.
433 Camera {
434 pan_x: cx - canvas_cx * new_zoom,
435 pan_y: cy - canvas_cy * new_zoom,
436 zoom: new_zoom,
437 }
438}
439
440// ─────────────────────────────────────────────────────────────────────────
441// Navegación de DAGs — layout automático, encuadre y conos (puros, testeables)
442// ─────────────────────────────────────────────────────────────────────────
443
444/// Posición asignada por [`layout_por_rango`] a un nodo.
445#[derive(Debug, Clone, Copy, PartialEq)]
446pub struct LayoutPos {
447 pub id: NodeId,
448 pub x: f32,
449 pub y: f32,
450}
451
452/// Opciones del layout por rango topológico.
453#[derive(Debug, Clone, Copy)]
454pub struct LayoutRango {
455 /// Margen desde el origen del lienzo.
456 pub margin: f32,
457 /// Separación horizontal extra entre columnas (rangos).
458 pub col_gap: f32,
459 /// Separación vertical entre filas dentro de un rango.
460 pub row_gap: f32,
461 /// Si `true`, los rangos apilan de **arriba hacia abajo** (eje principal Y) y
462 /// los nodos de un rango se reparten en X. `false` (default) = rangos de
463 /// izquierda a derecha. En ambos casos cada rango se **centra** respecto del
464 /// más poblado (autoequilibrado).
465 pub vertical: bool,
466
467}
468
469impl Default for LayoutRango {
470 fn default() -> Self {
471 Self { margin: 24.0, col_gap: 36.0, row_gap: 12.0, vertical: false }
472 }
473}
474
475/// Asigna posiciones a un DAG por **rango topológico**: la columna de un nodo =
476/// longitud del camino más largo desde una raíz (rank), la fila = su orden
477/// (estable) dentro del rango. `edges` son aristas dirigidas `(from, to)`. Es la
478/// única implementación de este layout — antes vivía atada a
479/// `pluma-notebook-graph`. Determinista y pura. Nodos en ciclo o inalcanzables
480/// caen al rango 0 (mejor esfuerzo, sin colgarse).
481pub fn layout_por_rango(
482 ids: &[NodeId],
483 edges: &[(NodeId, NodeId)],
484 metrics: &NodegraphMetrics,
485 opts: LayoutRango,
486) -> Vec<LayoutPos> {
487 let n = ids.len();
488 let index: HashMap<NodeId, usize> = ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
489
490 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
491 let mut indeg = vec![0usize; n];
492 for &(f, t) in edges {
493 if let (Some(&fi), Some(&ti)) = (index.get(&f), index.get(&t)) {
494 adj[fi].push(ti);
495 indeg[ti] += 1;
496 }
497 }
498
499 // Kahn + longest-path para el rank.
500 let mut rank = vec![0i32; n];
501 let mut remaining = indeg.clone();
502 let mut queue: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
503 while let Some(u) = queue.pop_front() {
504 for &v in &adj[u] {
505 if rank[u] + 1 > rank[v] {
506 rank[v] = rank[u] + 1;
507 }
508 remaining[v] -= 1;
509 if remaining[v] == 0 {
510 queue.push_back(v);
511 }
512 }
513 }
514
515 let max_rank = rank.iter().copied().max().unwrap_or(0);
516 let n_ranks = (max_rank + 1) as usize;
517
518 // Conteo por rango, para centrar cada rango respecto del más poblado.
519 let mut count = vec![0i32; n_ranks];
520 for &r in &rank {
521 count[r as usize] += 1;
522 }
523 let max_count = count.iter().copied().max().unwrap_or(1).max(1);
524
525 let node_h = metrics.node_height(1, 1);
526 // Eje principal = entre rangos; eje cruzado = entre nodos de un rango.
527 let (main_step, cross_step) = if opts.vertical {
528 (node_h + opts.row_gap, metrics.node_width + opts.col_gap)
529 } else {
530 (metrics.node_width + opts.col_gap, node_h + opts.row_gap)
531 };
532
533 let mut row_in_rank = vec![0i32; n_ranks];
534 let mut out = Vec::with_capacity(n);
535 for (i, &id) in ids.iter().enumerate() {
536 let r = rank[i] as usize;
537 let row = row_in_rank[r];
538 row_in_rank[r] += 1;
539 // Autoequilibrado: media diferencia de población respecto del rango mayor.
540 let cross_offset = (max_count - count[r]) as f32 * cross_step * 0.5;
541 let main = opts.margin + rank[i] as f32 * main_step;
542 let cross = opts.margin + cross_offset + row as f32 * cross_step;
543 let (x, y) = if opts.vertical { (cross, main) } else { (main, cross) };
544 out.push(LayoutPos { id, x, y });
545 }
546 out
547}
548
549/// Cámara que **encuadra** todos los nodos en un viewport dado, con un pequeño
550/// margen relativo. Sin nodos → [`Camera::default`]. El zoom se clampa a
551/// `[ZOOM_MIN, ZOOM_MAX]`. Pura; base del botón "encuadrar" y del arranque
552/// centrado. Espeja el contrato de [`zoom_at_center`] (el caller pasa el tamaño
553/// del viewport que conoce de su layout).
554pub fn fit_to_view(
555 nodes: &[NodeSpec],
556 metrics: &NodegraphMetrics,
557 viewport_w: f32,
558 viewport_h: f32,
559) -> Camera {
560 if nodes.is_empty() || viewport_w <= 0.0 || viewport_h <= 0.0 {
561 return Camera::default();
562 }
563 let mut min_x = f32::INFINITY;
564 let mut min_y = f32::INFINITY;
565 let mut max_x = f32::NEG_INFINITY;
566 let mut max_y = f32::NEG_INFINITY;
567 for n in nodes {
568 let h = metrics.node_height(n.inputs.len(), n.outputs.len());
569 min_x = min_x.min(n.x);
570 min_y = min_y.min(n.y);
571 max_x = max_x.max(n.x + metrics.node_width);
572 max_y = max_y.max(n.y + h);
573 }
574 let content_w = (max_x - min_x).max(1.0);
575 let content_h = (max_y - min_y).max(1.0);
576 // 0.9 deja un respiro alrededor del contenido.
577 let zoom = ((viewport_w / content_w).min(viewport_h / content_h) * 0.9)
578 .clamp(ZOOM_MIN, ZOOM_MAX);
579 let content_cx = (min_x + max_x) * 0.5;
580 let content_cy = (min_y + max_y) * 0.5;
581 Camera {
582 pan_x: viewport_w * 0.5 - content_cx * zoom,
583 pan_y: viewport_h * 0.5 - content_cy * zoom,
584 zoom,
585 }
586}
587
588/// Nodos **aguas-arriba** de `id` (sus ancestros por `edges` dirigidas
589/// `(from, to)`), incluyendo `id`. Ordenado y sin duplicados. Sirve para
590/// resaltar qué toca *cercenar* o *conciliar* antes de confirmar.
591pub fn cono_ascendente(edges: &[(NodeId, NodeId)], id: NodeId) -> Vec<NodeId> {
592 let mut visited: HashSet<NodeId> = HashSet::new();
593 visited.insert(id);
594 let mut stack = vec![id];
595 while let Some(cur) = stack.pop() {
596 for &(f, t) in edges {
597 if t == cur && visited.insert(f) {
598 stack.push(f);
599 }
600 }
601 }
602 let mut out: Vec<NodeId> = visited.into_iter().collect();
603 out.sort_unstable();
604 out
605}
606
607/// Nodos **aguas-abajo** de `id` (sus descendientes por `edges`), incluyendo
608/// `id`. Ordenado y sin duplicados.
609pub fn cono_descendente(edges: &[(NodeId, NodeId)], id: NodeId) -> Vec<NodeId> {
610 let mut visited: HashSet<NodeId> = HashSet::new();
611 visited.insert(id);
612 let mut stack = vec![id];
613 while let Some(cur) = stack.pop() {
614 for &(f, t) in edges {
615 if f == cur && visited.insert(t) {
616 stack.push(t);
617 }
618 }
619 }
620 let mut out: Vec<NodeId> = visited.into_iter().collect();
621 out.sort_unstable();
622 out
623}
624
625type DragNodeFn<Msg> =
626 Arc<dyn Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
627type ConnectFn<Msg> = Arc<
628 dyn Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync,
629>;
630
631/// Codifica `(node_id, pin_idx)` en el `u64` que viaja como payload del
632/// drag de un pin. 32 bits superiores = node_id, 16 bits inferiores =
633/// pin_idx.
634#[inline]
635fn encode_payload(node: NodeId, pin: PinIdx) -> u64 {
636 ((node as u64) << 32) | (pin as u64)
637}
638
639#[inline]
640fn decode_payload(payload: u64) -> (NodeId, PinIdx) {
641 let node = (payload >> 32) as NodeId;
642 let pin = (payload & 0xFFFF) as PinIdx;
643 (node, pin)
644}
645
646/// Construye el lienzo de nodos. `on_drag_node` se invoca con el delta
647/// del cursor cuando el usuario arrastra la title bar de un nodo (las
648/// fases `Move` y `End` se reenvían tal cual). `on_connect` se invoca
649/// cuando el usuario suelta un cable iniciado en un pin de salida
650/// sobre un pin de entrada de otro nodo.
651pub fn nodegraph_view<Msg, FDrag, FConnect>(
652 nodes: &[NodeSpec],
653 wires: &[Wire],
654 palette: &NodegraphPalette,
655 metrics: &NodegraphMetrics,
656 on_drag_node: FDrag,
657 on_connect: FConnect,
658) -> View<Msg>
659where
660 Msg: Clone + Send + Sync + 'static,
661 FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
662 FConnect:
663 Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
664{
665 nodegraph_view_ex::<Msg, FDrag, FConnect, fn(NodeId) -> Option<Msg>>(
666 nodes,
667 wires,
668 palette,
669 metrics,
670 on_drag_node,
671 on_connect,
672 None,
673 )
674}
675
676/// Variante extendida con un handler opcional de click derecho sobre
677/// la title bar de cada nodo. Permite a la app montar acciones por-nodo
678/// (estilo "ejecutar desde aquí" en un notebook reactivo, o "duplicar
679/// este nodo" en un editor de cadena de audio) sin esperar a que el
680/// widget tenga un menú contextual propio.
681///
682/// `on_right_click_node` se evalúa una vez por nodo al construir la
683/// vista — si devuelve `Some(msg)`, el runtime emite ese `Msg` al hacer
684/// right-click sobre la title bar; `None` deja al nodo sin acción
685/// contextual.
686pub fn nodegraph_view_ex<Msg, FDrag, FConnect, FRight>(
687 nodes: &[NodeSpec],
688 wires: &[Wire],
689 palette: &NodegraphPalette,
690 metrics: &NodegraphMetrics,
691 on_drag_node: FDrag,
692 on_connect: FConnect,
693 on_right_click_node: Option<FRight>,
694) -> View<Msg>
695where
696 Msg: Clone + Send + Sync + 'static,
697 FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
698 FConnect:
699 Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
700 FRight: Fn(NodeId) -> Option<Msg>,
701{
702 nodegraph_view_styled(
703 nodes,
704 wires,
705 palette,
706 metrics,
707 on_drag_node,
708 on_connect,
709 on_right_click_node,
710 None,
711 None,
712 )
713}
714
715/// Variante con realce: además de los handlers, acepta dos closures de
716/// estilo evaluados en construcción —`node_tint(id)` tiñe nodos puntuales
717/// y `wire_tint(&Wire)` recolorea cables— para que el caller marque un
718/// subgrafo (cono afectado, ruta crítica, celda con error…) sin tocar la
719/// paleta global. Ambos `None` = render idéntico a [`nodegraph_view`].
720#[allow(clippy::too_many_arguments)]
721pub fn nodegraph_view_styled<Msg, FDrag, FConnect, FRight>(
722 nodes: &[NodeSpec],
723 wires: &[Wire],
724 palette: &NodegraphPalette,
725 metrics: &NodegraphMetrics,
726 on_drag_node: FDrag,
727 on_connect: FConnect,
728 on_right_click_node: Option<FRight>,
729 node_tint: Option<&dyn Fn(NodeId) -> Option<NodeTint>>,
730 wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
731) -> View<Msg>
732where
733 Msg: Clone + Send + Sync + 'static,
734 FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
735 FConnect:
736 Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
737 FRight: Fn(NodeId) -> Option<Msg>,
738{
739 let on_drag: DragNodeFn<Msg> = Arc::new(on_drag_node);
740 let on_connect: ConnectFn<Msg> = Arc::new(on_connect);
741
742 let painted = precompute_wires(nodes, wires, metrics, palette.wire, wire_tint, None);
743 let stroke_px = metrics.wire_stroke;
744
745 let mut children: Vec<View<Msg>> = Vec::with_capacity(nodes.len() + 1);
746
747 // Capa 0 — cables (van detrás de los nodos).
748 children.push(wires_layer(painted, stroke_px, 0.0));
749
750 // Capa 1..N — nodos.
751 for node in nodes {
752 let right_click_msg = on_right_click_node
753 .as_ref()
754 .and_then(|f| f(node.id));
755 let tint = node_tint.and_then(|f| f(node.id));
756 children.push(node_view(
757 node,
758 palette,
759 metrics,
760 &on_drag,
761 &on_connect,
762 right_click_msg,
763 tint,
764 false,
765 None,
766 // El visualizador clásico no lleva cuerpos: el cuerpo entra por
767 // `NodegraphSpec`, que es el lienzo navegable.
768 None,
769 // Ni conexión por el borde: también es del lienzo navegable.
770 false,
771 ));
772 }
773
774 View::new(Style {
775 size: Size {
776 width: percent(1.0_f32),
777 height: percent(1.0_f32),
778 },
779 ..Default::default()
780 })
781 .fill(palette.bg_canvas)
782 .clip(true)
783 .children(children)
784}
785
786// ─────────────────────────────────────────────────────────────────────────
787// Variante con cámara (pan/zoom) + selección — `nodegraph_view_spec`
788// ─────────────────────────────────────────────────────────────────────────
789
790type NodeMsgFn<Msg> = Arc<dyn Fn(NodeId) -> Option<Msg> + Send + Sync>;
791type PanFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
792type ZoomFn<Msg> = Arc<dyn Fn(f32) -> Option<Msg> + Send + Sync>;
793
794/// Especificación completa del lienzo **navegable**: además de mover nodos y
795/// conectar pins, soporta **cámara** (pan/zoom), **selección** de nodos y
796/// **deselección** al clickear el fondo. Es el editor de grafo real (vs. el
797/// visualizador de las funciones `nodegraph_view*`, que son este spec con
798/// cámara identidad y sin selección).
799///
800/// Todo lo interactivo es opcional: deja los `None` y `Camera::default()` para
801/// obtener el comportamiento clásico. Las coordenadas de `nodes` son de
802/// **canvas** (lógicas); el widget las proyecta a pantalla con la cámara. El
803/// delta de arrastre de un nodo se devuelve ya **en coordenadas de canvas**
804/// (dividido por el zoom), así el caller lo suma directo a `node.x/y`.
805pub struct NodegraphSpec<'a, Msg: Clone + Send + Sync + 'static> {
806 pub nodes: &'a [NodeSpec],
807 pub wires: &'a [Wire],
808 /// Aristas simples nodo→nodo (sin pins), pintadas junto a los cables. Para
809 /// grafos de relación/DAG genérico; dejar `&[]` para el clásico de pins.
810 pub edges: &'a [Edge],
811 pub palette: &'a NodegraphPalette,
812 pub metrics: &'a NodegraphMetrics,
813 pub camera: Camera,
814 pub on_drag_node: DragNodeFn<Msg>,
815 pub on_connect: ConnectFn<Msg>,
816 pub on_right_click_node: Option<NodeMsgFn<Msg>>,
817 pub node_tint: Option<&'a dyn Fn(NodeId) -> Option<NodeTint>>,
818 pub wire_tint: Option<&'a dyn Fn(&Wire) -> Option<Color>>,
819 /// Cable **punteado** en vez de continuo. Es para la distinción que el color
820 /// no puede llevar solo: lo que se sabe contra lo que se supone. `soltar`
821 /// pinta así la clave foránea que nadie declaró y que sólo se ve en el dato
822 /// —sólida cuando una persona se hizo cargo, punteada mientras es una
823 /// inferencia—, y esa diferencia es el contenido del grafo, no un adorno.
824 pub wire_dash: Option<&'a dyn Fn(&Wire) -> bool>,
825 /// Recolorea aristas simples (p.ej. el cono resaltado). `None` = paleta.
826 pub edge_tint: Option<&'a dyn Fn(&Edge) -> Option<Color>>,
827 /// Arista **punteada**, igual que [`NodegraphSpec::wire_dash`] pero para las
828 /// aristas simples.
829 pub edge_dash: Option<&'a dyn Fn(&Edge) -> bool>,
830 /// Fase 0..1 del **flujo** animado de las aristas (un guion sutil que corre
831 /// ancestro→descendiente). `0.0` = sin flujo. La app la avanza con un tick.
832 pub flujo: f32,
833 /// Predicado de selección: `true` = el nodo se pinta con borde de acento.
834 pub selected: Option<&'a dyn Fn(NodeId) -> bool>,
835 /// Click en el cuerpo de un nodo → `on_select(id)`.
836 pub on_select_node: Option<NodeMsgFn<Msg>>,
837 /// Click en el fondo vacío del lienzo → este `Msg` (deseleccionar).
838 pub on_canvas_click: Option<Msg>,
839 /// Arrastre del fondo vacío → `on_pan(phase, dx, dy)` (delta de pantalla).
840 pub on_pan: Option<PanFn<Msg>>,
841 /// Rueda sobre el lienzo → `on_zoom(dy)` (el caller resuelve con
842 /// [`zoom_step`]/[`zoom_at_center`]).
843 pub on_zoom: Option<ZoomFn<Msg>>,
844 /// **Cuerpo embebido** por nodo (ver [`CuerpoNodo`]). Recibe el id y el
845 /// zoom vigente; `None` = el nodo clásico, sin nada bajo los pins.
846 ///
847 /// ⚠️ [`fit_to_view`] **no** conoce los cuerpos: encuadra sobre la altura
848 /// de pins nomás, así que un lienzo de nodos altos queda encuadrado un poco
849 /// corto por abajo. Decirlo acá es más barato que un parámetro que casi
850 /// nadie usaría.
851 pub cuerpo: Option<&'a dyn Fn(NodeId, f32) -> Option<CuerpoNodo<Msg>>>,
852}
853
854/// Compone el lienzo navegable a partir de un [`NodegraphSpec`].
855pub fn nodegraph_view_spec<Msg>(spec: NodegraphSpec<'_, Msg>) -> View<Msg>
856where
857 Msg: Clone + Send + Sync + 'static,
858{
859 nodegraph_view_spec_ext(spec, NodegraphExtras::default())
860}
861
862/// [`nodegraph_view_spec`] con lo de [`NodegraphExtras`] — hoy, los puños para
863/// arrastrar la punta de una arista a otro nodo.
864pub fn nodegraph_view_spec_ext<Msg>(
865 spec: NodegraphSpec<'_, Msg>,
866 extras: NodegraphExtras<Msg>,
867) -> View<Msg>
868where
869 Msg: Clone + Send + Sync + 'static,
870{
871 let NodegraphSpec {
872 nodes,
873 wires,
874 edges,
875 palette,
876 metrics,
877 camera,
878 on_drag_node,
879 on_connect,
880 on_right_click_node,
881 node_tint,
882 wire_tint,
883 wire_dash,
884 edge_tint,
885 edge_dash,
886 flujo,
887 selected,
888 on_select_node,
889 on_canvas_click,
890 on_pan,
891 on_zoom,
892 cuerpo,
893 } = spec;
894
895 let zoom = camera.zoom.max(f32::EPSILON);
896 // Métricas escaladas + nodos en coordenadas de PANTALLA (canvas·zoom+pan).
897 let scaled = metrics.scaled(zoom);
898 let screen_nodes: Vec<NodeSpec> = nodes
899 .iter()
900 .map(|n| {
901 let (sx, sy) = camera.apply(n.x, n.y);
902 NodeSpec { x: sx, y: sy, ..n.clone() }
903 })
904 .collect();
905
906 // El delta de arrastre llega en px de pantalla; lo pasamos a canvas (÷zoom)
907 // antes de dárselo al caller, así suma directo a node.x/y lógicos.
908 let on_drag_canvas: DragNodeFn<Msg> = {
909 let inner = on_drag_node.clone();
910 Arc::new(move |id, phase, dx, dy| (inner)(id, phase, dx / zoom, dy / zoom))
911 };
912
913 // **El alto real de cada nodo**, en pantalla: el clásico más el cuerpo. Se
914 // calcula una vez acá porque lo necesitan los cables (para saber por dónde
915 // enganchan) y el hit-test del soltado, y calcularlo dos veces es cómo el
916 // cable termina en un lugar distinto del que se soltó.
917 let altos: Vec<f32> = screen_nodes
918 .iter()
919 .map(|n| {
920 let clasico = scaled.node_height(n.inputs.len(), n.outputs.len());
921 let cuerpo_alto =
922 cuerpo.and_then(|f| f(n.id, zoom)).map(|c| c.alto * zoom).unwrap_or(0.0);
923 clasico + cuerpo_alto
924 })
925 .collect();
926 let anclas_fn = extras.anclas.clone();
927 let mut painted =
928 precompute_wires(&screen_nodes, wires, &scaled, palette.wire, wire_tint, wire_dash);
929 painted.extend(precompute_edges(
930 &screen_nodes,
931 edges,
932 &scaled,
933 palette.wire,
934 edge_tint,
935 edge_dash,
936 &altos,
937 anclas_fn.as_ref().map(|f| f.as_ref() as &dyn Fn(usize) -> _),
938 ));
939 let stroke_px = scaled.wire_stroke;
940 // Las puntas, para los puños: `painted` se consume al armar la capa.
941 //
942 // **Sólo las aristas**, que van después de los wires: el índice que el puño
943 // devuelve es el de `edges`, y contando desde el principio un grafo con pins
944 // Y aristas le mandaba a reconectar la arista equivocada.
945 let punos: Vec<(f32, f32, f32, f32)> = painted
946 .iter()
947 .skip(wires.len())
948 .map(|w| (w.x1, w.y1, w.x2, w.y2))
949 .collect();
950
951 let mut children: Vec<View<Msg>> = Vec::with_capacity(nodes.len() + 1);
952
953 // Capa 0 — cables + captura de click/deselección en el fondo. El click sin
954 // mover deselecciona; el arrastre (pan) lo toma el root draggable.
955 let mut wires = wires_layer(painted, stroke_px, flujo);
956 if let Some(msg) = on_canvas_click {
957 wires = wires.on_click(msg);
958 }
959 children.push(wires);
960
961 // Capa 1..N — nodos (en coords de pantalla), con selección.
962 for node in &screen_nodes {
963 let right_click_msg = on_right_click_node.as_ref().and_then(|f| f(node.id));
964 let tint = node_tint.and_then(|f| f(node.id));
965 let is_sel = selected.map(|f| f(node.id)).unwrap_or(false);
966 let select_msg = on_select_node.as_ref().and_then(|f| f(node.id));
967 // El alto del cuerpo viene en coordenadas de canvas; acá ya estamos en
968 // pantalla, así que se escala como cualquier otra longitud.
969 let cuerpo_nodo = cuerpo.and_then(|f| f(node.id, zoom)).map(|c| CuerpoNodo {
970 alto: c.alto * zoom,
971 vista: c.vista,
972 });
973 children.push(node_view(
974 node,
975 palette,
976 &scaled,
977 &on_drag_canvas,
978 &on_connect,
979 right_click_msg,
980 tint,
981 is_sel,
982 select_msg,
983 cuerpo_nodo,
984 scaled.conectar_por_borde,
985 ));
986 }
987
988 // **Los puños de las puntas.** Van al final para quedar encima de los nodos:
989 // el hit-test elige el más interno con handler, y debajo no los agarraría
990 // nadie. Sólo con conexión por el borde — en un grafo de pins una punta
991 // suelta no significa nada.
992 if scaled.conectar_por_borde
993 && (extras.on_reconectar.is_some() || extras.on_tirar_puno.is_some())
994 {
995 for (i, (x1, y1, x2, y2)) in punos.iter().copied().enumerate() {
996 for (extremo, (px, py)) in
997 [(ExtremoArista::Desde, (x1, y1)), (ExtremoArista::Hasta, (x2, y2))]
998 {
999 // **Mientras se arrastra, el puño está donde lo llevaron**, no
1000 // donde lo pone la geometría: sin eso el tirador se queda clavado
1001 // y el gesto no se ve.
1002 let arrastrando = extras.tirando.filter(|(j, e, _, _)| *j == i && *e == extremo);
1003 let (px, py) = match arrastrando {
1004 Some((_, _, cx, cy)) => camera.apply(cx, cy),
1005 None => (px, py),
1006 };
1007 // **El acumulador vive adentro del gesto.** El runtime clona la
1008 // closure al empezar el arrastre y la mantiene aunque la vista se
1009 // regenere (ver `DragFn`), así que una `base` capturada no ve los
1010 // puntos nuevos: con deltas incrementales la punta se movería un
1011 // solo paso desde donde empezó y se quedaría ahí. Dos `AtomicU32`
1012 // con los bits del `f32` porque esto es `no_std` y `forbid(unsafe)`.
1013 let acum = alloc::sync::Arc::new((
1014 core::sync::atomic::AtomicU32::new(camera.invert(px, py).0.to_bits()),
1015 core::sync::atomic::AtomicU32::new(camera.invert(px, py).1.to_bits()),
1016 ));
1017 let tirar = extras.on_tirar_puno.clone();
1018 let reconectar = extras.on_reconectar.clone();
1019 // Para resolver a quién le cayó: las cajas de los nodos y quién
1020 // está en la otra punta (nadie se conecta consigo mismo).
1021 let cajas: alloc::vec::Vec<(NodeId, f32, f32, f32, f32)> = screen_nodes
1022 .iter()
1023 .zip(altos.iter())
1024 .map(|(n, h)| (n.id, n.x, n.y, scaled.node_width, *h))
1025 .collect();
1026 let otro_nodo = edges.get(i).map(|e| match extremo {
1027 ExtremoArista::Desde => e.to_node,
1028 ExtremoArista::Hasta => e.from_node,
1029 });
1030 let cam = camera;
1031 children.push(
1032 View::new(Style {
1033 position: Position::Absolute,
1034 inset: Rect {
1035 left: length(px - PUNO_R),
1036 top: length(py - PUNO_R),
1037 right: length(0.0_f32),
1038 bottom: length(0.0_f32),
1039 },
1040 size: Size {
1041 width: length(PUNO_R * 2.0),
1042 height: length(PUNO_R * 2.0),
1043 },
1044 ..Default::default()
1045 })
1046 .fill(palette.pin_output)
1047 .radius(PUNO_R as f64)
1048 .draggable(move |phase: DragPhase, dx: f32, dy: f32| {
1049 use core::sync::atomic::Ordering::Relaxed;
1050 let z = cam.zoom.max(f32::EPSILON);
1051 let cx = f32::from_bits(acum.0.load(Relaxed)) + dx / z;
1052 let cy = f32::from_bits(acum.1.load(Relaxed)) + dy / z;
1053 acum.0.store(cx.to_bits(), Relaxed);
1054 acum.1.store(cy.to_bits(), Relaxed);
1055 if matches!(phase, DragPhase::End) {
1056 // **Soltar sobre un nodo reconecta**; soltar al aire
1057 // deja todo como estaba, y las dos cosas tienen que
1058 // avisar o el fantasma queda pintado para siempre.
1059 let (sx, sy) = cam.apply(cx, cy);
1060 let destino = cajas
1061 .iter()
1062 .rev()
1063 .find(|(id, nx, ny, w, h)| {
1064 sx >= *nx
1065 && sx <= nx + w
1066 && sy >= *ny
1067 && sy <= ny + h
1068 && Some(*id) != otro_nodo
1069 })
1070 .copied();
1071 if let (Some((id, nx, ny, w, h)), Some(f)) = (destino, reconectar.as_ref())
1072 {
1073 let ancla = ancla_normalizada(nx, ny, w, h, (sx, sy));
1074 return f(i, extremo, id, ancla);
1075 }
1076 }
1077 tirar.as_ref().and_then(|f| f(i, extremo, phase, cx, cy))
1078 })
1079 .role(llimphi_compositor::Role::Button)
1080 .aria_label(String::from("↔")),
1081 );
1082 }
1083 }
1084 }
1085
1086 // **El cable fantasma**: de la punta que no se mueve a donde va la mano. Es
1087 // la respuesta del gesto, y va último para quedar por encima de todo.
1088 if let Some((i, extremo, cx, cy)) = extras.tirando {
1089 if let Some((x1, y1, x2, y2)) = punos.get(i).copied() {
1090 let fijo = match extremo {
1091 ExtremoArista::Desde => (x2, y2),
1092 ExtremoArista::Hasta => (x1, y1),
1093 };
1094 let (sx, sy) = camera.apply(cx, cy);
1095 children.push(wires_layer(
1096 alloc::vec![WirePainted {
1097 x1: fijo.0,
1098 y1: fijo.1,
1099 x2: sx,
1100 y2: sy,
1101 d1: if sx >= fijo.0 { 1.0 } else { -1.0 },
1102 d2: if sx >= fijo.0 { -1.0 } else { 1.0 },
1103 color: palette.pin_drop_hover,
1104 vertical: false,
1105 // Punteado: es una propuesta, no un cable. Un fantasma que se
1106 // pinta igual que lo firme se lee como que ya quedó.
1107 punteada: true,
1108 }],
1109 stroke_px,
1110 flujo,
1111 ));
1112 }
1113 }
1114
1115 let mut root = View::new(Style {
1116 size: Size {
1117 width: percent(1.0_f32),
1118 height: percent(1.0_f32),
1119 },
1120 ..Default::default()
1121 })
1122 .fill(palette.bg_canvas)
1123 .clip(true)
1124 .children(children);
1125
1126 // Pan: arrastre del fondo (los nodos/pins, draggables internos, ganan).
1127 if let Some(pan) = on_pan {
1128 root = root.draggable(move |phase, dx, dy| (pan)(phase, dx, dy));
1129 }
1130 // Zoom con la rueda.
1131 if let Some(zoomf) = on_zoom {
1132 root = root.on_scroll(move |_dx, dy| (zoomf)(dy));
1133 }
1134 root
1135}
1136
1137#[derive(Debug, Clone, Copy)]
1138struct WirePainted {
1139 x1: f32,
1140 y1: f32,
1141 x2: f32,
1142 y2: f32,
1143 /// Signo de la tangente horizontal en cada extremo (+1 sale hacia la
1144 /// derecha, -1 hacia la izquierda). Permite curvar el Bezier hacia afuera
1145 /// del lado por el que engancha cada punta.
1146 d1: f32,
1147 d2: f32,
1148 color: Color,
1149 /// Si `true`, la tangente del Bezier es vertical (aristas apiladas
1150 /// arriba→abajo) en vez de horizontal.
1151 vertical: bool,
1152 /// Si `true`, se pinta con guiones en vez de trazo continuo.
1153 punteada: bool,
1154}
1155
1156fn precompute_wires(
1157 nodes: &[NodeSpec],
1158 wires: &[Wire],
1159 metrics: &NodegraphMetrics,
1160 default_color: Color,
1161 wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
1162 wire_dash: Option<&dyn Fn(&Wire) -> bool>,
1163) -> Vec<WirePainted> {
1164 let mut out = Vec::with_capacity(wires.len());
1165 for w in wires {
1166 let from = nodes.iter().find(|n| n.id == w.from_node);
1167 let to = nodes.iter().find(|n| n.id == w.to_node);
1168 if let (Some(a), Some(b)) = (from, to) {
1169 let y1 = metrics.output_pin_y(a.y, w.from_output);
1170 let y2 = metrics.input_pin_y(b.y, w.to_input);
1171 // Modo direccional (default): salida por la derecha de `a`, entrada
1172 // por la izquierda de `b`. Modo `nearest`: el destino a la izquierda
1173 // invierte los lados para que el cable no dé la vuelta.
1174 let destino_a_la_derecha = b.x + metrics.node_width * 0.5 >= a.x + metrics.node_width * 0.5;
1175 let (x1, d1, x2, d2) = if !metrics.wire_nearest_side || destino_a_la_derecha {
1176 (a.x + metrics.node_width, 1.0, b.x, -1.0)
1177 } else {
1178 (a.x, -1.0, b.x + metrics.node_width, 1.0)
1179 };
1180 let color = wire_tint.and_then(|f| f(w)).unwrap_or(default_color);
1181 let punteada = wire_dash.map(|f| f(w)).unwrap_or(false);
1182 out.push(WirePainted { x1, y1, x2, y2, d1, d2, color, vertical: false, punteada });
1183 }
1184 }
1185 out
1186}
1187
1188/// Geometría de las aristas simples: ancla en el centro vertical de cada nodo y
1189/// en el borde horizontal del lado más cercano al otro nodo (sin depender de
1190/// pins). Comparte el tipo pintado con los cables, así viajan en la misma capa.
1191fn precompute_edges(
1192 nodes: &[NodeSpec],
1193 edges: &[Edge],
1194 metrics: &NodegraphMetrics,
1195 default_color: Color,
1196 edge_tint: Option<&dyn Fn(&Edge) -> Option<Color>>,
1197 edge_dash: Option<&dyn Fn(&Edge) -> bool>,
1198 // `altos`: el alto **real** de cada nodo, en el mismo orden que `nodes` — el
1199 // clásico más el cuerpo embebido. Sin esto el cable engancha a media altura
1200 // de la barra de título, porque `node_height` sólo cuenta pins, y en un
1201 // grafo de relación —donde todo el nodo es cuerpo— eso se ve como un cable
1202 // que entra por el vértice de arriba en vez de por el costado.
1203 altos: &[f32],
1204 // `anclas`: las que el operador dejó puestas, por índice de arista —
1205 // `(desde, hasta)` en coordenadas **normalizadas** del nodo (0..1 de ancho y
1206 // de alto). `None` = la deriva la geometría.
1207 anclas: Option<&dyn Fn(usize) -> (Option<(f32, f32)>, Option<(f32, f32)>)>,
1208) -> Vec<WirePainted> {
1209 let mut out = Vec::with_capacity(edges.len());
1210 for (i, e) in edges.iter().enumerate() {
1211 let from = nodes.iter().position(|n| n.id == e.from_node);
1212 let to = nodes.iter().position(|n| n.id == e.to_node);
1213 if let (Some(ia), Some(ib)) = (from, to) {
1214 let (a, b) = (&nodes[ia], &nodes[ib]);
1215 let a_h = altos.get(ia).copied().unwrap_or_else(|| {
1216 metrics.node_height(a.inputs.len(), a.outputs.len())
1217 });
1218 let b_h = altos.get(ib).copied().unwrap_or_else(|| {
1219 metrics.node_height(b.inputs.len(), b.outputs.len())
1220 });
1221 let color = edge_tint.and_then(|f| f(e)).unwrap_or(default_color);
1222 let punteada = edge_dash.map(|f| f(e)).unwrap_or(false);
1223 let (an_a, an_b) = anclas.map(|f| f(i)).unwrap_or((None, None));
1224 let painted = if metrics.conectar_por_borde {
1225 // **Por el borde**: cada punta sale del punto del rectángulo que
1226 // mira al otro nodo, o del que el operador dejó puesto. Es lo
1227 // que hace que dos cables del mismo nodo no salgan los dos del
1228 // mismo lugar — apilados, el de arriba tapa al de abajo y el
1229 // tirador del tapado no le contesta a nadie.
1230 let ca = (a.x + metrics.node_width * 0.5, a.y + a_h * 0.5);
1231 let cb = (b.x + metrics.node_width * 0.5, b.y + b_h * 0.5);
1232 let (x1, y1) = match an_a {
1233 Some((u, v)) => (a.x + u * metrics.node_width, a.y + v * a_h),
1234 None => punto_en_el_borde(a.x, a.y, metrics.node_width, a_h, cb),
1235 };
1236 let (x2, y2) = match an_b {
1237 Some((u, v)) => (b.x + u * metrics.node_width, b.y + v * b_h),
1238 None => punto_en_el_borde(b.x, b.y, metrics.node_width, b_h, ca),
1239 };
1240 // La tangente sale **hacia afuera** del lado por el que
1241 // engancha, así el Bezier no se mete por adentro del nodo.
1242 let d1 = if x1 <= a.x + 0.5 {
1243 -1.0
1244 } else if x1 >= a.x + metrics.node_width - 0.5 {
1245 1.0
1246 } else if x2 >= x1 {
1247 1.0
1248 } else {
1249 -1.0
1250 };
1251 let d2 = if x2 <= b.x + 0.5 {
1252 -1.0
1253 } else if x2 >= b.x + metrics.node_width - 0.5 {
1254 1.0
1255 } else if x1 >= x2 {
1256 1.0
1257 } else {
1258 -1.0
1259 };
1260 WirePainted { x1, y1, x2, y2, d1, d2, color, vertical: false, punteada }
1261 } else if metrics.vertical {
1262 // Vertical: ancla en el centro-x, borde superior/inferior más
1263 // cercano al otro nodo; tangente vertical.
1264 let ax = a.x + metrics.node_width * 0.5;
1265 let bx = b.x + metrics.node_width * 0.5;
1266 let a_cy = a.y + a_h * 0.5;
1267 let b_cy = b.y + b_h * 0.5;
1268 let (y1, d1, y2, d2) = if b_cy >= a_cy {
1269 (a.y + a_h, 1.0, b.y, -1.0)
1270 } else {
1271 (a.y, -1.0, b.y + b_h, 1.0)
1272 };
1273 WirePainted { x1: ax, y1, x2: bx, y2, d1, d2, color, vertical: true, punteada }
1274 } else {
1275 // Horizontal: centro-y, borde izquierdo/derecho más cercano.
1276 let ay = a.y + a_h * 0.5;
1277 let by = b.y + b_h * 0.5;
1278 let a_cx = a.x + metrics.node_width * 0.5;
1279 let b_cx = b.x + metrics.node_width * 0.5;
1280 let (x1, d1, x2, d2) = if b_cx >= a_cx {
1281 (a.x + metrics.node_width, 1.0, b.x, -1.0)
1282 } else {
1283 (a.x, -1.0, b.x + metrics.node_width, 1.0)
1284 };
1285 WirePainted { x1, y1: ay, x2, y2: by, d1, d2, color, vertical: false, punteada }
1286 };
1287 out.push(painted);
1288 }
1289 }
1290 out
1291}
1292
1293/// **Dónde el segmento que va al otro nodo corta el rectángulo de este nodo.**
1294///
1295/// Se resuelve con la razón más chica entre media-caja y la distancia al centro
1296/// del otro: la que primero se agota dice por qué lado sale. Con los dos centros
1297/// en el mismo punto —nodos superpuestos— devuelve el medio del lado derecho, que
1298/// es lo que hacía el widget antes y no inventa una dirección que no hay.
1299fn punto_en_el_borde(x: f32, y: f32, ancho: f32, alto: f32, otro: (f32, f32)) -> (f32, f32) {
1300 let (cx, cy) = (x + ancho * 0.5, y + alto * 0.5);
1301 let (dx, dy) = (otro.0 - cx, otro.1 - cy);
1302 if dx.abs() < f32::EPSILON && dy.abs() < f32::EPSILON {
1303 return (x + ancho, cy);
1304 }
1305 let (hx, hy) = (ancho * 0.5, alto * 0.5);
1306 // `t` es cuánto hay que avanzar en la dirección (dx,dy) para tocar el borde.
1307 let tx = if dx.abs() > f32::EPSILON { hx / dx.abs() } else { f32::INFINITY };
1308 let ty = if dy.abs() > f32::EPSILON { hy / dy.abs() } else { f32::INFINITY };
1309 let t = tx.min(ty);
1310 (cx + dx * t, cy + dy * t)
1311}
1312
1313/// Lo inverso: el punto, como ancla **normalizada** del nodo (0..1 de cada
1314/// lado). Es lo que se guarda, porque un ancla en píxeles se despega del nodo en
1315/// cuanto el nodo cambia de tamaño.
1316pub fn ancla_normalizada(
1317 x: f32,
1318 y: f32,
1319 ancho: f32,
1320 alto: f32,
1321 punto: (f32, f32),
1322) -> (f32, f32) {
1323 let u = if ancho.abs() > f32::EPSILON { (punto.0 - x) / ancho } else { 0.5 };
1324 let v = if alto.abs() > f32::EPSILON { (punto.1 - y) / alto } else { 0.5 };
1325 (u.clamp(0.0, 1.0), v.clamp(0.0, 1.0))
1326}
1327
1328fn wires_layer<Msg>(wires: Vec<WirePainted>, stroke_px: f32, flujo: f32) -> View<Msg>
1329where
1330 Msg: Clone + 'static,
1331{
1332 let nodo = View::new(Style {
1333 position: Position::Absolute,
1334 inset: Rect {
1335 left: length(0.0_f32),
1336 top: length(0.0_f32),
1337 right: length(0.0_f32),
1338 bottom: length(0.0_f32),
1339 },
1340 size: Size {
1341 width: percent(1.0_f32),
1342 height: percent(1.0_f32),
1343 },
1344 ..Default::default()
1345 });
1346 if wires.is_empty() {
1347 return nodo;
1348 }
1349 nodo.paint_with(move |scene, _ts, rect| {
1350 let stroke = Stroke::new(stroke_px as f64);
1351 // Flujo (opt-in): un guion animado, MUY sutil, corriendo por cada arista
1352 // en el sentido ancestro→descendiente. `flujo` es la fase 0..1 que la app
1353 // avanza con un tick; el offset del dash la traduce en movimiento.
1354 let flujo_dash = if flujo > 0.0 {
1355 let dash = (stroke_px as f64 * 3.0).max(5.0);
1356 let gap = dash * 2.4;
1357 let period = dash + gap;
1358 let offset = -(flujo as f64) * period;
1359 Some(Stroke::new((stroke_px * 0.9) as f64).with_dashes(offset, [dash, gap]))
1360 } else {
1361 None
1362 };
1363 // Guion FIJO de un cable dudoso — distinto del flujo, que es un brillo
1364 // que corre: acá el trazo mismo está partido, y eso se lee como «esto
1365 // todavía no es un hecho» sin depender del color (que ya lleva el tinte
1366 // y no sobrevive a un daltonismo).
1367 let punteado = {
1368 let dash = (stroke_px as f64 * 2.2).max(4.0);
1369 Stroke::new(stroke_px as f64).with_dashes(0.0, [dash, dash])
1370 };
1371 for w in &wires {
1372 // Bezier cúbica con tangentes horizontales — mismo patrón
1373 // que las hebras de pluma-editor-llimphi. `d1`/`d2` orientan la
1374 // tangente hacia afuera del lado por el que engancha cada punta.
1375 let x1 = (rect.x + w.x1) as f64;
1376 let y1 = (rect.y + w.y1) as f64;
1377 let x2 = (rect.x + w.x2) as f64;
1378 let y2 = (rect.y + w.y2) as f64;
1379 let mut path = BezPath::new();
1380 path.move_to((x1, y1));
1381 if w.vertical {
1382 // Tangente vertical: los controles se separan en Y.
1383 let mag = (y2 - y1).abs().max(40.0) * 0.5;
1384 path.curve_to((x1, y1 + w.d1 as f64 * mag), (x2, y2 + w.d2 as f64 * mag), (x2, y2));
1385 } else {
1386 // Tangente horizontal: los controles se separan en X.
1387 let dx = ((w.x2 - w.x1).abs().max(40.0) * 0.5) as f64;
1388 path.curve_to((x1 + w.d1 as f64 * dx, y1), (x2 + w.d2 as f64 * dx, y2), (x2, y2));
1389 }
1390 let trazo = if w.punteada { &punteado } else { &stroke };
1391 scene.stroke(trazo, Affine::IDENTITY, w.color, None, &path);
1392 // Guion de flujo: versión aclarada y semitransparente del color de la
1393 // arista, para que se lea como un brillo que corre sin tapar el cable.
1394 if let Some(dash) = &flujo_dash {
1395 let c = w.color.components;
1396 let flow_color = Color {
1397 components: [(c[0] + 1.0) * 0.5, (c[1] + 1.0) * 0.5, (c[2] + 1.0) * 0.5, 0.5],
1398 ..w.color
1399 };
1400 scene.stroke(dash, Affine::IDENTITY, flow_color, None, &path);
1401 }
1402 }
1403 })
1404}
1405
1406#[allow(clippy::too_many_arguments)]
1407fn node_view<Msg>(
1408 node: &NodeSpec,
1409 palette: &NodegraphPalette,
1410 metrics: &NodegraphMetrics,
1411 on_drag: &DragNodeFn<Msg>,
1412 on_connect: &ConnectFn<Msg>,
1413 on_right_click_msg: Option<Msg>,
1414 tint: Option<NodeTint>,
1415 selected: bool,
1416 on_select_msg: Option<Msg>,
1417 cuerpo: Option<CuerpoNodo<Msg>>,
1418 conectar_por_borde: bool,
1419) -> View<Msg>
1420where
1421 Msg: Clone + Send + Sync + 'static,
1422{
1423 let n_in = node.inputs.len();
1424 let n_out = node.outputs.len();
1425 // Alto de la parte clásica (título + filas de pins). El cuerpo crece hacia
1426 // abajo desde acá, así que **las posiciones de los pins no cambian** — que
1427 // es lo que hace que los cables sigan enganchando donde siempre.
1428 let alto_clasico = metrics.node_height(n_in, n_out);
1429 let alto_cuerpo = cuerpo.as_ref().map(|c| c.alto.max(0.0)).unwrap_or(0.0);
1430 let height = alto_clasico + alto_cuerpo;
1431
1432 // Colores efectivos: el tinte sobrescribe la paleta por-campo.
1433 let tint = tint.unwrap_or_default();
1434 let bg_node = tint.bg_node.unwrap_or(palette.bg_node);
1435 let bg_title = tint.bg_title.unwrap_or(palette.bg_title);
1436 let fg_title = tint.fg_title.unwrap_or(palette.fg_title);
1437
1438 let node_id = node.id;
1439 let drag = on_drag.clone();
1440 let mut title_bar = View::new(Style {
1441 size: Size {
1442 width: percent(1.0_f32),
1443 height: length(metrics.title_height),
1444 },
1445 flex_shrink: 0.0,
1446 padding: Rect {
1447 left: length(8.0_f32),
1448 right: length(8.0_f32),
1449 top: length(0.0_f32),
1450 bottom: length(0.0_f32),
1451 },
1452 ..Default::default()
1453 })
1454 .fill(bg_title)
1455 .text_aligned(
1456 node.label.clone(),
1457 metrics.title_text_size,
1458 fg_title,
1459 Alignment::Start,
1460 )
1461 .draggable(move |phase, dx, dy| (drag)(node_id, phase, dx, dy));
1462
1463 if let Some(msg) = on_right_click_msg {
1464 title_bar = title_bar.on_right_click(msg);
1465 }
1466
1467 let mut pin_layer_children: Vec<View<Msg>> = Vec::with_capacity(n_in + n_out);
1468 for (i, label) in node.inputs.iter().enumerate() {
1469 pin_layer_children.push(pin_view(
1470 node_id,
1471 i as PinIdx,
1472 PinKind::Input,
1473 label,
1474 palette,
1475 metrics,
1476 on_connect.clone(),
1477 ));
1478 }
1479 for (i, label) in node.outputs.iter().enumerate() {
1480 pin_layer_children.push(pin_view(
1481 node_id,
1482 i as PinIdx,
1483 PinKind::Output,
1484 label,
1485 palette,
1486 metrics,
1487 on_connect.clone(),
1488 ));
1489 }
1490 let pin_layer = View::new(Style {
1491 position: Position::Absolute,
1492 inset: Rect {
1493 left: length(0.0_f32),
1494 top: length(metrics.title_height),
1495 right: length(0.0_f32),
1496 // El cuerpo se lleva la franja de abajo; sin esto la capa de pins
1497 // se estiraría por encima de él.
1498 bottom: length(alto_cuerpo),
1499 },
1500 size: Size {
1501 width: percent(1.0_f32),
1502 height: Dimension::auto(),
1503 },
1504 ..Default::default()
1505 })
1506 .children(pin_layer_children);
1507
1508 let mut root = View::new(Style {
1509 position: Position::Absolute,
1510 inset: Rect {
1511 left: length(node.x),
1512 top: length(node.y),
1513 right: length(0.0_f32),
1514 bottom: length(0.0_f32),
1515 },
1516 size: Size {
1517 width: length(metrics.node_width),
1518 height: length(height),
1519 },
1520 ..Default::default()
1521 })
1522 .fill(bg_node)
1523 .radius(metrics.node_radius)
1524 // El nodo es el ítem navegable del grafo: el título vive en un hijo, así
1525 // que el nombre va explícito. `checked` marca el seleccionado.
1526 .role(llimphi_compositor::Role::ListItem)
1527 .aria_label(node.label.clone())
1528 .aria_checked(selected);
1529
1530 let mut capas = vec![title_bar, pin_layer];
1531 if let Some(c) = cuerpo {
1532 // Absoluta y anclada bajo la parte clásica: el cuerpo no empuja nada,
1533 // ocupa el espacio que el nodo ya creció para él.
1534 capas.push(
1535 View::new(Style {
1536 position: Position::Absolute,
1537 inset: Rect {
1538 left: length(0.0_f32),
1539 top: length(alto_clasico),
1540 right: length(0.0_f32),
1541 bottom: length(0.0_f32),
1542 },
1543 size: Size {
1544 width: percent(1.0_f32),
1545 height: length(alto_cuerpo),
1546 },
1547 ..Default::default()
1548 })
1549 .children(vec![c.vista]),
1550 );
1551 }
1552 // **El borde conecta.** Una capa que cubre todo menos la barra de título
1553 // —que es la que mueve— y que arrastra y recibe. Va al final de las capas
1554 // para quedar encima de los pins y del cuerpo: el hit-test elige el nodo
1555 // más interno con handler, así que si estuviera debajo no la tocaría nadie.
1556 if conectar_por_borde {
1557 let cb = on_connect.clone();
1558 capas.push(
1559 View::new(Style {
1560 position: Position::Absolute,
1561 inset: Rect {
1562 left: length(0.0_f32),
1563 top: length(metrics.title_height),
1564 right: length(0.0_f32),
1565 bottom: length(0.0_f32),
1566 },
1567 size: Size {
1568 width: percent(1.0_f32),
1569 height: length(height - metrics.title_height),
1570 },
1571 ..Default::default()
1572 })
1573 // El arrastre no mueve nada: lo que viaja es el payload.
1574 .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
1575 // Pin 0 por convención: sin pins declarados no hay cuál elegir, y
1576 // el caller que use esto no los mira.
1577 .drag_payload(encode_payload(node_id, 0))
1578 .on_drop(move |payload: u64| {
1579 // **Una punta arrastrada ya no entra por acá.** Entraba, y era el
1580 // defecto: un `on_drop` no lleva coordenadas, así que lo único
1581 // que se podía saber era «le cayó a este nodo» y la punta volvía
1582 // al lugar que la geometría quisiera — nunca al que se soltó. Hoy
1583 // lo resuelve el puño, que sí sabe dónde está la mano. Si alguna
1584 // llega igual se ignora, para no leerla como un cable nuevo.
1585 if payload & MARCA_PUNO != 0 {
1586 return None;
1587 }
1588 let (from_node, from_pin) = decode_payload(payload);
1589 // **Nadie se conecta consigo mismo.** Sale solo al soltar donde
1590 // se empezó, y no significa nada.
1591 if from_node == node_id {
1592 return None;
1593 }
1594 (cb)(from_node, from_pin, node_id, 0)
1595 })
1596 .drop_hover_fill(palette.pin_drop_hover),
1597 );
1598 }
1599 root = root.children(capas);
1600
1601 // Elevación: sombra suave sobre el fondo (opt-in por métrica).
1602 if metrics.node_elevacion > 0.0 {
1603 let blur = metrics.node_elevacion as f64;
1604 root = root.shadow(Shadow::soft(90, blur).offset(0.0, blur * 0.22));
1605 }
1606
1607 // Selección: borde de acento alrededor del nodo elegido.
1608 if selected {
1609 root = root.border(2.0, palette.wire);
1610 }
1611 // Click en el cuerpo del nodo (no en el título draggable ni en los pins) →
1612 // seleccionar. El árbitro de hit-test da prioridad a los hijos interactivos.
1613 if let Some(msg) = on_select_msg {
1614 root = root.on_click(msg);
1615 }
1616 root
1617}
1618
1619#[derive(Debug, Clone, Copy)]
1620enum PinKind {
1621 Input,
1622 Output,
1623}
1624
1625fn pin_view<Msg>(
1626 node_id: NodeId,
1627 pin_idx: PinIdx,
1628 kind: PinKind,
1629 label: &str,
1630 palette: &NodegraphPalette,
1631 metrics: &NodegraphMetrics,
1632 on_connect: ConnectFn<Msg>,
1633) -> View<Msg>
1634where
1635 Msg: Clone + Send + Sync + 'static,
1636{
1637 let y_top = pin_idx as f32 * metrics.pin_row_height;
1638 let row_h = metrics.pin_row_height;
1639 let r = metrics.pin_radius;
1640 let diam = r * 2.0;
1641
1642 let (pin_left, pin_right, dot_color, label_align) = match kind {
1643 PinKind::Input => (
1644 Some(length(-r)),
1645 None,
1646 palette.pin_input,
1647 Alignment::Start,
1648 ),
1649 PinKind::Output => (
1650 None,
1651 Some(length(-r)),
1652 palette.pin_output,
1653 Alignment::End,
1654 ),
1655 };
1656
1657 let mut dot = View::new(Style {
1658 position: Position::Absolute,
1659 inset: Rect {
1660 left: pin_left.unwrap_or_else(|| length(0.0_f32)),
1661 top: length((row_h - diam) * 0.5),
1662 right: pin_right.unwrap_or_else(|| length(0.0_f32)),
1663 bottom: length(0.0_f32),
1664 },
1665 size: Size {
1666 width: length(diam),
1667 height: length(diam),
1668 },
1669 ..Default::default()
1670 })
1671 .fill(dot_color)
1672 .radius(r as f64);
1673
1674 match kind {
1675 PinKind::Output => {
1676 dot = dot
1677 .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
1678 .drag_payload(encode_payload(node_id, pin_idx));
1679 }
1680 PinKind::Input => {
1681 let to_node = node_id;
1682 let to_pin = pin_idx;
1683 let cb = on_connect.clone();
1684 dot = dot
1685 .on_drop(move |payload: u64| {
1686 let (from_node, from_pin) = decode_payload(payload);
1687 (cb)(from_node, from_pin, to_node, to_pin)
1688 })
1689 .drop_hover_fill(palette.pin_drop_hover);
1690 }
1691 }
1692
1693 let label_view = View::new(Style {
1694 position: Position::Absolute,
1695 inset: Rect {
1696 left: length(diam + 4.0),
1697 top: length(0.0_f32),
1698 right: length(diam + 4.0),
1699 bottom: length(0.0_f32),
1700 },
1701 size: Size {
1702 width: Dimension::auto(),
1703 height: length(row_h),
1704 },
1705 ..Default::default()
1706 })
1707 .text_aligned(
1708 label.to_string(),
1709 metrics.pin_label_size,
1710 palette.fg_pin_label,
1711 label_align,
1712 );
1713
1714 View::new(Style {
1715 position: Position::Absolute,
1716 inset: Rect {
1717 left: length(0.0_f32),
1718 top: length(y_top),
1719 right: length(0.0_f32),
1720 bottom: length(0.0_f32),
1721 },
1722 size: Size {
1723 width: percent(1.0_f32),
1724 height: length(row_h),
1725 },
1726 ..Default::default()
1727 })
1728 .children(vec![label_view, dot])
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 // Estos `use` sólo los necesitan los tests: arriba quedaban como imports
1734 // muertos. Es deriva de la migración a `no_std`, donde todo lo de `alloc`
1735 // se importa a mano.
1736 use alloc::format;
1737 use super::*;
1738
1739 #[test]
1740 fn camera_apply_e_invert_son_inversas() {
1741 let cam = Camera { pan_x: 30.0, pan_y: -10.0, zoom: 2.0 };
1742 let (sx, sy) = cam.apply(50.0, 40.0);
1743 assert!((sx - (50.0 * 2.0 + 30.0)).abs() < 1e-3);
1744 assert!((sy - (40.0 * 2.0 - 10.0)).abs() < 1e-3);
1745 let (cx, cy) = cam.invert(sx, sy);
1746 assert!((cx - 50.0).abs() < 1e-3 && (cy - 40.0).abs() < 1e-3);
1747 }
1748
1749 #[test]
1750 fn zoom_step_multiplica_y_clampa() {
1751 // **El test habla del GESTO, no del signo.** El de antes decía
1752 // «acercar sube» con `zoom_step(1.0, 1.0) > 1.0`, que es cierto como
1753 // aritmética y falso como gesto: el `1.0` que llega de la rueda es el
1754 // delta ya invertido por Llimphi, o sea rueda hacia ATRÁS. Escrito así
1755 // el test pasaba y el zoom salía al revés en las trece apps.
1756 //
1757 // Convención de Llimphi (`input.rs::handle_mouse_wheel`): rueda hacia
1758 // adelante → `dy` negativo.
1759 let adelante = -1.0;
1760 let atras = 1.0;
1761 assert!(zoom_step(1.0, adelante) > 1.0, "rueda hacia adelante ACERCA");
1762 assert!(zoom_step(1.0, atras) < 1.0, "rueda hacia atrás ALEJA");
1763 // Clamp en los extremos, con el mismo criterio: no se puede acercar
1764 // más allá del tope ni alejar más acá del piso.
1765 assert_eq!(zoom_step(ZOOM_MAX, 5.0 * adelante), ZOOM_MAX);
1766 assert_eq!(zoom_step(ZOOM_MIN, 5.0 * atras), ZOOM_MIN);
1767 }
1768
1769 #[test]
1770 fn zoom_at_center_mantiene_fijo_el_centro() {
1771 let cam = Camera::default();
1772 let (vw, vh) = (800.0_f32, 600.0_f32);
1773 // Punto de canvas bajo el centro antes de zoomear.
1774 let before = cam.invert(vw * 0.5, vh * 0.5);
1775 let cam2 = zoom_at_center(cam, 2.5, vw, vh);
1776 assert!((cam2.zoom - 2.5).abs() < 1e-3);
1777 // El mismo punto de canvas sigue proyectándose al centro.
1778 let (sx, sy) = cam2.apply(before.0, before.1);
1779 assert!((sx - vw * 0.5).abs() < 1e-2, "cx fijo (dio {sx})");
1780 assert!((sy - vh * 0.5).abs() < 1e-2, "cy fijo (dio {sy})");
1781 }
1782
1783 #[test]
1784 fn scaled_metrics_escala_todo_por_zoom() {
1785 let m = NodegraphMetrics::default();
1786 let s = m.scaled(2.0);
1787 assert!((s.node_width - m.node_width * 2.0).abs() < 1e-3);
1788 assert!((s.title_text_size - m.title_text_size * 2.0).abs() < 1e-3);
1789 assert!((s.node_radius - m.node_radius * 2.0).abs() < 1e-9);
1790 }
1791
1792 // ---- el cuerpo embebido -------------------------------------------------
1793
1794 /// Arma el lienzo con y sin cuerpo y devuelve los dos árboles, para poder
1795 /// comparar **presencia contra ausencia** (Regla 8.bis del repo): un test
1796 /// que sólo mira el caso con cuerpo pasaría igual si el cuerpo se ignorara.
1797 fn lienzo_con_y_sin_cuerpo(alto: f32) -> (View<()>, View<()>) {
1798 let nodes = vec![NodeSpec {
1799 id: 1,
1800 label: "n".into(),
1801 x: 0.0,
1802 y: 0.0,
1803 inputs: vec!["a".into()],
1804 outputs: vec!["out".into()],
1805 }];
1806 let palette = NodegraphPalette::default();
1807 let metrics = NodegraphMetrics::default();
1808 let armar = |cuerpo: Option<&dyn Fn(NodeId, f32) -> Option<CuerpoNodo<()>>>| {
1809 nodegraph_view_spec(NodegraphSpec {
1810 nodes: &nodes,
1811 wires: &[],
1812 edges: &[],
1813 palette: &palette,
1814 metrics: &metrics,
1815 camera: Camera::default(),
1816 on_drag_node: Arc::new(|_, _, _, _| None),
1817 on_connect: Arc::new(|_, _, _, _| None),
1818 on_right_click_node: None,
1819 node_tint: None,
1820 wire_tint: None,
1821 wire_dash: None,
1822 edge_tint: None,
1823 edge_dash: None,
1824 flujo: 0.0,
1825 selected: None,
1826 on_select_node: None,
1827 on_canvas_click: None,
1828 on_pan: None,
1829 on_zoom: None,
1830 cuerpo,
1831 })
1832 };
1833 let con = armar(Some(&move |_, _| {
1834 Some(CuerpoNodo {
1835 alto,
1836 vista: View::new(Style::default()),
1837 })
1838 }));
1839 (con, armar(None))
1840 }
1841
1842 /// El nodo del lienzo (hijo 1: el 0 es la capa de cables).
1843 fn nodo_del_lienzo(v: &View<()>) -> &View<()> {
1844 &v.children[1]
1845 }
1846
1847 fn alto_de(v: &View<()>) -> f32 {
1848 v.style
1849 .size
1850 .height
1851 .into_option()
1852 .expect("el nodo se dimensiona en px absolutos")
1853 }
1854
1855 #[test]
1856 fn cuerpo_crece_el_nodo_exactamente_su_alto() {
1857 let (con, sin) = lienzo_con_y_sin_cuerpo(40.0);
1858 let (a, b) = (alto_de(nodo_del_lienzo(&con)), alto_de(nodo_del_lienzo(&sin)));
1859 assert!(
1860 (a - b - 40.0).abs() < 1e-3,
1861 "con cuerpo {a}, sin cuerpo {b}: la diferencia tiene que ser el alto pedido"
1862 );
1863 }
1864
1865 #[test]
1866 fn cuerpo_no_mueve_los_pins() {
1867 // Los pins viven en la capa 1 del nodo, anclada al título por arriba.
1868 // Si el cuerpo los corriera, los cables engancharían en el aire —
1869 // `precompute_wires` calcula sus Y con `input_pin_y`, que no sabe nada
1870 // de cuerpos.
1871 let (con, sin) = lienzo_con_y_sin_cuerpo(40.0);
1872 let capa = |v: &View<()>| nodo_del_lienzo(v).children[1].style.inset.top;
1873 assert_eq!(
1874 alloc::format!("{:?}", capa(&con)),
1875 alloc::format!("{:?}", capa(&sin)),
1876 "la capa de pins tiene que empezar en el mismo Y"
1877 );
1878 }
1879
1880 #[test]
1881 fn cuerpo_none_deja_el_arbol_igual_que_antes() {
1882 // Retrocompatibilidad literal: el nodo sin cuerpo tiene dos capas
1883 // (título + pins) y el que lo tiene, tres.
1884 let (con, sin) = lienzo_con_y_sin_cuerpo(40.0);
1885 assert_eq!(nodo_del_lienzo(&sin).children.len(), 2);
1886 assert_eq!(nodo_del_lienzo(&con).children.len(), 3);
1887 }
1888
1889 #[test]
1890 fn cuerpo_escala_con_el_zoom() {
1891 // El alto viene en coordenadas de canvas: a zoom 2 el nodo tiene que
1892 // crecer 80 px de pantalla, no 40.
1893 let nodes = vec![NodeSpec {
1894 id: 1,
1895 label: "n".into(),
1896 x: 0.0,
1897 y: 0.0,
1898 inputs: vec![],
1899 outputs: vec![],
1900 }];
1901 let palette = NodegraphPalette::default();
1902 let metrics = NodegraphMetrics::default();
1903 let cuerpo = move |_: NodeId, _: f32| {
1904 Some(CuerpoNodo {
1905 alto: 40.0,
1906 vista: View::new(Style::default()),
1907 })
1908 };
1909 let armar = |zoom: f32| {
1910 nodegraph_view_spec(NodegraphSpec::<()> {
1911 nodes: &nodes,
1912 wires: &[],
1913 edges: &[],
1914 palette: &palette,
1915 metrics: &metrics,
1916 camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom },
1917 on_drag_node: Arc::new(|_, _, _, _| None),
1918 on_connect: Arc::new(|_, _, _, _| None),
1919 on_right_click_node: None,
1920 node_tint: None,
1921 wire_tint: None,
1922 wire_dash: None,
1923 edge_tint: None,
1924 edge_dash: None,
1925 flujo: 0.0,
1926 selected: None,
1927 on_select_node: None,
1928 on_canvas_click: None,
1929 on_pan: None,
1930 on_zoom: None,
1931 cuerpo: Some(&cuerpo),
1932 })
1933 };
1934 let sin_cuerpo = |zoom: f32| {
1935 let m = metrics.scaled(zoom);
1936 m.node_height(0, 0)
1937 };
1938 for zoom in [1.0_f32, 2.0, 0.5] {
1939 let total = alto_de(nodo_del_lienzo(&armar(zoom)));
1940 let aporte = total - sin_cuerpo(zoom);
1941 assert!(
1942 (aporte - 40.0 * zoom).abs() < 1e-3,
1943 "a zoom {zoom} el cuerpo aportó {aporte} px, se esperaban {}",
1944 40.0 * zoom
1945 );
1946 }
1947 // Nota: el nodo **entero** no se duplica al duplicar el zoom, porque
1948 // `node_height` suma 6.0 px fijos que `scaled` no escala. Es anterior a
1949 // este campo y afecta a los 13 consumidores por igual; se deja como
1950 // está, pero el test mide el aporte del cuerpo y no el total, para no
1951 // atarse a esa constante.
1952 }
1953
1954 #[test]
1955 fn payload_roundtrip() {
1956 for (n, p) in [
1957 (0u32, 0u16),
1958 (1, 0),
1959 (0, 1),
1960 (42, 7),
1961 (u32::MAX, u16::MAX),
1962 (123_456, 65_535),
1963 ] {
1964 let enc = encode_payload(n, p);
1965 let (n2, p2) = decode_payload(enc);
1966 assert_eq!((n, p), (n2, p2), "payload {enc} → ({n2}, {p2})");
1967 }
1968 }
1969
1970 #[test]
1971 fn metrics_node_height_grows_with_max_side() {
1972 let m = NodegraphMetrics::default();
1973 assert_eq!(m.node_height(3, 1), m.node_height(1, 3));
1974 let min = m.title_height + m.pin_row_height + 6.0;
1975 assert_eq!(m.node_height(0, 0), min);
1976 }
1977
1978 #[test]
1979 fn pin_y_progression() {
1980 let m = NodegraphMetrics::default();
1981 let y0 = m.input_pin_y(100.0, 0);
1982 let y1 = m.input_pin_y(100.0, 1);
1983 let y2 = m.input_pin_y(100.0, 2);
1984 assert!(y1 - y0 > 0.0, "pins crecen hacia abajo");
1985 assert!((y2 - y1) - (y1 - y0) < 1e-3, "espaciado uniforme");
1986 }
1987
1988 #[test]
1989 fn precompute_skips_dangling_wires() {
1990 let nodes = vec![NodeSpec {
1991 id: 1,
1992 label: "solo".into(),
1993 x: 0.0,
1994 y: 0.0,
1995 inputs: vec!["in".into()],
1996 outputs: vec!["out".into()],
1997 }];
1998 let wires = vec![Wire {
1999 from_node: 99,
2000 from_output: 0,
2001 to_node: 1,
2002 to_input: 0,
2003 }];
2004 let m = NodegraphMetrics::default();
2005 let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None, None);
2006 assert!(pre.is_empty());
2007 }
2008
2009 #[test]
2010 fn un_cable_dudoso_se_pinta_punteado_y_uno_sabido_no() {
2011 // La distinción que el grafo tiene que llevar y el color no puede llevar
2012 // solo: lo que se sabe contra lo que se supone.
2013 let nodes = dos_nodos(0.0, 300.0);
2014 let wires = vec![UN_CABLE];
2015 let m = NodegraphMetrics::default();
2016 let sabido = precompute_wires(&nodes, &wires, &m, NEGRO, None, None);
2017 assert!(!sabido[0].punteada, "sin predicado, el trazo es continuo");
2018 let dudoso = precompute_wires(&nodes, &wires, &m, NEGRO, None, Some(&|_: &Wire| true));
2019 assert!(dudoso[0].punteada);
2020 }
2021
2022 #[test]
2023 fn el_punteado_de_una_arista_se_decide_por_arista() {
2024 let nodes = dos_nodos(0.0, 300.0);
2025 let edges = vec![
2026 Edge { from_node: 1, to_node: 2 },
2027 Edge { from_node: 2, to_node: 1 },
2028 ];
2029 let m = NodegraphMetrics::default();
2030 let pre = precompute_edges(
2031 &nodes,
2032 &edges,
2033 &m,
2034 NEGRO,
2035 None,
2036 Some(&|e: &Edge| e.from_node == 1),
2037 &[],
2038 None,
2039 );
2040 assert_eq!(pre.len(), 2);
2041 assert!(pre[0].punteada && !pre[1].punteada, "el predicado tiene que ver cada arista");
2042 }
2043
2044 fn dos_nodos(xa: f32, xb: f32) -> Vec<NodeSpec> {
2045 vec![
2046 NodeSpec { id: 1, label: "a".into(), x: xa, y: 0.0, inputs: vec!["".into()], outputs: vec!["".into()] },
2047 NodeSpec { id: 2, label: "b".into(), x: xb, y: 0.0, inputs: vec!["".into()], outputs: vec!["".into()] },
2048 ]
2049 }
2050 const UN_CABLE: Wire = Wire { from_node: 1, from_output: 0, to_node: 2, to_input: 0 };
2051 const NEGRO: Color = Color::from_rgba8(0, 0, 0, 255);
2052
2053 #[test]
2054 fn direccional_siempre_sale_derecha_entra_izquierda() {
2055 // Modo clásico: aunque el destino esté a la IZQUIERDA, el cable sale por
2056 // la derecha de `a` y entra por la izquierda de `b` (semántica DAG).
2057 let m = NodegraphMetrics::default();
2058 let pre = precompute_wires(&dos_nodos(300.0, 0.0), &[UN_CABLE], &m, NEGRO, None, None);
2059 assert!((pre[0].x1 - (300.0 + m.node_width)).abs() < 1e-3);
2060 assert!((pre[0].x2 - 0.0).abs() < 1e-3);
2061 assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0));
2062 }
2063
2064 #[test]
2065 fn nearest_side_invierte_lados_si_destino_a_la_izquierda() {
2066 // Con `wire_nearest_side`, el destino a la izquierda hace que el cable
2067 // salga por la IZQUIERDA de `a` (x1 = a.x) y entre por la DERECHA de `b`
2068 // (x2 = b.x + node_width) — sin dar la vuelta al nodo.
2069 let m = NodegraphMetrics { wire_nearest_side: true, ..Default::default() };
2070 let pre = precompute_wires(&dos_nodos(300.0, 0.0), &[UN_CABLE], &m, NEGRO, None, None);
2071 assert!((pre[0].x1 - 300.0).abs() < 1e-3);
2072 assert!((pre[0].x2 - m.node_width).abs() < 1e-3);
2073 assert_eq!((pre[0].d1, pre[0].d2), (-1.0, 1.0));
2074 }
2075
2076 #[test]
2077 fn nearest_side_coincide_con_direccional_si_destino_a_la_derecha() {
2078 // Destino a la derecha: `nearest` no cambia nada respecto del direccional.
2079 let m = NodegraphMetrics { wire_nearest_side: true, ..Default::default() };
2080 let pre = precompute_wires(&dos_nodos(0.0, 300.0), &[UN_CABLE], &m, NEGRO, None, None);
2081 assert!((pre[0].x1 - m.node_width).abs() < 1e-3);
2082 assert!((pre[0].x2 - 300.0).abs() < 1e-3);
2083 assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0));
2084 }
2085
2086 #[test]
2087 fn precompute_resolves_existing_wires() {
2088 let nodes = vec![
2089 NodeSpec {
2090 id: 1,
2091 label: "a".into(),
2092 x: 0.0,
2093 y: 0.0,
2094 inputs: vec![],
2095 outputs: vec!["out".into()],
2096 },
2097 NodeSpec {
2098 id: 2,
2099 label: "b".into(),
2100 x: 200.0,
2101 y: 50.0,
2102 inputs: vec!["in".into()],
2103 outputs: vec![],
2104 },
2105 ];
2106 let wires = vec![Wire {
2107 from_node: 1,
2108 from_output: 0,
2109 to_node: 2,
2110 to_input: 0,
2111 }];
2112 let m = NodegraphMetrics::default();
2113 let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None, None);
2114 assert_eq!(pre.len(), 1);
2115 assert!((pre[0].x1 - m.node_width).abs() < 1e-3);
2116 assert!((pre[0].x2 - 200.0).abs() < 1e-3);
2117 }
2118
2119 // ── Aristas simples (F0.1) ────────────────────────────────────────────
2120
2121 fn nodo_sin_pins(id: NodeId, x: f32, y: f32) -> NodeSpec {
2122 NodeSpec { id, label: format!("n{id}"), x, y, inputs: vec![], outputs: vec![] }
2123 }
2124
2125 #[test]
2126 fn edge_ancla_en_centro_y_lado_cercano() {
2127 let m = NodegraphMetrics::default();
2128 // b a la derecha de a → sale por la derecha de a, entra por la izq de b.
2129 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 300.0, 40.0)];
2130 let edges = vec![Edge { from_node: 1, to_node: 2 }];
2131 let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[], None);
2132 assert_eq!(pre.len(), 1);
2133 let half = m.node_height(0, 0) * 0.5;
2134 assert!((pre[0].x1 - m.node_width).abs() < 1e-3, "sale por borde derecho de a");
2135 assert!((pre[0].y1 - half).abs() < 1e-3, "ancla en centro vertical de a");
2136 assert!((pre[0].x2 - 300.0).abs() < 1e-3, "entra por borde izquierdo de b");
2137 assert!((pre[0].y2 - (40.0 + half)).abs() < 1e-3, "centro vertical de b");
2138 assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0));
2139 }
2140
2141 // ── Conexión por el borde: dónde engancha cada punta ──────────────────
2142
2143 /// **El defecto que el operador vio.** Con conexión por el borde, dos cables
2144 /// que salen del MISMO nodo hacia nodos distintos tienen que salir por
2145 /// puntos distintos. Salían por el mismo —media altura del título, siempre—
2146 /// así que los dos tiradores quedaban uno encima del otro: el de arriba tapa
2147 /// al de abajo, el tapado no le contesta a nadie, y el gesto se lee como
2148 /// «las uniones no se pueden reacomodar».
2149 #[test]
2150 fn por_el_borde_dos_cables_del_mismo_nodo_no_salen_del_mismo_punto() {
2151 let m = NodegraphMetrics { conectar_por_borde: true, ..Default::default() };
2152 // `a` en el medio, uno a la derecha y otro abajo.
2153 let nodes = vec![
2154 nodo_sin_pins(1, 200.0, 200.0),
2155 nodo_sin_pins(2, 600.0, 200.0),
2156 nodo_sin_pins(3, 200.0, 600.0),
2157 ];
2158 let edges =
2159 vec![Edge { from_node: 1, to_node: 2 }, Edge { from_node: 1, to_node: 3 }];
2160 let altos = vec![120.0, 120.0, 120.0];
2161 let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &altos, None);
2162 assert_eq!(pre.len(), 2);
2163 let (p1, p2) = ((pre[0].x1, pre[0].y1), (pre[1].x1, pre[1].y1));
2164 assert!(
2165 (p1.0 - p2.0).abs() > 1.0 || (p1.1 - p2.1).abs() > 1.0,
2166 "las dos puntas salen del mismo punto: {p1:?} y {p2:?}"
2167 );
2168 // Y cada una mira al suyo: a la derecha sale por el borde derecho, al de
2169 // abajo por el borde de abajo.
2170 assert!((p1.0 - (200.0 + m.node_width)).abs() < 1e-3, "al de la derecha, por la derecha");
2171 assert!((p2.1 - (200.0 + 120.0)).abs() < 1e-3, "al de abajo, por abajo");
2172 }
2173
2174 /// **El alto que cuenta es el real, cuerpo incluido.** Con `node_height` a
2175 /// secas el cable engancha a media barra de título, y en un grafo de relación
2176 /// —donde el nodo es todo cuerpo— eso se ve como un cable que entra por el
2177 /// vértice de arriba.
2178 #[test]
2179 fn por_el_borde_el_alto_que_cuenta_es_el_del_cuerpo() {
2180 let m = NodegraphMetrics { conectar_por_borde: true, ..Default::default() };
2181 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 600.0, 0.0)];
2182 let edges = vec![Edge { from_node: 1, to_node: 2 }];
2183
2184 let sin = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[], None);
2185 let con = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[200.0, 200.0], None);
2186 assert!((sin[0].y1 - m.node_height(0, 0) * 0.5).abs() < 1e-3, "sin altos, el clásico");
2187 assert!((con[0].y1 - 100.0).abs() < 1e-3, "con altos, el medio del nodo de verdad");
2188 assert!(
2189 (sin[0].y1 - con[0].y1).abs() > 1.0,
2190 "si dieran lo mismo este test no estaría midiendo nada"
2191 );
2192 }
2193
2194 /// **El ancla puesta a mano gana.** Es lo que hace que la punta se quede
2195 /// donde se la soltó en vez de volver a donde la geometría quiera.
2196 #[test]
2197 fn por_el_borde_el_ancla_puesta_gana_sobre_la_geometria() {
2198 let m = NodegraphMetrics { conectar_por_borde: true, ..Default::default() };
2199 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 600.0, 0.0)];
2200 let edges = vec![Edge { from_node: 1, to_node: 2 }];
2201 let altos = vec![100.0, 100.0];
2202 // Pegada al borde de abajo, a un cuarto del ancho.
2203 let anclas = |_i: usize| (Some((0.25_f32, 1.0_f32)), None);
2204 let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &altos, Some(&anclas));
2205 assert!((pre[0].x1 - m.node_width * 0.25).abs() < 1e-3, "a un cuarto del ancho");
2206 assert!((pre[0].y1 - 100.0).abs() < 1e-3, "pegada al borde de abajo");
2207 // Y la otra punta, sin ancla, la sigue poniendo la geometría.
2208 assert!((pre[0].x2 - 600.0).abs() < 1e-3, "la punta sin ancla no se movió");
2209 }
2210
2211 /// Ida y vuelta: lo que se guarda es la fracción, no el píxel.
2212 #[test]
2213 fn el_ancla_se_guarda_normalizada_y_vuelve_al_mismo_punto() {
2214 let (x, y, w, h) = (100.0_f32, 50.0_f32, 160.0_f32, 80.0_f32);
2215 let punto = (100.0 + 40.0, 50.0 + 80.0);
2216 let (u, v) = ancla_normalizada(x, y, w, h, punto);
2217 assert!((u - 0.25).abs() < 1e-5 && (v - 1.0).abs() < 1e-5, "({u}, {v})");
2218 // Y el nodo del doble de alto deja el ancla en el borde de abajo igual.
2219 assert!(((y + v * 160.0) - (y + 160.0)).abs() < 1e-5);
2220 }
2221
2222 /// Fuera de la caja se recorta: un ancla al 1,4 del ancho dibujaría el cable
2223 /// despegado del nodo, que es peor que no haberlo movido.
2224 #[test]
2225 fn un_ancla_fuera_de_la_caja_se_recorta_al_borde() {
2226 let (u, v) = ancla_normalizada(0.0, 0.0, 100.0, 100.0, (140.0, -20.0));
2227 assert_eq!((u, v), (1.0, 0.0));
2228 }
2229
2230 #[test]
2231 fn edge_lado_cercano_si_destino_a_la_izquierda() {
2232 let m = NodegraphMetrics::default();
2233 let nodes = vec![nodo_sin_pins(1, 300.0, 0.0), nodo_sin_pins(2, 0.0, 0.0)];
2234 let edges = vec![Edge { from_node: 1, to_node: 2 }];
2235 let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[], None);
2236 // Destino a la izquierda: sale por la izq de a, entra por la der de b.
2237 assert!((pre[0].x1 - 300.0).abs() < 1e-3);
2238 assert!((pre[0].x2 - m.node_width).abs() < 1e-3);
2239 assert_eq!((pre[0].d1, pre[0].d2), (-1.0, 1.0));
2240 }
2241
2242 #[test]
2243 fn edge_colgante_se_descarta() {
2244 let m = NodegraphMetrics::default();
2245 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0)];
2246 let edges = vec![Edge { from_node: 1, to_node: 99 }];
2247 assert!(precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[], None).is_empty());
2248 }
2249
2250 // ── Layout por rango topológico (F0.3) ────────────────────────────────
2251
2252 #[test]
2253 fn layout_cadena_lineal_columnas_crecientes() {
2254 let m = NodegraphMetrics::default();
2255 let ids = vec![1u32, 2, 3];
2256 let edges = vec![(1, 2), (2, 3)];
2257 let pos = layout_por_rango(&ids, &edges, &m, LayoutRango::default());
2258 assert!(pos[0].x < pos[1].x && pos[1].x < pos[2].x, "cada eslabón una columna más");
2259 // Rangos distintos ⇒ cada uno en la fila 0 de su columna (mismo y).
2260 assert!((pos[0].y - pos[1].y).abs() < 1e-3);
2261 }
2262
2263 #[test]
2264 fn layout_diamante_merge_en_rango_mas_profundo() {
2265 // a→b, a→c, b→d, c→d : d debe caer en el rango 2 (max de sus padres +1).
2266 let m = NodegraphMetrics::default();
2267 let ids = vec![10u32, 11, 12, 13]; // a,b,c,d
2268 let edges = vec![(10, 11), (10, 12), (11, 13), (12, 13)];
2269 let pos = layout_por_rango(&ids, &edges, &m, LayoutRango::default());
2270 let col = |id: u32| pos.iter().find(|p| p.id == id).unwrap().x;
2271 let opts = LayoutRango::default();
2272 let col_w = m.node_width + opts.col_gap;
2273 assert!((col(10) - opts.margin).abs() < 1e-3, "a en rango 0");
2274 assert!((col(11) - (opts.margin + col_w)).abs() < 1e-3, "b en rango 1");
2275 assert!((col(13) - (opts.margin + 2.0 * col_w)).abs() < 1e-3, "d (merge) en rango 2");
2276 }
2277
2278 #[test]
2279 fn layout_ciclo_no_cuelga() {
2280 let m = NodegraphMetrics::default();
2281 let ids = vec![1u32, 2];
2282 let edges = vec![(1, 2), (2, 1)];
2283 // No debe colgarse; ambos caen a rango 0 (mejor esfuerzo).
2284 let pos = layout_por_rango(&ids, &edges, &m, LayoutRango::default());
2285 assert_eq!(pos.len(), 2);
2286 }
2287
2288 // ── Encuadre y conos (F0.5) ───────────────────────────────────────────
2289
2290 #[test]
2291 fn fit_to_view_centra_y_clampa() {
2292 let m = NodegraphMetrics::default();
2293 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 400.0, 300.0)];
2294 let (vw, vh) = (800.0_f32, 600.0_f32);
2295 let cam = fit_to_view(&nodes, &m, vw, vh);
2296 assert!(cam.zoom >= ZOOM_MIN && cam.zoom <= ZOOM_MAX);
2297 // El centro del contenido se proyecta al centro del viewport.
2298 let mut min_x = f32::INFINITY; let mut max_x = f32::NEG_INFINITY;
2299 let mut min_y = f32::INFINITY; let mut max_y = f32::NEG_INFINITY;
2300 for n in &nodes {
2301 let h = m.node_height(0, 0);
2302 min_x = min_x.min(n.x); max_x = max_x.max(n.x + m.node_width);
2303 min_y = min_y.min(n.y); max_y = max_y.max(n.y + h);
2304 }
2305 let (ccx, ccy) = ((min_x + max_x) * 0.5, (min_y + max_y) * 0.5);
2306 let (sx, sy) = cam.apply(ccx, ccy);
2307 assert!((sx - vw * 0.5).abs() < 1e-2 && (sy - vh * 0.5).abs() < 1e-2);
2308 }
2309
2310 #[test]
2311 fn fit_to_view_sin_nodos_es_identidad() {
2312 let m = NodegraphMetrics::default();
2313 assert_eq!(fit_to_view(&[], &m, 800.0, 600.0), Camera::default());
2314 }
2315
2316 #[test]
2317 fn cono_ascendente_y_descendente_en_diamante() {
2318 // a→b, a→c, b→d, c→d
2319 let edges = vec![(10u32, 11), (10, 12), (11, 13), (12, 13)];
2320 let mut asc = cono_ascendente(&edges, 13); // ancestros de d + d
2321 asc.sort_unstable();
2322 assert_eq!(asc, vec![10, 11, 12, 13]);
2323 let mut desc = cono_descendente(&edges, 10); // descendientes de a + a
2324 desc.sort_unstable();
2325 assert_eq!(desc, vec![10, 11, 12, 13]);
2326 // Una hoja no tiene descendientes salvo ella misma.
2327 assert_eq!(cono_descendente(&edges, 13), vec![13]);
2328 // Una raíz no tiene ancestros salvo ella misma.
2329 assert_eq!(cono_ascendente(&edges, 10), vec![10]);
2330 }
2331
2332 // ── Orientación vertical (F0 visual) ──────────────────────────────────
2333
2334 #[test]
2335 fn edge_vertical_ancla_arriba_y_abajo() {
2336 let m = NodegraphMetrics { vertical: true, ..Default::default() };
2337 // b debajo de a → sale por el borde inferior de a, entra por el superior de b.
2338 let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 40.0, 300.0)];
2339 let pre = precompute_edges(&nodes, &[Edge { from_node: 1, to_node: 2 }], &m, NEGRO, None, None, &[], None);
2340 let h = m.node_height(0, 0);
2341 assert!((pre[0].x1 - m.node_width * 0.5).abs() < 1e-3, "centro-x de a");
2342 assert!((pre[0].y1 - h).abs() < 1e-3, "borde inferior de a");
2343 assert!((pre[0].x2 - (40.0 + m.node_width * 0.5)).abs() < 1e-3, "centro-x de b");
2344 assert!((pre[0].y2 - 300.0).abs() < 1e-3, "borde superior de b");
2345 assert!(pre[0].vertical);
2346 assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0));
2347 }
2348
2349 #[test]
2350 fn layout_vertical_apila_rangos_en_y() {
2351 let m = NodegraphMetrics::default();
2352 let ids = vec![1u32, 2, 3];
2353 let edges = vec![(1, 2), (2, 3)];
2354 let opts = LayoutRango { vertical: true, ..Default::default() };
2355 let pos = layout_por_rango(&ids, &edges, &m, opts);
2356 // Cadena: cada eslabón un rango más abajo (y crece), misma columna (x igual).
2357 assert!(pos[0].y < pos[1].y && pos[1].y < pos[2].y);
2358 assert!((pos[0].x - pos[1].x).abs() < 1e-3);
2359 }
2360}