tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ main widgets/nodegraph/src/lib.rs Rust · 2360 lines · 94.8 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}