//! `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)] use std::sync::Arc; use llimphi_ui::llimphi_layout::taffy::{ prelude::{length, percent, Dimension, Position, Size, Style}, Rect, }; use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Stroke}; use llimphi_ui::llimphi_raster::peniko::Color; use llimphi_ui::llimphi_text::Alignment; use llimphi_ui::{DragPhase, 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, } /// 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, } /// 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, } 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, } } } 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) } } 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); 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)); // 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, )); } 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) } #[derive(Debug, Clone, Copy)] struct WirePainted { x1: f32, y1: f32, x2: f32, y2: f32, color: Color, } fn precompute_wires( nodes: &[NodeSpec], wires: &[Wire], metrics: &NodegraphMetrics, default_color: Color, wire_tint: Option<&dyn Fn(&Wire) -> Option>, ) -> 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 x1 = a.x + metrics.node_width; let y1 = metrics.output_pin_y(a.y, w.from_output); let x2 = b.x; let y2 = metrics.input_pin_y(b.y, w.to_input); let color = wire_tint.and_then(|f| f(w)).unwrap_or(default_color); out.push(WirePainted { x1, y1, x2, y2, color, }); } } out } fn wires_layer(wires: Vec, stroke_px: 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); for w in &wires { // Bezier cúbica con tangentes horizontales — mismo patrón // que las hebras de pluma-editor-llimphi. let dx = ((w.x2 - w.x1).abs().max(40.0) * 0.5) as f64; 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)); path.curve_to((x1 + dx, y1), (x2 - dx, y2), (x2, y2)); scene.stroke(&stroke, Affine::IDENTITY, w.color, None, &path); } }) } fn node_view( node: &NodeSpec, palette: &NodegraphPalette, metrics: &NodegraphMetrics, on_drag: &DragNodeFn, on_connect: &ConnectFn, on_right_click_msg: Option, tint: Option, ) -> View where Msg: Clone + Send + Sync + 'static, { let n_in = node.inputs.len(); let n_out = node.outputs.len(); let height = metrics.node_height(n_in, n_out); // 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), bottom: length(0.0_f32), }, size: Size { width: percent(1.0_f32), height: Dimension::auto(), }, ..Default::default() }) .children(pin_layer_children); 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) .children(vec![title_bar, pin_layer]) } #[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), right: length(diam + 4.0), bottom: length(0.0), }, 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), top: length(y_top), right: length(0.0), 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 { use super::*; #[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); assert!(pre.is_empty()); } #[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); assert_eq!(pre.len(), 1); assert!((pre[0].x1 - m.node_width).abs() < 1e-3); assert!((pre[0].x2 - 200.0).abs() < 1e-3); } }