tawasuyu

tawasuyu/llimphi

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

Rust 124Markdown 82TOML 78Shell 12149 Definiciones

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 widgets/nodegraph/src/lib.rs Rust · 1513 líneas · 55.8 KB Historia Quién tocó qué crudo

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