//! `llimphi-widget-nodegraph` — lienzo de nodos con pins y cables //! Bezier sobre Llimphi. //! //! Modelo declarativo de un grafo dirigido: cada frame, el caller pasa //! la lista actual de [`NodeSpec`]s + [`Wire`]s y el widget pinta: //! //! - el lienzo (fondo lleno); //! - cada nodo como un rect con título arriba y pins a los lados //! (entradas a la izquierda, salidas a la derecha); //! - los cables entre `(node_a, output_pin_a)` y `(node_b, input_pin_b)` //! como Bezier cúbicas con tangentes horizontales (mismo look que //! `pluma-editor-llimphi::multilienzo_editor::carril_editor`). //! //! El widget no mantiene estado: el caller acumula posición de nodos + //! cables en su `Model` y le pasa handlers para los dos eventos //! interactivos: //! //! - **mover un nodo** — `on_drag_node(node_id, phase, dx, dy)` se //! invoca al arrastrar la title bar de un nodo. El handler suma el //! delta a la posición persistida. //! - **conectar dos pins** — al arrastrar desde un pin de salida y //! soltar sobre un pin de entrada, `on_connect(from_node, from_out, //! to_node, to_in)` se invoca para que el caller materialice el //! `Wire` en su modelo. //! //! Reusable por: pluma (visualizador DAG), nakui (fórmulas yupay), //! tullpu (ajustes no destructivos), dominium (sistemas), takiy //! (cadena de audio), pluma-notebook (kernel-DAG visual). #![forbid(unsafe_code)] // Sin `std`: este widget no usa nada del sistema —sólo `alloc` para // `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula // WASM o al kernel. Antes enlazaba `std` por omisión (W4.6). #![no_std] extern crate alloc; use alloc::string::String; use alloc::vec::Vec; use alloc::vec; use alloc::string::ToString; // `hashbrown` ES el `HashMap` de std por dentro; `VecDeque` vive en `alloc`. // Nada de esto necesitaba un sistema operativo — sólo estaba nombrado como si. use alloc::collections::VecDeque; use hashbrown::{HashMap, HashSet}; use alloc::sync::Arc; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{length, percent, Dimension, Position, Size, Style}, Rect, }; use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Stroke}; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::{DragPhase, Shadow, View}; /// Identificador opaco de un nodo. El caller asigna estos valores; el /// widget los pasa de vuelta sin interpretarlos. pub type NodeId = u32; /// Índice del pin dentro de la lista `inputs` o `outputs` del nodo. pub type PinIdx = u16; /// Especificación de un nodo del grafo. El caller construye uno por /// nodo en cada `view`. Las posiciones son en pixels relativas al rect /// del lienzo. #[derive(Debug, Clone)] pub struct NodeSpec { pub id: NodeId, pub label: String, /// Esquina superior-izquierda del nodo, en coordenadas del lienzo. pub x: f32, pub y: f32, /// Labels de los pins de entrada. Cantidad = altura mínima del nodo. pub inputs: Vec, /// Labels de los pins de salida. pub outputs: Vec, } /// **Cuerpo embebido** de un nodo: lo que se dibuja debajo de la fila de pins. /// /// Sin esto un nodo sólo puede decir su nombre, y el grafo obliga a mirar a /// otro lado para saber qué produce cada paso. Con esto un nodo muestra su /// resultado adentro: krama pinta el valor con su unidad, tullpu podría pintar /// el preview del ajuste, takiy la onda de su eslabón, pluma-notebook el output /// de la celda. /// /// # Por qué no vive en [`NodeSpec`] /// /// `NodeSpec` es `Debug + Clone` y **no** es genérico en `Msg`; una `View` /// adentro lo volvería genérico y rompería sus 12 sitios de construcción en el /// árbol. El cuerpo entra por [`NodegraphSpec::cuerpo`], que es un closure — /// exactamente la misma forma que ya tienen `node_tint`, `selected` y /// `wire_tint`, y que sólo cuesta un `None` a su único consumidor externo. pub struct CuerpoNodo { /// Alto reservado bajo los pins, en coordenadas de **canvas** (el widget lo /// escala por el zoom). El nodo crece esa cantidad. pub alto: f32, /// Lo que llena ese rect. Un cuerpo hecho con `View::paint_with` escala /// solo con el zoom porque recibe el rect ya escalado; uno hecho de texto /// no — por eso el closure recibe el zoom y puede dimensionar la fuente. pub vista: View, } /// Cable entre el pin de salida de un nodo y el pin de entrada de otro. /// El widget no valida ciclos ni direcciones — esa política vive en el /// caller. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Wire { pub from_node: NodeId, pub from_output: PinIdx, pub to_node: NodeId, pub to_input: PinIdx, } /// **Bit que marca un payload de puño** (arrastrar la punta de una arista) para /// distinguirlo del de un pin o un borde (conectar algo nuevo). Los dos viajan /// por el mismo canal —un `u64`— y el destino tiene que saber cuál recibió: /// tratar una reconexión como una conexión nueva dejaría la arista vieja /// colgada y una segunda encima. const MARCA_PUNO: u64 = 1 << 63; /// Radio del puño de una punta, en px de pantalla. Chico pero agarrable. const PUNO_R: f32 = 5.0; /// **Cuál de las dos puntas de una arista.** #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtremoArista { /// La punta que sale del nodo origen. Desde, /// La que llega al destino. Hasta, } /// Lo que [`nodegraph_view_spec_ext`] agrega sobre [`nodegraph_view_spec`]. /// /// Va en un struct aparte y no en [`NodegraphSpec`] porque el spec lo /// construyen **siete** crates con literal completo: un campo más los rompe a /// todos. `Default` es el lienzo de siempre. pub struct NodegraphExtras { /// **Mientras se arrastra una punta.** `on_tirar_puno(i, extremo, fase, x, y)` /// con `(x, y)` **absoluto en coordenadas del lienzo**, no un delta: el /// caller lo guarda tal cual y lo devuelve por [`Self::tirando`], y así no /// hay dos acumuladores que se puedan desfasar. /// /// Es lo que hace que el gesto se vea. Sin esto el puño se podía arrastrar y /// **no pasaba nada en pantalla** hasta soltarlo: el operador lo reportó como /// «las uniones no se pueden reacomodar», que es exactamente lo que parece /// cuando un gesto no tiene respuesta. pub on_tirar_puno: Option< alloc::sync::Arc< dyn Fn(usize, ExtremoArista, DragPhase, f32, f32) -> Option + Send + Sync, >, >, /// **La punta que se está arrastrando ahora**, como el caller la tiene: /// `(arista, extremo, x, y)` en coordenadas del lienzo. El widget pinta el /// cable fantasma hasta ahí y resalta el nodo que lo recibiría. pub tirando: Option<(usize, ExtremoArista, f32, f32)>, /// **Dónde quedó cada punta**, por índice de arista: `(desde, hasta)` en /// coordenadas normalizadas del nodo (0..1 de ancho y de alto). `None` en una /// punta = la deriva la geometría, que es el default y lo que hace cualquier /// arista nueva. pub anclas: Option< alloc::sync::Arc< dyn Fn(usize) -> (Option<(f32, f32)>, Option<(f32, f32)>) + Send + Sync, >, >, /// **Soltar una punta sobre un nodo.** Sale en `DragPhase::End` cuando el /// punto cae dentro de un nodo: `on_reconectar(i, extremo, nodo, ancla)`, con /// el ancla ya normalizada al nodo que la recibe. El caller borra la vieja y /// pone la nueva — el widget no tiene estado. /// /// **Lo resuelve el widget y no el caller** porque la geometría del borde es /// del widget: con el caller decidiendo a quién le cayó, habría dos cuentas /// del mismo rectángulo y el cable terminaría en un lugar distinto del que /// se soltó. /// /// Sólo aparece con `NodegraphMetrics::conectar_por_borde`: sin eso el /// grafo es de pins y una punta suelta no significa nada. pub on_reconectar: Option< alloc::sync::Arc< dyn Fn(usize, ExtremoArista, NodeId, (f32, f32)) -> Option + Send + Sync, >, >, } impl Default for NodegraphExtras { fn default() -> Self { Self { on_tirar_puno: None, tirando: None, anclas: None, on_reconectar: None } } } /// Arista **simple** nodo→nodo, sin pins. A diferencia de [`Wire`] (que engancha /// en puertos concretos de dataflow), una arista se ancla en el **centro /// vertical** de cada nodo y en el **borde horizontal más cercano** al otro /// nodo (como `wire_nearest_side`, pero sin depender de que existan pins). Es la /// forma de primera clase para grafos de relación / contención / DAG genérico /// donde los nodos no tienen puertos — antes había que fabricar pins dummy de /// string vacío (churay, navigator, anim-studio lo hacían así). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Edge { pub from_node: NodeId, pub to_node: NodeId, } /// Tinte opcional de un nodo resaltado. Cada campo `Some` sobrescribe /// el color correspondiente de la paleta global para *ese* nodo; los /// `None` heredan la paleta. Sirve para que el caller marque un subgrafo /// (p.ej. el cono afectado por un morfismo) sin tocar el resto. #[derive(Debug, Clone, Copy, Default)] pub struct NodeTint { pub bg_node: Option, pub bg_title: Option, pub fg_title: Option, } /// Paleta del lienzo. Hereda del [`llimphi_theme::Theme`] semántico. #[derive(Debug, Clone, Copy)] pub struct NodegraphPalette { pub bg_canvas: Color, pub bg_node: Color, pub bg_title: Color, pub fg_title: Color, pub fg_pin_label: Color, pub pin_input: Color, pub pin_output: Color, pub pin_drop_hover: Color, pub wire: Color, pub border: Color, } impl Default for NodegraphPalette { fn default() -> Self { Self::from_theme(&llimphi_theme::Theme::dark()) } } impl NodegraphPalette { pub fn from_theme(t: &llimphi_theme::Theme) -> Self { Self { bg_canvas: t.bg_app, bg_node: t.bg_panel, bg_title: t.bg_panel_alt, fg_title: t.fg_text, fg_pin_label: t.fg_muted, pin_input: t.accent, pin_output: t.accent, pin_drop_hover: t.bg_selected, wire: t.accent, border: t.border, } } } /// Geometría del nodo y de los pins. #[derive(Debug, Clone, Copy)] pub struct NodegraphMetrics { pub node_width: f32, pub title_height: f32, pub pin_row_height: f32, pub pin_radius: f32, pub pin_label_size: f32, pub title_text_size: f32, pub wire_stroke: f32, pub node_radius: f64, /// Si `true`, cada cable **sale y entra por el lado más cercano** al otro /// nodo (derecha→izquierda si el destino está a la derecha, izquierda→derecha /// si está a la izquierda), en vez de siempre salida-derecha → entrada- /// izquierda. Evita cables que dan la vuelta al nodo cuando el grafo no es /// un DAG estricto de izquierda a derecha (p.ej. un grafo de relaciones). /// Como cada nodo tiene un pin a cada lado, el cable engancha en un pin real /// de ambos extremos. Default `false` (semántica direccional clásica). pub wire_nearest_side: bool, /// Elevación (blur de la sombra en px) de cada nodo sobre el fondo. `0.0` /// (default) = sin sombra, para no cambiar el look de los consumidores /// clásicos; el cockpit de DAG la enciende. Escala con el zoom. pub node_elevacion: f32, /// Si `true`, las **aristas simples** (`Edge`) se rutean verticalmente: /// anclan en el borde superior/inferior de cada nodo con tangente vertical /// (para un DAG apilado de arriba hacia abajo). `false` (default) = ruteo /// horizontal clásico. No afecta a los cables con pin (`Wire`). pub vertical: bool, /// **Conectar arrastrando de un nodo a otro, sin pins.** /// /// Con `true`, el CUERPO de cada nodo —todo menos su barra de título, que /// sigue siendo para moverlo— es a la vez fuente de arrastre y destino de /// soltado: se tira desde cualquier punto de uno y se suelta en cualquier /// punto de otro, y sale `on_connect(de, 0, a, 0)`. /// /// Está para los grafos de **relación**, donde los nodos no tienen puertos y /// obligar a apuntarle a un punto de seis píxeles es pedirle puntería a /// alguien que sólo quiere decir «estos dos tienen que ver». Los pins /// siguen funcionando igual si el nodo los declara; con `inputs`/`outputs` /// vacíos, el nodo queda limpio y esto es la única forma de conectarlo. /// /// `false` —el default— es el widget de siempre. pub conectar_por_borde: bool, } impl Default for NodegraphMetrics { fn default() -> Self { Self { node_width: 160.0, title_height: 22.0, pin_row_height: 18.0, pin_radius: 5.0, pin_label_size: 10.0, title_text_size: 11.0, wire_stroke: 1.6, node_radius: 4.0, wire_nearest_side: false, node_elevacion: 0.0, vertical: false, conectar_por_borde: false, } } } impl NodegraphMetrics { /// Alto total del rect que ocupa un nodo con `n_in` entradas y /// `n_out` salidas. El cuerpo crece con el lado que tenga más pins. pub fn node_height(&self, n_in: usize, n_out: usize) -> f32 { let rows = n_in.max(n_out).max(1) as f32; self.title_height + rows * self.pin_row_height + 6.0 } /// Centro Y absoluto de un pin de entrada del nodo cuyo top-left es /// `(_x, node_y)`. Sirve también para outputs (misma alineación). pub fn input_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 { node_y + self.title_height + 3.0 + (pin as f32 + 0.5) * self.pin_row_height } pub fn output_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 { self.input_pin_y(node_y, pin) } /// Escala todas las longitudes por `zoom` (para el render con cámara). Los /// tamaños de fuente escalan también, así el zoom es real (no un stretch). fn scaled(&self, zoom: f32) -> Self { Self { node_width: self.node_width * zoom, title_height: self.title_height * zoom, pin_row_height: self.pin_row_height * zoom, pin_radius: self.pin_radius * zoom, pin_label_size: self.pin_label_size * zoom, title_text_size: self.title_text_size * zoom, wire_stroke: self.wire_stroke * zoom, node_radius: self.node_radius * zoom as f64, wire_nearest_side: self.wire_nearest_side, node_elevacion: self.node_elevacion * zoom, vertical: self.vertical, // No es una longitud: el zoom no cambia cómo se conecta. conectar_por_borde: self.conectar_por_borde, } } } /// Cámara del lienzo: desplazamiento (pan) + escala (zoom). El caller la /// guarda en su `Model`; el widget transforma las coordenadas de canvas de /// cada nodo/cable a coordenadas de pantalla con `screen = canvas * zoom + /// pan`. `Default` = identidad (sin pan, zoom 1) → render idéntico al clásico. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Camera { pub pan_x: f32, pub pan_y: f32, pub zoom: f32, } impl Default for Camera { fn default() -> Self { Self { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 } } } /// Zoom mínimo/máximo del lienzo. pub const ZOOM_MIN: f32 = 0.25; pub const ZOOM_MAX: f32 = 4.0; impl Camera { /// Coordenada de pantalla de un punto de canvas. pub fn apply(&self, x: f32, y: f32) -> (f32, f32) { (x * self.zoom + self.pan_x, y * self.zoom + self.pan_y) } /// Coordenada de canvas de un punto de pantalla (inversa de [`apply`]). pub fn invert(&self, sx: f32, sy: f32) -> (f32, f32) { let z = self.zoom.max(f32::EPSILON); ((sx - self.pan_x) / z, (sy - self.pan_y) / z) } } /// Nuevo zoom tras un `wheel_delta` **de la rueda ya normalizada por Llimphi**, /// multiplicativo y clampeado a `[ZOOM_MIN, ZOOM_MAX]`. Pura y testeable. /// /// **El signo es el de Llimphi, no el de la aritmética.** El runner de ventana /// invierte la `y` de winit (`input.rs::handle_mouse_wheel`) para que el delta /// sea un *desplazamiento de contenido*: rueda hacia adelante → `dy` NEGATIVO, /// que es lo que un scroll necesita para subir. Un zoom quiere lo contrario — /// rueda hacia adelante ACERCA, la convención de Maps, Figma y Blender—, así /// que acá el delta se niega. El propio motor ya lo hacía en su camino de /// `ctrl+rueda` (`1.1^(-dy)` en `process_wheel`): esta función era el único /// lugar que usaba el signo crudo, y por eso el zoom salía al revés. /// /// Estaba mal en los **trece** llamadores a la vez —ninguno negaba el delta—, /// que es la razón de arreglarlo acá y no en cada app: el parámetro se llama /// `wheel_delta`, así que respetar el sentido del gesto es contrato de esta /// función. Reportado en metal sobre nnawi: *«el wheel está al revés»*. pub fn zoom_step(zoom: f32, wheel_delta: f32) -> f32 { // Factor exponencial suave: cada tick escala ~10 %. let factor = (-wheel_delta * 0.1).exp(); (zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX) } /// Aplica un nuevo zoom manteniendo **fijo el centro del viewport** (el punto /// de canvas bajo el centro no se mueve en pantalla). Devuelve la cámara /// ajustada. El caller pasa el tamaño del viewport (que conoce de su layout), /// igual que el contrato del `scroll` widget. Sin esto, el zoom "escapa" hacia /// la esquina. pub fn zoom_at_center(cam: Camera, new_zoom: f32, viewport_w: f32, viewport_h: f32) -> Camera { let new_zoom = new_zoom.clamp(ZOOM_MIN, ZOOM_MAX); let (cx, cy) = (viewport_w * 0.5, viewport_h * 0.5); // Punto de canvas actualmente bajo el centro. let (canvas_cx, canvas_cy) = cam.invert(cx, cy); // Reponer el pan para que ese mismo punto siga bajo el centro con el zoom nuevo. Camera { pan_x: cx - canvas_cx * new_zoom, pan_y: cy - canvas_cy * new_zoom, zoom: new_zoom, } } // ───────────────────────────────────────────────────────────────────────── // Navegación de DAGs — layout automático, encuadre y conos (puros, testeables) // ───────────────────────────────────────────────────────────────────────── /// Posición asignada por [`layout_por_rango`] a un nodo. #[derive(Debug, Clone, Copy, PartialEq)] pub struct LayoutPos { pub id: NodeId, pub x: f32, pub y: f32, } /// Opciones del layout por rango topológico. #[derive(Debug, Clone, Copy)] pub struct LayoutRango { /// Margen desde el origen del lienzo. pub margin: f32, /// Separación horizontal extra entre columnas (rangos). pub col_gap: f32, /// Separación vertical entre filas dentro de un rango. pub row_gap: f32, /// Si `true`, los rangos apilan de **arriba hacia abajo** (eje principal Y) y /// los nodos de un rango se reparten en X. `false` (default) = rangos de /// izquierda a derecha. En ambos casos cada rango se **centra** respecto del /// más poblado (autoequilibrado). pub vertical: bool, } impl Default for LayoutRango { fn default() -> Self { Self { margin: 24.0, col_gap: 36.0, row_gap: 12.0, vertical: false } } } /// Asigna posiciones a un DAG por **rango topológico**: la columna de un nodo = /// longitud del camino más largo desde una raíz (rank), la fila = su orden /// (estable) dentro del rango. `edges` son aristas dirigidas `(from, to)`. Es la /// única implementación de este layout — antes vivía atada a /// `pluma-notebook-graph`. Determinista y pura. Nodos en ciclo o inalcanzables /// caen al rango 0 (mejor esfuerzo, sin colgarse). pub fn layout_por_rango( ids: &[NodeId], edges: &[(NodeId, NodeId)], metrics: &NodegraphMetrics, opts: LayoutRango, ) -> Vec { let n = ids.len(); let index: HashMap = ids.iter().enumerate().map(|(i, &id)| (id, i)).collect(); let mut adj: Vec> = vec![Vec::new(); n]; let mut indeg = vec![0usize; n]; for &(f, t) in edges { if let (Some(&fi), Some(&ti)) = (index.get(&f), index.get(&t)) { adj[fi].push(ti); indeg[ti] += 1; } } // Kahn + longest-path para el rank. let mut rank = vec![0i32; n]; let mut remaining = indeg.clone(); let mut queue: VecDeque = (0..n).filter(|&i| indeg[i] == 0).collect(); while let Some(u) = queue.pop_front() { for &v in &adj[u] { if rank[u] + 1 > rank[v] { rank[v] = rank[u] + 1; } remaining[v] -= 1; if remaining[v] == 0 { queue.push_back(v); } } } let max_rank = rank.iter().copied().max().unwrap_or(0); let n_ranks = (max_rank + 1) as usize; // Conteo por rango, para centrar cada rango respecto del más poblado. let mut count = vec![0i32; n_ranks]; for &r in &rank { count[r as usize] += 1; } let max_count = count.iter().copied().max().unwrap_or(1).max(1); let node_h = metrics.node_height(1, 1); // Eje principal = entre rangos; eje cruzado = entre nodos de un rango. let (main_step, cross_step) = if opts.vertical { (node_h + opts.row_gap, metrics.node_width + opts.col_gap) } else { (metrics.node_width + opts.col_gap, node_h + opts.row_gap) }; let mut row_in_rank = vec![0i32; n_ranks]; let mut out = Vec::with_capacity(n); for (i, &id) in ids.iter().enumerate() { let r = rank[i] as usize; let row = row_in_rank[r]; row_in_rank[r] += 1; // Autoequilibrado: media diferencia de población respecto del rango mayor. let cross_offset = (max_count - count[r]) as f32 * cross_step * 0.5; let main = opts.margin + rank[i] as f32 * main_step; let cross = opts.margin + cross_offset + row as f32 * cross_step; let (x, y) = if opts.vertical { (cross, main) } else { (main, cross) }; out.push(LayoutPos { id, x, y }); } out } /// Cámara que **encuadra** todos los nodos en un viewport dado, con un pequeño /// margen relativo. Sin nodos → [`Camera::default`]. El zoom se clampa a /// `[ZOOM_MIN, ZOOM_MAX]`. Pura; base del botón "encuadrar" y del arranque /// centrado. Espeja el contrato de [`zoom_at_center`] (el caller pasa el tamaño /// del viewport que conoce de su layout). pub fn fit_to_view( nodes: &[NodeSpec], metrics: &NodegraphMetrics, viewport_w: f32, viewport_h: f32, ) -> Camera { if nodes.is_empty() || viewport_w <= 0.0 || viewport_h <= 0.0 { return Camera::default(); } let mut min_x = f32::INFINITY; let mut min_y = f32::INFINITY; let mut max_x = f32::NEG_INFINITY; let mut max_y = f32::NEG_INFINITY; for n in nodes { let h = metrics.node_height(n.inputs.len(), n.outputs.len()); min_x = min_x.min(n.x); min_y = min_y.min(n.y); max_x = max_x.max(n.x + metrics.node_width); max_y = max_y.max(n.y + h); } let content_w = (max_x - min_x).max(1.0); let content_h = (max_y - min_y).max(1.0); // 0.9 deja un respiro alrededor del contenido. let zoom = ((viewport_w / content_w).min(viewport_h / content_h) * 0.9) .clamp(ZOOM_MIN, ZOOM_MAX); let content_cx = (min_x + max_x) * 0.5; let content_cy = (min_y + max_y) * 0.5; Camera { pan_x: viewport_w * 0.5 - content_cx * zoom, pan_y: viewport_h * 0.5 - content_cy * zoom, zoom, } } /// Nodos **aguas-arriba** de `id` (sus ancestros por `edges` dirigidas /// `(from, to)`), incluyendo `id`. Ordenado y sin duplicados. Sirve para /// resaltar qué toca *cercenar* o *conciliar* antes de confirmar. pub fn cono_ascendente(edges: &[(NodeId, NodeId)], id: NodeId) -> Vec { let mut visited: HashSet = HashSet::new(); visited.insert(id); let mut stack = vec![id]; while let Some(cur) = stack.pop() { for &(f, t) in edges { if t == cur && visited.insert(f) { stack.push(f); } } } let mut out: Vec = visited.into_iter().collect(); out.sort_unstable(); out } /// Nodos **aguas-abajo** de `id` (sus descendientes por `edges`), incluyendo /// `id`. Ordenado y sin duplicados. pub fn cono_descendente(edges: &[(NodeId, NodeId)], id: NodeId) -> Vec { let mut visited: HashSet = HashSet::new(); visited.insert(id); let mut stack = vec![id]; while let Some(cur) = stack.pop() { for &(f, t) in edges { if f == cur && visited.insert(t) { stack.push(t); } } } let mut out: Vec = visited.into_iter().collect(); out.sort_unstable(); out } type DragNodeFn = Arc Option + Send + Sync>; type ConnectFn = Arc< dyn Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync, >; /// Codifica `(node_id, pin_idx)` en el `u64` que viaja como payload del /// drag de un pin. 32 bits superiores = node_id, 16 bits inferiores = /// pin_idx. #[inline] fn encode_payload(node: NodeId, pin: PinIdx) -> u64 { ((node as u64) << 32) | (pin as u64) } #[inline] fn decode_payload(payload: u64) -> (NodeId, PinIdx) { let node = (payload >> 32) as NodeId; let pin = (payload & 0xFFFF) as PinIdx; (node, pin) } /// Construye el lienzo de nodos. `on_drag_node` se invoca con el delta /// del cursor cuando el usuario arrastra la title bar de un nodo (las /// fases `Move` y `End` se reenvían tal cual). `on_connect` se invoca /// cuando el usuario suelta un cable iniciado en un pin de salida /// sobre un pin de entrada de otro nodo. pub fn nodegraph_view( nodes: &[NodeSpec], wires: &[Wire], palette: &NodegraphPalette, metrics: &NodegraphMetrics, on_drag_node: FDrag, on_connect: FConnect, ) -> View where Msg: Clone + Send + Sync + 'static, FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync + 'static, FConnect: Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync + 'static, { nodegraph_view_ex:: Option>( nodes, wires, palette, metrics, on_drag_node, on_connect, None, ) } /// Variante extendida con un handler opcional de click derecho sobre /// la title bar de cada nodo. Permite a la app montar acciones por-nodo /// (estilo "ejecutar desde aquí" en un notebook reactivo, o "duplicar /// este nodo" en un editor de cadena de audio) sin esperar a que el /// widget tenga un menú contextual propio. /// /// `on_right_click_node` se evalúa una vez por nodo al construir la /// vista — si devuelve `Some(msg)`, el runtime emite ese `Msg` al hacer /// right-click sobre la title bar; `None` deja al nodo sin acción /// contextual. pub fn nodegraph_view_ex( nodes: &[NodeSpec], wires: &[Wire], palette: &NodegraphPalette, metrics: &NodegraphMetrics, on_drag_node: FDrag, on_connect: FConnect, on_right_click_node: Option, ) -> View where Msg: Clone + Send + Sync + 'static, FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync + 'static, FConnect: Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync + 'static, FRight: Fn(NodeId) -> Option, { nodegraph_view_styled( nodes, wires, palette, metrics, on_drag_node, on_connect, on_right_click_node, None, None, ) } /// Variante con realce: además de los handlers, acepta dos closures de /// estilo evaluados en construcción —`node_tint(id)` tiñe nodos puntuales /// y `wire_tint(&Wire)` recolorea cables— para que el caller marque un /// subgrafo (cono afectado, ruta crítica, celda con error…) sin tocar la /// paleta global. Ambos `None` = render idéntico a [`nodegraph_view`]. #[allow(clippy::too_many_arguments)] pub fn nodegraph_view_styled( nodes: &[NodeSpec], wires: &[Wire], palette: &NodegraphPalette, metrics: &NodegraphMetrics, on_drag_node: FDrag, on_connect: FConnect, on_right_click_node: Option, node_tint: Option<&dyn Fn(NodeId) -> Option>, wire_tint: Option<&dyn Fn(&Wire) -> Option>, ) -> View where Msg: Clone + Send + Sync + 'static, FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync + 'static, FConnect: Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync + 'static, FRight: Fn(NodeId) -> Option, { let on_drag: DragNodeFn = Arc::new(on_drag_node); let on_connect: ConnectFn = Arc::new(on_connect); let painted = precompute_wires(nodes, wires, metrics, palette.wire, wire_tint, None); let stroke_px = metrics.wire_stroke; let mut children: Vec> = Vec::with_capacity(nodes.len() + 1); // Capa 0 — cables (van detrás de los nodos). children.push(wires_layer(painted, stroke_px, 0.0)); // Capa 1..N — nodos. for node in nodes { let right_click_msg = on_right_click_node .as_ref() .and_then(|f| f(node.id)); let tint = node_tint.and_then(|f| f(node.id)); children.push(node_view( node, palette, metrics, &on_drag, &on_connect, right_click_msg, tint, false, None, // El visualizador clásico no lleva cuerpos: el cuerpo entra por // `NodegraphSpec`, que es el lienzo navegable. None, // Ni conexión por el borde: también es del lienzo navegable. false, )); } View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }) .fill(palette.bg_canvas) .clip(true) .children(children) } // ───────────────────────────────────────────────────────────────────────── // Variante con cámara (pan/zoom) + selección — `nodegraph_view_spec` // ───────────────────────────────────────────────────────────────────────── type NodeMsgFn = Arc Option + Send + Sync>; type PanFn = Arc Option + Send + Sync>; type ZoomFn = Arc Option + Send + Sync>; /// Especificación completa del lienzo **navegable**: además de mover nodos y /// conectar pins, soporta **cámara** (pan/zoom), **selección** de nodos y /// **deselección** al clickear el fondo. Es el editor de grafo real (vs. el /// visualizador de las funciones `nodegraph_view*`, que son este spec con /// cámara identidad y sin selección). /// /// Todo lo interactivo es opcional: deja los `None` y `Camera::default()` para /// obtener el comportamiento clásico. Las coordenadas de `nodes` son de /// **canvas** (lógicas); el widget las proyecta a pantalla con la cámara. El /// delta de arrastre de un nodo se devuelve ya **en coordenadas de canvas** /// (dividido por el zoom), así el caller lo suma directo a `node.x/y`. pub struct NodegraphSpec<'a, Msg: Clone + Send + Sync + 'static> { pub nodes: &'a [NodeSpec], pub wires: &'a [Wire], /// Aristas simples nodo→nodo (sin pins), pintadas junto a los cables. Para /// grafos de relación/DAG genérico; dejar `&[]` para el clásico de pins. pub edges: &'a [Edge], pub palette: &'a NodegraphPalette, pub metrics: &'a NodegraphMetrics, pub camera: Camera, pub on_drag_node: DragNodeFn, pub on_connect: ConnectFn, pub on_right_click_node: Option>, pub node_tint: Option<&'a dyn Fn(NodeId) -> Option>, pub wire_tint: Option<&'a dyn Fn(&Wire) -> Option>, /// Cable **punteado** en vez de continuo. Es para la distinción que el color /// no puede llevar solo: lo que se sabe contra lo que se supone. `soltar` /// pinta así la clave foránea que nadie declaró y que sólo se ve en el dato /// —sólida cuando una persona se hizo cargo, punteada mientras es una /// inferencia—, y esa diferencia es el contenido del grafo, no un adorno. pub wire_dash: Option<&'a dyn Fn(&Wire) -> bool>, /// Recolorea aristas simples (p.ej. el cono resaltado). `None` = paleta. pub edge_tint: Option<&'a dyn Fn(&Edge) -> Option>, /// Arista **punteada**, igual que [`NodegraphSpec::wire_dash`] pero para las /// aristas simples. pub edge_dash: Option<&'a dyn Fn(&Edge) -> bool>, /// Fase 0..1 del **flujo** animado de las aristas (un guion sutil que corre /// ancestro→descendiente). `0.0` = sin flujo. La app la avanza con un tick. pub flujo: f32, /// Predicado de selección: `true` = el nodo se pinta con borde de acento. pub selected: Option<&'a dyn Fn(NodeId) -> bool>, /// Click en el cuerpo de un nodo → `on_select(id)`. pub on_select_node: Option>, /// Click en el fondo vacío del lienzo → este `Msg` (deseleccionar). pub on_canvas_click: Option, /// Arrastre del fondo vacío → `on_pan(phase, dx, dy)` (delta de pantalla). pub on_pan: Option>, /// Rueda sobre el lienzo → `on_zoom(dy)` (el caller resuelve con /// [`zoom_step`]/[`zoom_at_center`]). pub on_zoom: Option>, /// **Cuerpo embebido** por nodo (ver [`CuerpoNodo`]). Recibe el id y el /// zoom vigente; `None` = el nodo clásico, sin nada bajo los pins. /// /// ⚠️ [`fit_to_view`] **no** conoce los cuerpos: encuadra sobre la altura /// de pins nomás, así que un lienzo de nodos altos queda encuadrado un poco /// corto por abajo. Decirlo acá es más barato que un parámetro que casi /// nadie usaría. pub cuerpo: Option<&'a dyn Fn(NodeId, f32) -> Option>>, } /// Compone el lienzo navegable a partir de un [`NodegraphSpec`]. pub fn nodegraph_view_spec(spec: NodegraphSpec<'_, Msg>) -> View where Msg: Clone + Send + Sync + 'static, { nodegraph_view_spec_ext(spec, NodegraphExtras::default()) } /// [`nodegraph_view_spec`] con lo de [`NodegraphExtras`] — hoy, los puños para /// arrastrar la punta de una arista a otro nodo. pub fn nodegraph_view_spec_ext( spec: NodegraphSpec<'_, Msg>, extras: NodegraphExtras, ) -> View where Msg: Clone + Send + Sync + 'static, { let NodegraphSpec { nodes, wires, edges, palette, metrics, camera, on_drag_node, on_connect, on_right_click_node, node_tint, wire_tint, wire_dash, edge_tint, edge_dash, flujo, selected, on_select_node, on_canvas_click, on_pan, on_zoom, cuerpo, } = spec; let zoom = camera.zoom.max(f32::EPSILON); // Métricas escaladas + nodos en coordenadas de PANTALLA (canvas·zoom+pan). let scaled = metrics.scaled(zoom); let screen_nodes: Vec = nodes .iter() .map(|n| { let (sx, sy) = camera.apply(n.x, n.y); NodeSpec { x: sx, y: sy, ..n.clone() } }) .collect(); // El delta de arrastre llega en px de pantalla; lo pasamos a canvas (÷zoom) // antes de dárselo al caller, así suma directo a node.x/y lógicos. let on_drag_canvas: DragNodeFn = { let inner = on_drag_node.clone(); Arc::new(move |id, phase, dx, dy| (inner)(id, phase, dx / zoom, dy / zoom)) }; // **El alto real de cada nodo**, en pantalla: el clásico más el cuerpo. Se // calcula una vez acá porque lo necesitan los cables (para saber por dónde // enganchan) y el hit-test del soltado, y calcularlo dos veces es cómo el // cable termina en un lugar distinto del que se soltó. let altos: Vec = screen_nodes .iter() .map(|n| { let clasico = scaled.node_height(n.inputs.len(), n.outputs.len()); let cuerpo_alto = cuerpo.and_then(|f| f(n.id, zoom)).map(|c| c.alto * zoom).unwrap_or(0.0); clasico + cuerpo_alto }) .collect(); let anclas_fn = extras.anclas.clone(); let mut painted = precompute_wires(&screen_nodes, wires, &scaled, palette.wire, wire_tint, wire_dash); painted.extend(precompute_edges( &screen_nodes, edges, &scaled, palette.wire, edge_tint, edge_dash, &altos, anclas_fn.as_ref().map(|f| f.as_ref() as &dyn Fn(usize) -> _), )); let stroke_px = scaled.wire_stroke; // Las puntas, para los puños: `painted` se consume al armar la capa. // // **Sólo las aristas**, que van después de los wires: el índice que el puño // devuelve es el de `edges`, y contando desde el principio un grafo con pins // Y aristas le mandaba a reconectar la arista equivocada. let punos: Vec<(f32, f32, f32, f32)> = painted .iter() .skip(wires.len()) .map(|w| (w.x1, w.y1, w.x2, w.y2)) .collect(); let mut children: Vec> = Vec::with_capacity(nodes.len() + 1); // Capa 0 — cables + captura de click/deselección en el fondo. El click sin // mover deselecciona; el arrastre (pan) lo toma el root draggable. let mut wires = wires_layer(painted, stroke_px, flujo); if let Some(msg) = on_canvas_click { wires = wires.on_click(msg); } children.push(wires); // Capa 1..N — nodos (en coords de pantalla), con selección. for node in &screen_nodes { let right_click_msg = on_right_click_node.as_ref().and_then(|f| f(node.id)); let tint = node_tint.and_then(|f| f(node.id)); let is_sel = selected.map(|f| f(node.id)).unwrap_or(false); let select_msg = on_select_node.as_ref().and_then(|f| f(node.id)); // El alto del cuerpo viene en coordenadas de canvas; acá ya estamos en // pantalla, así que se escala como cualquier otra longitud. let cuerpo_nodo = cuerpo.and_then(|f| f(node.id, zoom)).map(|c| CuerpoNodo { alto: c.alto * zoom, vista: c.vista, }); children.push(node_view( node, palette, &scaled, &on_drag_canvas, &on_connect, right_click_msg, tint, is_sel, select_msg, cuerpo_nodo, scaled.conectar_por_borde, )); } // **Los puños de las puntas.** Van al final para quedar encima de los nodos: // el hit-test elige el más interno con handler, y debajo no los agarraría // nadie. Sólo con conexión por el borde — en un grafo de pins una punta // suelta no significa nada. if scaled.conectar_por_borde && (extras.on_reconectar.is_some() || extras.on_tirar_puno.is_some()) { for (i, (x1, y1, x2, y2)) in punos.iter().copied().enumerate() { for (extremo, (px, py)) in [(ExtremoArista::Desde, (x1, y1)), (ExtremoArista::Hasta, (x2, y2))] { // **Mientras se arrastra, el puño está donde lo llevaron**, no // donde lo pone la geometría: sin eso el tirador se queda clavado // y el gesto no se ve. let arrastrando = extras.tirando.filter(|(j, e, _, _)| *j == i && *e == extremo); let (px, py) = match arrastrando { Some((_, _, cx, cy)) => camera.apply(cx, cy), None => (px, py), }; // **El acumulador vive adentro del gesto.** El runtime clona la // closure al empezar el arrastre y la mantiene aunque la vista se // regenere (ver `DragFn`), así que una `base` capturada no ve los // puntos nuevos: con deltas incrementales la punta se movería un // solo paso desde donde empezó y se quedaría ahí. Dos `AtomicU32` // con los bits del `f32` porque esto es `no_std` y `forbid(unsafe)`. let acum = alloc::sync::Arc::new(( core::sync::atomic::AtomicU32::new(camera.invert(px, py).0.to_bits()), core::sync::atomic::AtomicU32::new(camera.invert(px, py).1.to_bits()), )); let tirar = extras.on_tirar_puno.clone(); let reconectar = extras.on_reconectar.clone(); // Para resolver a quién le cayó: las cajas de los nodos y quién // está en la otra punta (nadie se conecta consigo mismo). let cajas: alloc::vec::Vec<(NodeId, f32, f32, f32, f32)> = screen_nodes .iter() .zip(altos.iter()) .map(|(n, h)| (n.id, n.x, n.y, scaled.node_width, *h)) .collect(); let otro_nodo = edges.get(i).map(|e| match extremo { ExtremoArista::Desde => e.to_node, ExtremoArista::Hasta => e.from_node, }); let cam = camera; children.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(px - PUNO_R), top: length(py - PUNO_R), right: length(0.0_f32), bottom: length(0.0_f32), }, size: Size { width: length(PUNO_R * 2.0), height: length(PUNO_R * 2.0), }, ..Default::default() }) .fill(palette.pin_output) .radius(PUNO_R as f64) .draggable(move |phase: DragPhase, dx: f32, dy: f32| { use core::sync::atomic::Ordering::Relaxed; let z = cam.zoom.max(f32::EPSILON); let cx = f32::from_bits(acum.0.load(Relaxed)) + dx / z; let cy = f32::from_bits(acum.1.load(Relaxed)) + dy / z; acum.0.store(cx.to_bits(), Relaxed); acum.1.store(cy.to_bits(), Relaxed); if matches!(phase, DragPhase::End) { // **Soltar sobre un nodo reconecta**; soltar al aire // deja todo como estaba, y las dos cosas tienen que // avisar o el fantasma queda pintado para siempre. let (sx, sy) = cam.apply(cx, cy); let destino = cajas .iter() .rev() .find(|(id, nx, ny, w, h)| { sx >= *nx && sx <= nx + w && sy >= *ny && sy <= ny + h && Some(*id) != otro_nodo }) .copied(); if let (Some((id, nx, ny, w, h)), Some(f)) = (destino, reconectar.as_ref()) { let ancla = ancla_normalizada(nx, ny, w, h, (sx, sy)); return f(i, extremo, id, ancla); } } tirar.as_ref().and_then(|f| f(i, extremo, phase, cx, cy)) }) .role(llimphi_compositor::Role::Button) .aria_label(String::from("↔")), ); } } } // **El cable fantasma**: de la punta que no se mueve a donde va la mano. Es // la respuesta del gesto, y va último para quedar por encima de todo. if let Some((i, extremo, cx, cy)) = extras.tirando { if let Some((x1, y1, x2, y2)) = punos.get(i).copied() { let fijo = match extremo { ExtremoArista::Desde => (x2, y2), ExtremoArista::Hasta => (x1, y1), }; let (sx, sy) = camera.apply(cx, cy); children.push(wires_layer( alloc::vec![WirePainted { x1: fijo.0, y1: fijo.1, x2: sx, y2: sy, d1: if sx >= fijo.0 { 1.0 } else { -1.0 }, d2: if sx >= fijo.0 { -1.0 } else { 1.0 }, color: palette.pin_drop_hover, vertical: false, // Punteado: es una propuesta, no un cable. Un fantasma que se // pinta igual que lo firme se lee como que ya quedó. punteada: true, }], stroke_px, flujo, )); } } let mut root = View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }) .fill(palette.bg_canvas) .clip(true) .children(children); // Pan: arrastre del fondo (los nodos/pins, draggables internos, ganan). if let Some(pan) = on_pan { root = root.draggable(move |phase, dx, dy| (pan)(phase, dx, dy)); } // Zoom con la rueda. if let Some(zoomf) = on_zoom { root = root.on_scroll(move |_dx, dy| (zoomf)(dy)); } root } #[derive(Debug, Clone, Copy)] struct WirePainted { x1: f32, y1: f32, x2: f32, y2: f32, /// Signo de la tangente horizontal en cada extremo (+1 sale hacia la /// derecha, -1 hacia la izquierda). Permite curvar el Bezier hacia afuera /// del lado por el que engancha cada punta. d1: f32, d2: f32, color: Color, /// Si `true`, la tangente del Bezier es vertical (aristas apiladas /// arriba→abajo) en vez de horizontal. vertical: bool, /// Si `true`, se pinta con guiones en vez de trazo continuo. punteada: bool, } fn precompute_wires( nodes: &[NodeSpec], wires: &[Wire], metrics: &NodegraphMetrics, default_color: Color, wire_tint: Option<&dyn Fn(&Wire) -> Option>, wire_dash: Option<&dyn Fn(&Wire) -> bool>, ) -> Vec { let mut out = Vec::with_capacity(wires.len()); for w in wires { let from = nodes.iter().find(|n| n.id == w.from_node); let to = nodes.iter().find(|n| n.id == w.to_node); if let (Some(a), Some(b)) = (from, to) { let y1 = metrics.output_pin_y(a.y, w.from_output); let y2 = metrics.input_pin_y(b.y, w.to_input); // Modo direccional (default): salida por la derecha de `a`, entrada // por la izquierda de `b`. Modo `nearest`: el destino a la izquierda // invierte los lados para que el cable no dé la vuelta. let destino_a_la_derecha = b.x + metrics.node_width * 0.5 >= a.x + metrics.node_width * 0.5; let (x1, d1, x2, d2) = if !metrics.wire_nearest_side || destino_a_la_derecha { (a.x + metrics.node_width, 1.0, b.x, -1.0) } else { (a.x, -1.0, b.x + metrics.node_width, 1.0) }; let color = wire_tint.and_then(|f| f(w)).unwrap_or(default_color); let punteada = wire_dash.map(|f| f(w)).unwrap_or(false); out.push(WirePainted { x1, y1, x2, y2, d1, d2, color, vertical: false, punteada }); } } out } /// Geometría de las aristas simples: ancla en el centro vertical de cada nodo y /// en el borde horizontal del lado más cercano al otro nodo (sin depender de /// pins). Comparte el tipo pintado con los cables, así viajan en la misma capa. fn precompute_edges( nodes: &[NodeSpec], edges: &[Edge], metrics: &NodegraphMetrics, default_color: Color, edge_tint: Option<&dyn Fn(&Edge) -> Option>, edge_dash: Option<&dyn Fn(&Edge) -> bool>, // `altos`: el alto **real** de cada nodo, en el mismo orden que `nodes` — el // clásico más el cuerpo embebido. Sin esto el cable engancha a media altura // de la barra de título, porque `node_height` sólo cuenta pins, y en un // grafo de relación —donde todo el nodo es cuerpo— eso se ve como un cable // que entra por el vértice de arriba en vez de por el costado. altos: &[f32], // `anclas`: las que el operador dejó puestas, por índice de arista — // `(desde, hasta)` en coordenadas **normalizadas** del nodo (0..1 de ancho y // de alto). `None` = la deriva la geometría. anclas: Option<&dyn Fn(usize) -> (Option<(f32, f32)>, Option<(f32, f32)>)>, ) -> Vec { let mut out = Vec::with_capacity(edges.len()); for (i, e) in edges.iter().enumerate() { let from = nodes.iter().position(|n| n.id == e.from_node); let to = nodes.iter().position(|n| n.id == e.to_node); if let (Some(ia), Some(ib)) = (from, to) { let (a, b) = (&nodes[ia], &nodes[ib]); let a_h = altos.get(ia).copied().unwrap_or_else(|| { metrics.node_height(a.inputs.len(), a.outputs.len()) }); let b_h = altos.get(ib).copied().unwrap_or_else(|| { metrics.node_height(b.inputs.len(), b.outputs.len()) }); let color = edge_tint.and_then(|f| f(e)).unwrap_or(default_color); let punteada = edge_dash.map(|f| f(e)).unwrap_or(false); let (an_a, an_b) = anclas.map(|f| f(i)).unwrap_or((None, None)); let painted = if metrics.conectar_por_borde { // **Por el borde**: cada punta sale del punto del rectángulo que // mira al otro nodo, o del que el operador dejó puesto. Es lo // que hace que dos cables del mismo nodo no salgan los dos del // mismo lugar — apilados, el de arriba tapa al de abajo y el // tirador del tapado no le contesta a nadie. let ca = (a.x + metrics.node_width * 0.5, a.y + a_h * 0.5); let cb = (b.x + metrics.node_width * 0.5, b.y + b_h * 0.5); let (x1, y1) = match an_a { Some((u, v)) => (a.x + u * metrics.node_width, a.y + v * a_h), None => punto_en_el_borde(a.x, a.y, metrics.node_width, a_h, cb), }; let (x2, y2) = match an_b { Some((u, v)) => (b.x + u * metrics.node_width, b.y + v * b_h), None => punto_en_el_borde(b.x, b.y, metrics.node_width, b_h, ca), }; // La tangente sale **hacia afuera** del lado por el que // engancha, así el Bezier no se mete por adentro del nodo. let d1 = if x1 <= a.x + 0.5 { -1.0 } else if x1 >= a.x + metrics.node_width - 0.5 { 1.0 } else if x2 >= x1 { 1.0 } else { -1.0 }; let d2 = if x2 <= b.x + 0.5 { -1.0 } else if x2 >= b.x + metrics.node_width - 0.5 { 1.0 } else if x1 >= x2 { 1.0 } else { -1.0 }; WirePainted { x1, y1, x2, y2, d1, d2, color, vertical: false, punteada } } else if metrics.vertical { // Vertical: ancla en el centro-x, borde superior/inferior más // cercano al otro nodo; tangente vertical. let ax = a.x + metrics.node_width * 0.5; let bx = b.x + metrics.node_width * 0.5; let a_cy = a.y + a_h * 0.5; let b_cy = b.y + b_h * 0.5; let (y1, d1, y2, d2) = if b_cy >= a_cy { (a.y + a_h, 1.0, b.y, -1.0) } else { (a.y, -1.0, b.y + b_h, 1.0) }; WirePainted { x1: ax, y1, x2: bx, y2, d1, d2, color, vertical: true, punteada } } else { // Horizontal: centro-y, borde izquierdo/derecho más cercano. let ay = a.y + a_h * 0.5; let by = b.y + b_h * 0.5; let a_cx = a.x + metrics.node_width * 0.5; let b_cx = b.x + metrics.node_width * 0.5; let (x1, d1, x2, d2) = if b_cx >= a_cx { (a.x + metrics.node_width, 1.0, b.x, -1.0) } else { (a.x, -1.0, b.x + metrics.node_width, 1.0) }; WirePainted { x1, y1: ay, x2, y2: by, d1, d2, color, vertical: false, punteada } }; out.push(painted); } } out } /// **Dónde el segmento que va al otro nodo corta el rectángulo de este nodo.** /// /// Se resuelve con la razón más chica entre media-caja y la distancia al centro /// del otro: la que primero se agota dice por qué lado sale. Con los dos centros /// en el mismo punto —nodos superpuestos— devuelve el medio del lado derecho, que /// es lo que hacía el widget antes y no inventa una dirección que no hay. fn punto_en_el_borde(x: f32, y: f32, ancho: f32, alto: f32, otro: (f32, f32)) -> (f32, f32) { let (cx, cy) = (x + ancho * 0.5, y + alto * 0.5); let (dx, dy) = (otro.0 - cx, otro.1 - cy); if dx.abs() < f32::EPSILON && dy.abs() < f32::EPSILON { return (x + ancho, cy); } let (hx, hy) = (ancho * 0.5, alto * 0.5); // `t` es cuánto hay que avanzar en la dirección (dx,dy) para tocar el borde. let tx = if dx.abs() > f32::EPSILON { hx / dx.abs() } else { f32::INFINITY }; let ty = if dy.abs() > f32::EPSILON { hy / dy.abs() } else { f32::INFINITY }; let t = tx.min(ty); (cx + dx * t, cy + dy * t) } /// Lo inverso: el punto, como ancla **normalizada** del nodo (0..1 de cada /// lado). Es lo que se guarda, porque un ancla en píxeles se despega del nodo en /// cuanto el nodo cambia de tamaño. pub fn ancla_normalizada( x: f32, y: f32, ancho: f32, alto: f32, punto: (f32, f32), ) -> (f32, f32) { let u = if ancho.abs() > f32::EPSILON { (punto.0 - x) / ancho } else { 0.5 }; let v = if alto.abs() > f32::EPSILON { (punto.1 - y) / alto } else { 0.5 }; (u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)) } fn wires_layer(wires: Vec, stroke_px: f32, flujo: f32) -> View where Msg: Clone + 'static, { let nodo = View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(0.0_f32), right: length(0.0_f32), bottom: length(0.0_f32), }, size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }); if wires.is_empty() { return nodo; } nodo.paint_with(move |scene, _ts, rect| { let stroke = Stroke::new(stroke_px as f64); // Flujo (opt-in): un guion animado, MUY sutil, corriendo por cada arista // en el sentido ancestro→descendiente. `flujo` es la fase 0..1 que la app // avanza con un tick; el offset del dash la traduce en movimiento. let flujo_dash = if flujo > 0.0 { let dash = (stroke_px as f64 * 3.0).max(5.0); let gap = dash * 2.4; let period = dash + gap; let offset = -(flujo as f64) * period; Some(Stroke::new((stroke_px * 0.9) as f64).with_dashes(offset, [dash, gap])) } else { None }; // Guion FIJO de un cable dudoso — distinto del flujo, que es un brillo // que corre: acá el trazo mismo está partido, y eso se lee como «esto // todavía no es un hecho» sin depender del color (que ya lleva el tinte // y no sobrevive a un daltonismo). let punteado = { let dash = (stroke_px as f64 * 2.2).max(4.0); Stroke::new(stroke_px as f64).with_dashes(0.0, [dash, dash]) }; for w in &wires { // Bezier cúbica con tangentes horizontales — mismo patrón // que las hebras de pluma-editor-llimphi. `d1`/`d2` orientan la // tangente hacia afuera del lado por el que engancha cada punta. let x1 = (rect.x + w.x1) as f64; let y1 = (rect.y + w.y1) as f64; let x2 = (rect.x + w.x2) as f64; let y2 = (rect.y + w.y2) as f64; let mut path = BezPath::new(); path.move_to((x1, y1)); if w.vertical { // Tangente vertical: los controles se separan en Y. let mag = (y2 - y1).abs().max(40.0) * 0.5; path.curve_to((x1, y1 + w.d1 as f64 * mag), (x2, y2 + w.d2 as f64 * mag), (x2, y2)); } else { // Tangente horizontal: los controles se separan en X. let dx = ((w.x2 - w.x1).abs().max(40.0) * 0.5) as f64; path.curve_to((x1 + w.d1 as f64 * dx, y1), (x2 + w.d2 as f64 * dx, y2), (x2, y2)); } let trazo = if w.punteada { &punteado } else { &stroke }; scene.stroke(trazo, Affine::IDENTITY, w.color, None, &path); // Guion de flujo: versión aclarada y semitransparente del color de la // arista, para que se lea como un brillo que corre sin tapar el cable. if let Some(dash) = &flujo_dash { let c = w.color.components; let flow_color = Color { components: [(c[0] + 1.0) * 0.5, (c[1] + 1.0) * 0.5, (c[2] + 1.0) * 0.5, 0.5], ..w.color }; scene.stroke(dash, Affine::IDENTITY, flow_color, None, &path); } } }) } #[allow(clippy::too_many_arguments)] fn node_view( node: &NodeSpec, palette: &NodegraphPalette, metrics: &NodegraphMetrics, on_drag: &DragNodeFn, on_connect: &ConnectFn, on_right_click_msg: Option, tint: Option, selected: bool, on_select_msg: Option, cuerpo: Option>, conectar_por_borde: bool, ) -> View where Msg: Clone + Send + Sync + 'static, { let n_in = node.inputs.len(); let n_out = node.outputs.len(); // Alto de la parte clásica (título + filas de pins). El cuerpo crece hacia // abajo desde acá, así que **las posiciones de los pins no cambian** — que // es lo que hace que los cables sigan enganchando donde siempre. let alto_clasico = metrics.node_height(n_in, n_out); let alto_cuerpo = cuerpo.as_ref().map(|c| c.alto.max(0.0)).unwrap_or(0.0); let height = alto_clasico + alto_cuerpo; // Colores efectivos: el tinte sobrescribe la paleta por-campo. let tint = tint.unwrap_or_default(); let bg_node = tint.bg_node.unwrap_or(palette.bg_node); let bg_title = tint.bg_title.unwrap_or(palette.bg_title); let fg_title = tint.fg_title.unwrap_or(palette.fg_title); let node_id = node.id; let drag = on_drag.clone(); let mut title_bar = View::new(Style { size: Size { width: percent(1.0_f32), height: length(metrics.title_height), }, flex_shrink: 0.0, padding: Rect { left: length(8.0_f32), right: length(8.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .fill(bg_title) .text_aligned( node.label.clone(), metrics.title_text_size, fg_title, Alignment::Start, ) .draggable(move |phase, dx, dy| (drag)(node_id, phase, dx, dy)); if let Some(msg) = on_right_click_msg { title_bar = title_bar.on_right_click(msg); } let mut pin_layer_children: Vec> = Vec::with_capacity(n_in + n_out); for (i, label) in node.inputs.iter().enumerate() { pin_layer_children.push(pin_view( node_id, i as PinIdx, PinKind::Input, label, palette, metrics, on_connect.clone(), )); } for (i, label) in node.outputs.iter().enumerate() { pin_layer_children.push(pin_view( node_id, i as PinIdx, PinKind::Output, label, palette, metrics, on_connect.clone(), )); } let pin_layer = View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(metrics.title_height), right: length(0.0_f32), // El cuerpo se lleva la franja de abajo; sin esto la capa de pins // se estiraría por encima de él. bottom: length(alto_cuerpo), }, size: Size { width: percent(1.0_f32), height: Dimension::auto(), }, ..Default::default() }) .children(pin_layer_children); let mut root = View::new(Style { position: Position::Absolute, inset: Rect { left: length(node.x), top: length(node.y), right: length(0.0_f32), bottom: length(0.0_f32), }, size: Size { width: length(metrics.node_width), height: length(height), }, ..Default::default() }) .fill(bg_node) .radius(metrics.node_radius) // El nodo es el ítem navegable del grafo: el título vive en un hijo, así // que el nombre va explícito. `checked` marca el seleccionado. .role(llimphi_compositor::Role::ListItem) .aria_label(node.label.clone()) .aria_checked(selected); let mut capas = vec![title_bar, pin_layer]; if let Some(c) = cuerpo { // Absoluta y anclada bajo la parte clásica: el cuerpo no empuja nada, // ocupa el espacio que el nodo ya creció para él. capas.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(alto_clasico), right: length(0.0_f32), bottom: length(0.0_f32), }, size: Size { width: percent(1.0_f32), height: length(alto_cuerpo), }, ..Default::default() }) .children(vec![c.vista]), ); } // **El borde conecta.** Una capa que cubre todo menos la barra de título // —que es la que mueve— y que arrastra y recibe. Va al final de las capas // para quedar encima de los pins y del cuerpo: el hit-test elige el nodo // más interno con handler, así que si estuviera debajo no la tocaría nadie. if conectar_por_borde { let cb = on_connect.clone(); capas.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(metrics.title_height), right: length(0.0_f32), bottom: length(0.0_f32), }, size: Size { width: percent(1.0_f32), height: length(height - metrics.title_height), }, ..Default::default() }) // El arrastre no mueve nada: lo que viaja es el payload. .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None) // Pin 0 por convención: sin pins declarados no hay cuál elegir, y // el caller que use esto no los mira. .drag_payload(encode_payload(node_id, 0)) .on_drop(move |payload: u64| { // **Una punta arrastrada ya no entra por acá.** Entraba, y era el // defecto: un `on_drop` no lleva coordenadas, así que lo único // que se podía saber era «le cayó a este nodo» y la punta volvía // al lugar que la geometría quisiera — nunca al que se soltó. Hoy // lo resuelve el puño, que sí sabe dónde está la mano. Si alguna // llega igual se ignora, para no leerla como un cable nuevo. if payload & MARCA_PUNO != 0 { return None; } let (from_node, from_pin) = decode_payload(payload); // **Nadie se conecta consigo mismo.** Sale solo al soltar donde // se empezó, y no significa nada. if from_node == node_id { return None; } (cb)(from_node, from_pin, node_id, 0) }) .drop_hover_fill(palette.pin_drop_hover), ); } root = root.children(capas); // Elevación: sombra suave sobre el fondo (opt-in por métrica). if metrics.node_elevacion > 0.0 { let blur = metrics.node_elevacion as f64; root = root.shadow(Shadow::soft(90, blur).offset(0.0, blur * 0.22)); } // Selección: borde de acento alrededor del nodo elegido. if selected { root = root.border(2.0, palette.wire); } // Click en el cuerpo del nodo (no en el título draggable ni en los pins) → // seleccionar. El árbitro de hit-test da prioridad a los hijos interactivos. if let Some(msg) = on_select_msg { root = root.on_click(msg); } root } #[derive(Debug, Clone, Copy)] enum PinKind { Input, Output, } fn pin_view( node_id: NodeId, pin_idx: PinIdx, kind: PinKind, label: &str, palette: &NodegraphPalette, metrics: &NodegraphMetrics, on_connect: ConnectFn, ) -> View where Msg: Clone + Send + Sync + 'static, { let y_top = pin_idx as f32 * metrics.pin_row_height; let row_h = metrics.pin_row_height; let r = metrics.pin_radius; let diam = r * 2.0; let (pin_left, pin_right, dot_color, label_align) = match kind { PinKind::Input => ( Some(length(-r)), None, palette.pin_input, Alignment::Start, ), PinKind::Output => ( None, Some(length(-r)), palette.pin_output, Alignment::End, ), }; let mut dot = View::new(Style { position: Position::Absolute, inset: Rect { left: pin_left.unwrap_or_else(|| length(0.0_f32)), top: length((row_h - diam) * 0.5), right: pin_right.unwrap_or_else(|| length(0.0_f32)), bottom: length(0.0_f32), }, size: Size { width: length(diam), height: length(diam), }, ..Default::default() }) .fill(dot_color) .radius(r as f64); match kind { PinKind::Output => { dot = dot .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None) .drag_payload(encode_payload(node_id, pin_idx)); } PinKind::Input => { let to_node = node_id; let to_pin = pin_idx; let cb = on_connect.clone(); dot = dot .on_drop(move |payload: u64| { let (from_node, from_pin) = decode_payload(payload); (cb)(from_node, from_pin, to_node, to_pin) }) .drop_hover_fill(palette.pin_drop_hover); } } let label_view = View::new(Style { position: Position::Absolute, inset: Rect { left: length(diam + 4.0), top: length(0.0_f32), right: length(diam + 4.0), bottom: length(0.0_f32), }, size: Size { width: Dimension::auto(), height: length(row_h), }, ..Default::default() }) .text_aligned( label.to_string(), metrics.pin_label_size, palette.fg_pin_label, label_align, ); View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(y_top), right: length(0.0_f32), bottom: length(0.0_f32), }, size: Size { width: percent(1.0_f32), height: length(row_h), }, ..Default::default() }) .children(vec![label_view, dot]) } #[cfg(test)] mod tests { // Estos `use` sólo los necesitan los tests: arriba quedaban como imports // muertos. Es deriva de la migración a `no_std`, donde todo lo de `alloc` // se importa a mano. use alloc::format; use super::*; #[test] fn camera_apply_e_invert_son_inversas() { let cam = Camera { pan_x: 30.0, pan_y: -10.0, zoom: 2.0 }; let (sx, sy) = cam.apply(50.0, 40.0); assert!((sx - (50.0 * 2.0 + 30.0)).abs() < 1e-3); assert!((sy - (40.0 * 2.0 - 10.0)).abs() < 1e-3); let (cx, cy) = cam.invert(sx, sy); assert!((cx - 50.0).abs() < 1e-3 && (cy - 40.0).abs() < 1e-3); } #[test] fn zoom_step_multiplica_y_clampa() { // **El test habla del GESTO, no del signo.** El de antes decía // «acercar sube» con `zoom_step(1.0, 1.0) > 1.0`, que es cierto como // aritmética y falso como gesto: el `1.0` que llega de la rueda es el // delta ya invertido por Llimphi, o sea rueda hacia ATRÁS. Escrito así // el test pasaba y el zoom salía al revés en las trece apps. // // Convención de Llimphi (`input.rs::handle_mouse_wheel`): rueda hacia // adelante → `dy` negativo. let adelante = -1.0; let atras = 1.0; assert!(zoom_step(1.0, adelante) > 1.0, "rueda hacia adelante ACERCA"); assert!(zoom_step(1.0, atras) < 1.0, "rueda hacia atrás ALEJA"); // Clamp en los extremos, con el mismo criterio: no se puede acercar // más allá del tope ni alejar más acá del piso. assert_eq!(zoom_step(ZOOM_MAX, 5.0 * adelante), ZOOM_MAX); assert_eq!(zoom_step(ZOOM_MIN, 5.0 * atras), ZOOM_MIN); } #[test] fn zoom_at_center_mantiene_fijo_el_centro() { let cam = Camera::default(); let (vw, vh) = (800.0_f32, 600.0_f32); // Punto de canvas bajo el centro antes de zoomear. let before = cam.invert(vw * 0.5, vh * 0.5); let cam2 = zoom_at_center(cam, 2.5, vw, vh); assert!((cam2.zoom - 2.5).abs() < 1e-3); // El mismo punto de canvas sigue proyectándose al centro. let (sx, sy) = cam2.apply(before.0, before.1); assert!((sx - vw * 0.5).abs() < 1e-2, "cx fijo (dio {sx})"); assert!((sy - vh * 0.5).abs() < 1e-2, "cy fijo (dio {sy})"); } #[test] fn scaled_metrics_escala_todo_por_zoom() { let m = NodegraphMetrics::default(); let s = m.scaled(2.0); assert!((s.node_width - m.node_width * 2.0).abs() < 1e-3); assert!((s.title_text_size - m.title_text_size * 2.0).abs() < 1e-3); assert!((s.node_radius - m.node_radius * 2.0).abs() < 1e-9); } // ---- el cuerpo embebido ------------------------------------------------- /// Arma el lienzo con y sin cuerpo y devuelve los dos árboles, para poder /// comparar **presencia contra ausencia** (Regla 8.bis del repo): un test /// que sólo mira el caso con cuerpo pasaría igual si el cuerpo se ignorara. fn lienzo_con_y_sin_cuerpo(alto: f32) -> (View<()>, View<()>) { let nodes = vec![NodeSpec { id: 1, label: "n".into(), x: 0.0, y: 0.0, inputs: vec!["a".into()], outputs: vec!["out".into()], }]; let palette = NodegraphPalette::default(); let metrics = NodegraphMetrics::default(); let armar = |cuerpo: Option<&dyn Fn(NodeId, f32) -> Option>>| { nodegraph_view_spec(NodegraphSpec { nodes: &nodes, wires: &[], edges: &[], palette: &palette, metrics: &metrics, camera: Camera::default(), on_drag_node: Arc::new(|_, _, _, _| None), on_connect: Arc::new(|_, _, _, _| None), on_right_click_node: None, node_tint: None, wire_tint: None, wire_dash: None, edge_tint: None, edge_dash: None, flujo: 0.0, selected: None, on_select_node: None, on_canvas_click: None, on_pan: None, on_zoom: None, cuerpo, }) }; let con = armar(Some(&move |_, _| { Some(CuerpoNodo { alto, vista: View::new(Style::default()), }) })); (con, armar(None)) } /// El nodo del lienzo (hijo 1: el 0 es la capa de cables). fn nodo_del_lienzo(v: &View<()>) -> &View<()> { &v.children[1] } fn alto_de(v: &View<()>) -> f32 { v.style .size .height .into_option() .expect("el nodo se dimensiona en px absolutos") } #[test] fn cuerpo_crece_el_nodo_exactamente_su_alto() { let (con, sin) = lienzo_con_y_sin_cuerpo(40.0); let (a, b) = (alto_de(nodo_del_lienzo(&con)), alto_de(nodo_del_lienzo(&sin))); assert!( (a - b - 40.0).abs() < 1e-3, "con cuerpo {a}, sin cuerpo {b}: la diferencia tiene que ser el alto pedido" ); } #[test] fn cuerpo_no_mueve_los_pins() { // Los pins viven en la capa 1 del nodo, anclada al título por arriba. // Si el cuerpo los corriera, los cables engancharían en el aire — // `precompute_wires` calcula sus Y con `input_pin_y`, que no sabe nada // de cuerpos. let (con, sin) = lienzo_con_y_sin_cuerpo(40.0); let capa = |v: &View<()>| nodo_del_lienzo(v).children[1].style.inset.top; assert_eq!( alloc::format!("{:?}", capa(&con)), alloc::format!("{:?}", capa(&sin)), "la capa de pins tiene que empezar en el mismo Y" ); } #[test] fn cuerpo_none_deja_el_arbol_igual_que_antes() { // Retrocompatibilidad literal: el nodo sin cuerpo tiene dos capas // (título + pins) y el que lo tiene, tres. let (con, sin) = lienzo_con_y_sin_cuerpo(40.0); assert_eq!(nodo_del_lienzo(&sin).children.len(), 2); assert_eq!(nodo_del_lienzo(&con).children.len(), 3); } #[test] fn cuerpo_escala_con_el_zoom() { // El alto viene en coordenadas de canvas: a zoom 2 el nodo tiene que // crecer 80 px de pantalla, no 40. let nodes = vec![NodeSpec { id: 1, label: "n".into(), x: 0.0, y: 0.0, inputs: vec![], outputs: vec![], }]; let palette = NodegraphPalette::default(); let metrics = NodegraphMetrics::default(); let cuerpo = move |_: NodeId, _: f32| { Some(CuerpoNodo { alto: 40.0, vista: View::new(Style::default()), }) }; let armar = |zoom: f32| { nodegraph_view_spec(NodegraphSpec::<()> { nodes: &nodes, wires: &[], edges: &[], palette: &palette, metrics: &metrics, camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom }, on_drag_node: Arc::new(|_, _, _, _| None), on_connect: Arc::new(|_, _, _, _| None), on_right_click_node: None, node_tint: None, wire_tint: None, wire_dash: None, edge_tint: None, edge_dash: None, flujo: 0.0, selected: None, on_select_node: None, on_canvas_click: None, on_pan: None, on_zoom: None, cuerpo: Some(&cuerpo), }) }; let sin_cuerpo = |zoom: f32| { let m = metrics.scaled(zoom); m.node_height(0, 0) }; for zoom in [1.0_f32, 2.0, 0.5] { let total = alto_de(nodo_del_lienzo(&armar(zoom))); let aporte = total - sin_cuerpo(zoom); assert!( (aporte - 40.0 * zoom).abs() < 1e-3, "a zoom {zoom} el cuerpo aportó {aporte} px, se esperaban {}", 40.0 * zoom ); } // Nota: el nodo **entero** no se duplica al duplicar el zoom, porque // `node_height` suma 6.0 px fijos que `scaled` no escala. Es anterior a // este campo y afecta a los 13 consumidores por igual; se deja como // está, pero el test mide el aporte del cuerpo y no el total, para no // atarse a esa constante. } #[test] fn payload_roundtrip() { for (n, p) in [ (0u32, 0u16), (1, 0), (0, 1), (42, 7), (u32::MAX, u16::MAX), (123_456, 65_535), ] { let enc = encode_payload(n, p); let (n2, p2) = decode_payload(enc); assert_eq!((n, p), (n2, p2), "payload {enc} → ({n2}, {p2})"); } } #[test] fn metrics_node_height_grows_with_max_side() { let m = NodegraphMetrics::default(); assert_eq!(m.node_height(3, 1), m.node_height(1, 3)); let min = m.title_height + m.pin_row_height + 6.0; assert_eq!(m.node_height(0, 0), min); } #[test] fn pin_y_progression() { let m = NodegraphMetrics::default(); let y0 = m.input_pin_y(100.0, 0); let y1 = m.input_pin_y(100.0, 1); let y2 = m.input_pin_y(100.0, 2); assert!(y1 - y0 > 0.0, "pins crecen hacia abajo"); assert!((y2 - y1) - (y1 - y0) < 1e-3, "espaciado uniforme"); } #[test] fn precompute_skips_dangling_wires() { let nodes = vec![NodeSpec { id: 1, label: "solo".into(), x: 0.0, y: 0.0, inputs: vec!["in".into()], outputs: vec!["out".into()], }]; let wires = vec![Wire { from_node: 99, from_output: 0, to_node: 1, to_input: 0, }]; let m = NodegraphMetrics::default(); let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None, None); assert!(pre.is_empty()); } #[test] fn un_cable_dudoso_se_pinta_punteado_y_uno_sabido_no() { // La distinción que el grafo tiene que llevar y el color no puede llevar // solo: lo que se sabe contra lo que se supone. let nodes = dos_nodos(0.0, 300.0); let wires = vec![UN_CABLE]; let m = NodegraphMetrics::default(); let sabido = precompute_wires(&nodes, &wires, &m, NEGRO, None, None); assert!(!sabido[0].punteada, "sin predicado, el trazo es continuo"); let dudoso = precompute_wires(&nodes, &wires, &m, NEGRO, None, Some(&|_: &Wire| true)); assert!(dudoso[0].punteada); } #[test] fn el_punteado_de_una_arista_se_decide_por_arista() { let nodes = dos_nodos(0.0, 300.0); let edges = vec![ Edge { from_node: 1, to_node: 2 }, Edge { from_node: 2, to_node: 1 }, ]; let m = NodegraphMetrics::default(); let pre = precompute_edges( &nodes, &edges, &m, NEGRO, None, Some(&|e: &Edge| e.from_node == 1), &[], None, ); assert_eq!(pre.len(), 2); assert!(pre[0].punteada && !pre[1].punteada, "el predicado tiene que ver cada arista"); } fn dos_nodos(xa: f32, xb: f32) -> Vec { vec![ NodeSpec { id: 1, label: "a".into(), x: xa, y: 0.0, inputs: vec!["".into()], outputs: vec!["".into()] }, NodeSpec { id: 2, label: "b".into(), x: xb, y: 0.0, inputs: vec!["".into()], outputs: vec!["".into()] }, ] } const UN_CABLE: Wire = Wire { from_node: 1, from_output: 0, to_node: 2, to_input: 0 }; const NEGRO: Color = Color::from_rgba8(0, 0, 0, 255); #[test] fn direccional_siempre_sale_derecha_entra_izquierda() { // Modo clásico: aunque el destino esté a la IZQUIERDA, el cable sale por // la derecha de `a` y entra por la izquierda de `b` (semántica DAG). let m = NodegraphMetrics::default(); let pre = precompute_wires(&dos_nodos(300.0, 0.0), &[UN_CABLE], &m, NEGRO, None, None); assert!((pre[0].x1 - (300.0 + m.node_width)).abs() < 1e-3); assert!((pre[0].x2 - 0.0).abs() < 1e-3); assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0)); } #[test] fn nearest_side_invierte_lados_si_destino_a_la_izquierda() { // Con `wire_nearest_side`, el destino a la izquierda hace que el cable // salga por la IZQUIERDA de `a` (x1 = a.x) y entre por la DERECHA de `b` // (x2 = b.x + node_width) — sin dar la vuelta al nodo. let m = NodegraphMetrics { wire_nearest_side: true, ..Default::default() }; let pre = precompute_wires(&dos_nodos(300.0, 0.0), &[UN_CABLE], &m, NEGRO, None, None); assert!((pre[0].x1 - 300.0).abs() < 1e-3); assert!((pre[0].x2 - m.node_width).abs() < 1e-3); assert_eq!((pre[0].d1, pre[0].d2), (-1.0, 1.0)); } #[test] fn nearest_side_coincide_con_direccional_si_destino_a_la_derecha() { // Destino a la derecha: `nearest` no cambia nada respecto del direccional. let m = NodegraphMetrics { wire_nearest_side: true, ..Default::default() }; let pre = precompute_wires(&dos_nodos(0.0, 300.0), &[UN_CABLE], &m, NEGRO, None, None); assert!((pre[0].x1 - m.node_width).abs() < 1e-3); assert!((pre[0].x2 - 300.0).abs() < 1e-3); assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0)); } #[test] fn precompute_resolves_existing_wires() { let nodes = vec![ NodeSpec { id: 1, label: "a".into(), x: 0.0, y: 0.0, inputs: vec![], outputs: vec!["out".into()], }, NodeSpec { id: 2, label: "b".into(), x: 200.0, y: 50.0, inputs: vec!["in".into()], outputs: vec![], }, ]; let wires = vec![Wire { from_node: 1, from_output: 0, to_node: 2, to_input: 0, }]; let m = NodegraphMetrics::default(); let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None, None); assert_eq!(pre.len(), 1); assert!((pre[0].x1 - m.node_width).abs() < 1e-3); assert!((pre[0].x2 - 200.0).abs() < 1e-3); } // ── Aristas simples (F0.1) ──────────────────────────────────────────── fn nodo_sin_pins(id: NodeId, x: f32, y: f32) -> NodeSpec { NodeSpec { id, label: format!("n{id}"), x, y, inputs: vec![], outputs: vec![] } } #[test] fn edge_ancla_en_centro_y_lado_cercano() { let m = NodegraphMetrics::default(); // b a la derecha de a → sale por la derecha de a, entra por la izq de b. let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 300.0, 40.0)]; let edges = vec![Edge { from_node: 1, to_node: 2 }]; let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[], None); assert_eq!(pre.len(), 1); let half = m.node_height(0, 0) * 0.5; assert!((pre[0].x1 - m.node_width).abs() < 1e-3, "sale por borde derecho de a"); assert!((pre[0].y1 - half).abs() < 1e-3, "ancla en centro vertical de a"); assert!((pre[0].x2 - 300.0).abs() < 1e-3, "entra por borde izquierdo de b"); assert!((pre[0].y2 - (40.0 + half)).abs() < 1e-3, "centro vertical de b"); assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0)); } // ── Conexión por el borde: dónde engancha cada punta ────────────────── /// **El defecto que el operador vio.** Con conexión por el borde, dos cables /// que salen del MISMO nodo hacia nodos distintos tienen que salir por /// puntos distintos. Salían por el mismo —media altura del título, siempre— /// así que los dos tiradores quedaban uno encima del otro: el de arriba tapa /// al de abajo, el tapado no le contesta a nadie, y el gesto se lee como /// «las uniones no se pueden reacomodar». #[test] fn por_el_borde_dos_cables_del_mismo_nodo_no_salen_del_mismo_punto() { let m = NodegraphMetrics { conectar_por_borde: true, ..Default::default() }; // `a` en el medio, uno a la derecha y otro abajo. let nodes = vec![ nodo_sin_pins(1, 200.0, 200.0), nodo_sin_pins(2, 600.0, 200.0), nodo_sin_pins(3, 200.0, 600.0), ]; let edges = vec![Edge { from_node: 1, to_node: 2 }, Edge { from_node: 1, to_node: 3 }]; let altos = vec![120.0, 120.0, 120.0]; let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &altos, None); assert_eq!(pre.len(), 2); let (p1, p2) = ((pre[0].x1, pre[0].y1), (pre[1].x1, pre[1].y1)); assert!( (p1.0 - p2.0).abs() > 1.0 || (p1.1 - p2.1).abs() > 1.0, "las dos puntas salen del mismo punto: {p1:?} y {p2:?}" ); // Y cada una mira al suyo: a la derecha sale por el borde derecho, al de // abajo por el borde de abajo. assert!((p1.0 - (200.0 + m.node_width)).abs() < 1e-3, "al de la derecha, por la derecha"); assert!((p2.1 - (200.0 + 120.0)).abs() < 1e-3, "al de abajo, por abajo"); } /// **El alto que cuenta es el real, cuerpo incluido.** Con `node_height` a /// secas el cable engancha a media barra de título, y en un grafo de relación /// —donde el nodo es todo cuerpo— eso se ve como un cable que entra por el /// vértice de arriba. #[test] fn por_el_borde_el_alto_que_cuenta_es_el_del_cuerpo() { let m = NodegraphMetrics { conectar_por_borde: true, ..Default::default() }; let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 600.0, 0.0)]; let edges = vec![Edge { from_node: 1, to_node: 2 }]; let sin = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[], None); let con = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[200.0, 200.0], None); assert!((sin[0].y1 - m.node_height(0, 0) * 0.5).abs() < 1e-3, "sin altos, el clásico"); assert!((con[0].y1 - 100.0).abs() < 1e-3, "con altos, el medio del nodo de verdad"); assert!( (sin[0].y1 - con[0].y1).abs() > 1.0, "si dieran lo mismo este test no estaría midiendo nada" ); } /// **El ancla puesta a mano gana.** Es lo que hace que la punta se quede /// donde se la soltó en vez de volver a donde la geometría quiera. #[test] fn por_el_borde_el_ancla_puesta_gana_sobre_la_geometria() { let m = NodegraphMetrics { conectar_por_borde: true, ..Default::default() }; let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 600.0, 0.0)]; let edges = vec![Edge { from_node: 1, to_node: 2 }]; let altos = vec![100.0, 100.0]; // Pegada al borde de abajo, a un cuarto del ancho. let anclas = |_i: usize| (Some((0.25_f32, 1.0_f32)), None); let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &altos, Some(&anclas)); assert!((pre[0].x1 - m.node_width * 0.25).abs() < 1e-3, "a un cuarto del ancho"); assert!((pre[0].y1 - 100.0).abs() < 1e-3, "pegada al borde de abajo"); // Y la otra punta, sin ancla, la sigue poniendo la geometría. assert!((pre[0].x2 - 600.0).abs() < 1e-3, "la punta sin ancla no se movió"); } /// Ida y vuelta: lo que se guarda es la fracción, no el píxel. #[test] fn el_ancla_se_guarda_normalizada_y_vuelve_al_mismo_punto() { let (x, y, w, h) = (100.0_f32, 50.0_f32, 160.0_f32, 80.0_f32); let punto = (100.0 + 40.0, 50.0 + 80.0); let (u, v) = ancla_normalizada(x, y, w, h, punto); assert!((u - 0.25).abs() < 1e-5 && (v - 1.0).abs() < 1e-5, "({u}, {v})"); // Y el nodo del doble de alto deja el ancla en el borde de abajo igual. assert!(((y + v * 160.0) - (y + 160.0)).abs() < 1e-5); } /// Fuera de la caja se recorta: un ancla al 1,4 del ancho dibujaría el cable /// despegado del nodo, que es peor que no haberlo movido. #[test] fn un_ancla_fuera_de_la_caja_se_recorta_al_borde() { let (u, v) = ancla_normalizada(0.0, 0.0, 100.0, 100.0, (140.0, -20.0)); assert_eq!((u, v), (1.0, 0.0)); } #[test] fn edge_lado_cercano_si_destino_a_la_izquierda() { let m = NodegraphMetrics::default(); let nodes = vec![nodo_sin_pins(1, 300.0, 0.0), nodo_sin_pins(2, 0.0, 0.0)]; let edges = vec![Edge { from_node: 1, to_node: 2 }]; let pre = precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[], None); // Destino a la izquierda: sale por la izq de a, entra por la der de b. assert!((pre[0].x1 - 300.0).abs() < 1e-3); assert!((pre[0].x2 - m.node_width).abs() < 1e-3); assert_eq!((pre[0].d1, pre[0].d2), (-1.0, 1.0)); } #[test] fn edge_colgante_se_descarta() { let m = NodegraphMetrics::default(); let nodes = vec![nodo_sin_pins(1, 0.0, 0.0)]; let edges = vec![Edge { from_node: 1, to_node: 99 }]; assert!(precompute_edges(&nodes, &edges, &m, NEGRO, None, None, &[], None).is_empty()); } // ── Layout por rango topológico (F0.3) ──────────────────────────────── #[test] fn layout_cadena_lineal_columnas_crecientes() { let m = NodegraphMetrics::default(); let ids = vec![1u32, 2, 3]; let edges = vec![(1, 2), (2, 3)]; let pos = layout_por_rango(&ids, &edges, &m, LayoutRango::default()); assert!(pos[0].x < pos[1].x && pos[1].x < pos[2].x, "cada eslabón una columna más"); // Rangos distintos ⇒ cada uno en la fila 0 de su columna (mismo y). assert!((pos[0].y - pos[1].y).abs() < 1e-3); } #[test] fn layout_diamante_merge_en_rango_mas_profundo() { // a→b, a→c, b→d, c→d : d debe caer en el rango 2 (max de sus padres +1). let m = NodegraphMetrics::default(); let ids = vec![10u32, 11, 12, 13]; // a,b,c,d let edges = vec![(10, 11), (10, 12), (11, 13), (12, 13)]; let pos = layout_por_rango(&ids, &edges, &m, LayoutRango::default()); let col = |id: u32| pos.iter().find(|p| p.id == id).unwrap().x; let opts = LayoutRango::default(); let col_w = m.node_width + opts.col_gap; assert!((col(10) - opts.margin).abs() < 1e-3, "a en rango 0"); assert!((col(11) - (opts.margin + col_w)).abs() < 1e-3, "b en rango 1"); assert!((col(13) - (opts.margin + 2.0 * col_w)).abs() < 1e-3, "d (merge) en rango 2"); } #[test] fn layout_ciclo_no_cuelga() { let m = NodegraphMetrics::default(); let ids = vec![1u32, 2]; let edges = vec![(1, 2), (2, 1)]; // No debe colgarse; ambos caen a rango 0 (mejor esfuerzo). let pos = layout_por_rango(&ids, &edges, &m, LayoutRango::default()); assert_eq!(pos.len(), 2); } // ── Encuadre y conos (F0.5) ─────────────────────────────────────────── #[test] fn fit_to_view_centra_y_clampa() { let m = NodegraphMetrics::default(); let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 400.0, 300.0)]; let (vw, vh) = (800.0_f32, 600.0_f32); let cam = fit_to_view(&nodes, &m, vw, vh); assert!(cam.zoom >= ZOOM_MIN && cam.zoom <= ZOOM_MAX); // El centro del contenido se proyecta al centro del viewport. let mut min_x = f32::INFINITY; let mut max_x = f32::NEG_INFINITY; let mut min_y = f32::INFINITY; let mut max_y = f32::NEG_INFINITY; for n in &nodes { let h = m.node_height(0, 0); min_x = min_x.min(n.x); max_x = max_x.max(n.x + m.node_width); min_y = min_y.min(n.y); max_y = max_y.max(n.y + h); } let (ccx, ccy) = ((min_x + max_x) * 0.5, (min_y + max_y) * 0.5); let (sx, sy) = cam.apply(ccx, ccy); assert!((sx - vw * 0.5).abs() < 1e-2 && (sy - vh * 0.5).abs() < 1e-2); } #[test] fn fit_to_view_sin_nodos_es_identidad() { let m = NodegraphMetrics::default(); assert_eq!(fit_to_view(&[], &m, 800.0, 600.0), Camera::default()); } #[test] fn cono_ascendente_y_descendente_en_diamante() { // a→b, a→c, b→d, c→d let edges = vec![(10u32, 11), (10, 12), (11, 13), (12, 13)]; let mut asc = cono_ascendente(&edges, 13); // ancestros de d + d asc.sort_unstable(); assert_eq!(asc, vec![10, 11, 12, 13]); let mut desc = cono_descendente(&edges, 10); // descendientes de a + a desc.sort_unstable(); assert_eq!(desc, vec![10, 11, 12, 13]); // Una hoja no tiene descendientes salvo ella misma. assert_eq!(cono_descendente(&edges, 13), vec![13]); // Una raíz no tiene ancestros salvo ella misma. assert_eq!(cono_ascendente(&edges, 10), vec![10]); } // ── Orientación vertical (F0 visual) ────────────────────────────────── #[test] fn edge_vertical_ancla_arriba_y_abajo() { let m = NodegraphMetrics { vertical: true, ..Default::default() }; // b debajo de a → sale por el borde inferior de a, entra por el superior de b. let nodes = vec![nodo_sin_pins(1, 0.0, 0.0), nodo_sin_pins(2, 40.0, 300.0)]; let pre = precompute_edges(&nodes, &[Edge { from_node: 1, to_node: 2 }], &m, NEGRO, None, None, &[], None); let h = m.node_height(0, 0); assert!((pre[0].x1 - m.node_width * 0.5).abs() < 1e-3, "centro-x de a"); assert!((pre[0].y1 - h).abs() < 1e-3, "borde inferior de a"); assert!((pre[0].x2 - (40.0 + m.node_width * 0.5)).abs() < 1e-3, "centro-x de b"); assert!((pre[0].y2 - 300.0).abs() < 1e-3, "borde superior de b"); assert!(pre[0].vertical); assert_eq!((pre[0].d1, pre[0].d2), (1.0, -1.0)); } #[test] fn layout_vertical_apila_rangos_en_y() { let m = NodegraphMetrics::default(); let ids = vec![1u32, 2, 3]; let edges = vec![(1, 2), (2, 3)]; let opts = LayoutRango { vertical: true, ..Default::default() }; let pos = layout_por_rango(&ids, &edges, &m, opts); // Cadena: cada eslabón un rango más abajo (y crece), misma columna (x igual). assert!(pos[0].y < pos[1].y && pos[1].y < pos[2].y); assert!((pos[0].x - pos[1].x).abs() < 1e-3); } }