//! `llimphi-widget-navigator` — navegador **data-agnóstico** de nodos en //! dos modos conmutables: **árbol** (`tree`) y **grafo** (`nodegraph`). //! //! Nació para que `pata` muestre las **Mónadas** de nouser y sus archivos //! en un sidebar, pero el widget no sabe de nouser: recibe un bosque de //! [`NavNode`]s (id opaco + label + [`NavKind`] + hijos) y emite `Msg`s al //! interactuar. El caller mapea cada `id` a lo suyo (un `MonadId`, un path) //! y decide qué hacer al seleccionar/abrir. //! //! Igual que el resto de widgets Llimphi, es **render-only y stateless**: //! el estado (qué nodos están expandidos, cuál está seleccionado, en qué //! modo está) vive en el `Model` del App; el widget sólo pinta y avisa. //! //! - **Árbol** — reusa [`llimphi_widget_tree`]. El navegador aplana el //! bosque respetando `is_expanded`, dibuja un icono por [`NavKind`] entre //! el chevron y el label, y cablea toggle / select / context por fila. //! - **Grafo** — reusa [`llimphi_widget_nodegraph`]. Coloca los nodos //! visibles en columnas por profundidad, con cables de **contención** //! (padre→hijo). El nodo seleccionado se resalta; arrastrar un nodo lo //! selecciona; el right-click abre el menú contextual. //! //! ```ignore //! navigator_view( //! NavSpec { roots: &model.nodes, mode: model.mode, //! selected: model.selected, palette, guides: true }, //! |id| model.expanded.contains(&id), //! Msg::Toggle, Msg::Select, Some(Msg::Open), //! ) //! ``` #![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::boxed::Box; use alloc::string::String; use alloc::vec::Vec; use alloc::vec; use alloc::format; // `hashbrown` ES el `HashMap` de std por dentro: en Linux no cambia nada, y // sin sistema operativo es lo único que existe. use hashbrown::{HashMap, HashSet}; use alloc::sync::Arc; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{Size, Style}, AlignItems, JustifyContent, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::{DragPhase, View}; use llimphi_theme::Theme; use llimphi_widget_nodegraph::{ cono_descendente, fit_to_view, layout_por_rango, nodegraph_view_styled, nodegraph_view_spec, Camera, Edge, LayoutRango, NodeId, NodeSpec, NodeTint, NodegraphMetrics, NodegraphPalette, NodegraphSpec, Wire, }; use llimphi_widget_tree::{tree_view, TreePalette, TreeRow, TreeSpec}; use llimphi_widget_context_menu::{ context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec, }; use grafo_nav::{GrafoNavegable, Rol, Verbo}; /// Identificador opaco de un nodo. El caller lo asigna y lo recibe de vuelta /// sin que el widget lo interprete (típicamente un índice a su propio mapa /// `id → MonadId | PathBuf`). pub type NavId = u64; /// Naturaleza de un nodo — sólo para elegir su icono y tinte. El widget no /// asume semántica de dominio más allá de esto. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavKind { /// Una Mónada (cluster semántico de nouser). Diamante de acento. Monad, /// Una agrupación intermedia (carpeta lógica, categoría). Cuadrado. Group, /// Un directorio del filesystem. Cuadrado tenue. Dir, /// Un archivo hoja. Punto. File, /// Cualquier otra cosa. Punto tenue. Other, } /// Un nodo del bosque que el navegador pinta. La jerarquía es explícita /// (`children`); el navegador la aplana según el estado de expansión. #[derive(Debug, Clone)] pub struct NavNode { pub id: NavId, pub label: String, pub kind: NavKind, pub children: Vec, } impl NavNode { /// Un nodo hoja (sin hijos). pub fn leaf(id: NavId, label: impl Into, kind: NavKind) -> Self { Self { id, label: label.into(), kind, children: Vec::new(), } } /// Un nodo con hijos. pub fn branch( id: NavId, label: impl Into, kind: NavKind, children: Vec, ) -> Self { Self { id, label: label.into(), kind, children, } } /// `true` si tiene al menos un hijo. pub fn has_children(&self) -> bool { !self.children.is_empty() } } /// Modo de visualización del navegador. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavMode { /// Árbol indentado con expand/collapse. Tree, /// Grafo de nodos con cables de contención. Graph, } impl NavMode { /// Etiquetas para un control segmentado (en el mismo orden que /// [`NavMode::index`] / [`NavMode::from_index`]). pub const LABELS: [&'static str; 2] = ["Árbol", "Grafo"]; /// El otro modo (para un botón de toggle simple). pub fn toggled(self) -> Self { match self { NavMode::Tree => NavMode::Graph, NavMode::Graph => NavMode::Tree, } } /// Índice 0/1 — para alimentar un control segmentado. pub fn index(self) -> usize { match self { NavMode::Tree => 0, NavMode::Graph => 1, } } /// Recupera el modo desde un índice de control segmentado (≥1 = grafo). pub fn from_index(i: usize) -> Self { if i == 0 { NavMode::Tree } else { NavMode::Graph } } } /// Paleta del navegador: hereda las de tree y nodegraph del [`Theme`] /// semántico, más los tintes por [`NavKind`] para los iconos. #[derive(Debug, Clone, Copy)] pub struct NavPalette { pub tree: TreePalette, pub graph: NodegraphPalette, pub accent: Color, pub monad: Color, pub group: Color, pub dir: Color, pub file: Color, pub other: Color, } impl Default for NavPalette { fn default() -> Self { Self::from_theme(&Theme::dark()) } } impl NavPalette { pub fn from_theme(t: &Theme) -> Self { Self { tree: TreePalette::from_theme(t), graph: NodegraphPalette::from_theme(t), accent: t.accent, monad: t.accent, group: t.fg_text, dir: t.fg_muted, file: t.fg_text, other: t.fg_muted, } } /// El color del icono de un nodo según su clase. pub fn kind_color(&self, kind: NavKind) -> Color { match kind { NavKind::Monad => self.monad, NavKind::Group => self.group, NavKind::Dir => self.dir, NavKind::File => self.file, NavKind::Other => self.other, } } } /// Lo que el navegador necesita saber para pintar, sin los callbacks. pub struct NavSpec<'a> { /// Las raíces del bosque a mostrar. pub roots: &'a [NavNode], /// Modo activo. pub mode: NavMode, /// Nodo seleccionado (resaltado en ambos modos). `None` = ninguno. pub selected: Option, /// Paleta. pub palette: NavPalette, /// Dibujar líneas guía de indentación en el árbol. pub guides: bool, } /// Alto de fila del árbol / paso vertical del grafo. const ROW_H: f32 = 24.0; /// Tamaño del icono de clase (px). const ICON_PX: f32 = 14.0; /// Compone el navegador. Los callbacks se identifican por [`NavId`]: /// - `is_expanded(id)` → si un nodo rama está abierto (sólo árbol); /// - `on_toggle(id)` → al click en el chevron (sólo árbol); /// - `on_select(id)` → al click en la fila (árbol) o al arrastrar el nodo /// (grafo); /// - `on_context(id)` → al right-click (ambos modos); `None` = sin menú. pub fn navigator_view( spec: NavSpec, is_expanded: FExp, on_toggle: FTog, on_select: FSel, on_context: Option, ) -> View where Msg: Clone + Send + Sync + 'static, FExp: Fn(NavId) -> bool, FTog: Fn(NavId) -> Msg, FSel: Fn(NavId) -> Msg + Send + Sync + 'static, FCtx: Fn(NavId) -> Msg, { match spec.mode { NavMode::Tree => tree_mode(spec, is_expanded, on_toggle, on_select, on_context), NavMode::Graph => graph_mode(spec, is_expanded, on_select, on_context), } } // ===================================================================== // Árbol // ===================================================================== fn tree_mode( spec: NavSpec, is_expanded: FExp, on_toggle: FTog, on_select: FSel, on_context: Option, ) -> View where Msg: Clone + Send + Sync + 'static, FExp: Fn(NavId) -> bool, FTog: Fn(NavId) -> Msg, FSel: Fn(NavId) -> Msg, FCtx: Fn(NavId) -> Msg, { let mut rows: Vec> = Vec::new(); for root in spec.roots { push_rows( root, 0, &spec, &is_expanded, &on_toggle, &on_select, &on_context, &mut rows, ); } tree_view(TreeSpec { rows, row_height: ROW_H, indent_px: 14.0, palette: spec.palette.tree, guides: spec.guides, }) } #[allow(clippy::too_many_arguments)] fn push_rows( node: &NavNode, depth: usize, spec: &NavSpec, is_expanded: &FExp, on_toggle: &FTog, on_select: &FSel, on_context: &Option, out: &mut Vec>, ) where Msg: Clone + Send + Sync + 'static, FExp: Fn(NavId) -> bool, FTog: Fn(NavId) -> Msg, FSel: Fn(NavId) -> Msg, FCtx: Fn(NavId) -> Msg, { let has_children = node.has_children(); let expanded = has_children && is_expanded(node.id); let icon = kind_icon_view::(node.kind, spec.palette.kind_color(node.kind)); let mut row = TreeRow::new( node.label.clone(), depth, has_children, expanded, spec.selected == Some(node.id), on_toggle(node.id), on_select(node.id), ) .with_icon(icon); if let Some(ctx) = on_context.as_ref().map(|f| f(node.id)) { row = row.with_context(ctx); } out.push(row); if expanded { for child in &node.children { push_rows( child, depth + 1, spec, is_expanded, on_toggle, on_select, on_context, out, ); } } } // ===================================================================== // Grafo // ===================================================================== /// Un nodo visible aplanado para el grafo: su id, su label/kind y la posición /// (índice) de su padre en la lista (`None` = raíz). struct FlatNode { id: NavId, label: String, kind: NavKind, depth: usize, parent: Option, has_children: bool, } fn flatten_for_graph bool>( roots: &[NavNode], is_expanded: &FExp, ) -> Vec { let mut out = Vec::new(); for root in roots { walk_graph(root, 0, None, is_expanded, &mut out); } out } fn walk_graph bool>( node: &NavNode, depth: usize, parent: Option, is_expanded: &FExp, out: &mut Vec, ) { let has_children = node.has_children(); let me = out.len(); out.push(FlatNode { id: node.id, label: node.label.clone(), kind: node.kind, depth, parent, has_children, }); if has_children && is_expanded(node.id) { for child in &node.children { walk_graph(child, depth + 1, Some(me), is_expanded, out); } } } fn graph_mode( spec: NavSpec, is_expanded: FExp, on_select: FSel, on_context: Option, ) -> View where Msg: Clone + Send + Sync + 'static, FExp: Fn(NavId) -> bool, FSel: Fn(NavId) -> Msg + Send + Sync + 'static, FCtx: Fn(NavId) -> Msg, { let flat = flatten_for_graph(spec.roots, &is_expanded); let metrics = NodegraphMetrics { node_width: 150.0, ..NodegraphMetrics::default() }; // Layout: columna por profundidad, una fila por nodo visible. const MARGIN: f32 = 24.0; const COL_GAP: f32 = 36.0; const ROW_GAP: f32 = 12.0; let node_h = metrics.node_height(1, 1); let col_w = metrics.node_width + COL_GAP; let mut nodes: Vec = Vec::with_capacity(flat.len()); let mut wires: Vec = Vec::new(); let ids: Vec = flat.iter().map(|f| f.id).collect(); for (i, f) in flat.iter().enumerate() { let inputs = if f.parent.is_some() { vec![String::new()] } else { Vec::new() }; let outputs = if f.has_children { vec![String::new()] } else { Vec::new() }; // Prefijo del icono en el label (el nodegraph no tiene slot de icono; // un glifo simple por clase basta para distinguirlos de un vistazo). let label = format!("{} {}", kind_glyph(f.kind), f.label); nodes.push(NodeSpec { id: i as NodeId, label, x: MARGIN + f.depth as f32 * col_w, y: MARGIN + i as f32 * (node_h + ROW_GAP), inputs, outputs, }); if let Some(p) = f.parent { wires.push(Wire { from_node: p as NodeId, from_output: 0, to_node: i as NodeId, to_input: 0, }); } } // Arrastrar un nodo lo selecciona (al soltar). El grafo no reposiciona // por arrastre — el layout es derivado, no editable. let drag_ids = ids.clone(); let on_drag = move |id: NodeId, phase: DragPhase, _dx: f32, _dy: f32| match phase { DragPhase::End => drag_ids .get(id as usize) .map(|nav_id| on_select(*nav_id)), DragPhase::Move => None, }; // Sin conexiones: la contención es fija. let on_connect = |_: NodeId, _: u16, _: NodeId, _: u16| None; // Right-click → menú contextual (evaluado en build, por nodo). let ctx_ids = &ids; let on_right: Option Option>> = on_context.map(|f| { let f = move |id: NodeId| ctx_ids.get(id as usize).map(|nav_id| f(*nav_id)); Box::new(f) as Box Option> }); // Resaltado del nodo seleccionado. let sel_idx = spec .selected .and_then(|sid| ids.iter().position(|id| *id == sid)); let accent = spec.palette.accent; let tint = move |id: NodeId| -> Option { if Some(id as usize) == sel_idx { Some(NodeTint { bg_title: Some(accent), ..NodeTint::default() }) } else { None } }; nodegraph_view_styled( &nodes, &wires, &spec.palette.graph, &metrics, on_drag, on_connect, on_right, Some(&tint as &dyn Fn(NodeId) -> Option), None, ) } /// Glifo ASCII-ish por clase para el label del grafo. fn kind_glyph(kind: NavKind) -> &'static str { match kind { NavKind::Monad => "◈", NavKind::Group => "▣", NavKind::Dir => "▸", NavKind::File => "·", NavKind::Other => "·", } } // ===================================================================== // Cockpit de DAG — grafo general (múltiples padres) + cámara navegable // ===================================================================== /// Un nodo **plano** de un DAG general. A diferencia de [`NavNode`] (un árbol /// de contención, un solo padre), aquí la topología va aparte en `edges`, así un /// nodo puede tener **varios padres** (un merge de commits, un mensaje que /// responde a dos, una celda con dos dependencias). El id sigue siendo opaco. #[derive(Debug, Clone)] pub struct NavFlatNode { pub id: NavId, pub label: String, pub kind: NavKind, } impl NavFlatNode { pub fn new(id: NavId, label: impl Into, kind: NavKind) -> Self { Self { id, label: label.into(), kind } } } /// Lo que el cockpit necesita para pintar un DAG general, sin los callbacks. pub struct NavDagSpec<'a> { /// Los nodos del grafo (lista plana). pub nodes: &'a [NavFlatNode], /// Aristas dirigidas `(from, to)` por id. Ids ausentes se ignoran. pub edges: &'a [(NavId, NavId)], /// Cámara (pan/zoom). El caller la guarda en su `Model`; usar /// [`llimphi_widget_nodegraph::fit_to_view`] para el encuadre inicial. pub camera: Camera, /// Nodo seleccionado: borde de acento + resaltado de su **cono descendente** /// (lo que se toca al *cercenar*). `None` = ninguno. pub selected: Option, /// Paleta. pub palette: NavPalette, /// `true` = layout apilado de arriba hacia abajo (rangos en Y), aristas /// verticales; `false` = de izquierda a derecha. Autoequilibrado en ambos. pub vertical: bool, /// Overrides de posición `(id, x, y)` (canvas) para nodos **arrastrados a /// mano**. Los ids presentes usan su posición dada; el resto cae al /// auto-layout. Vacío = todo automático. Sembrar con [`grafo_posiciones`]. pub posiciones: &'a [(NavId, f32, f32)], /// Fase 0..1 del flujo animado de las aristas. `0.0` = sin flujo. La app la /// avanza con un tick (`Handle::spawn_periodic`). pub flujo: f32, } /// Métricas del cockpit de DAG, compartidas por el render y el encuadre. Nodos /// algo anchos para las etiquetas y `wire_nearest_side` para que en un grafo de /// relaciones los cables enganchen por el lado que mira al otro nodo (no dan la /// vuelta). fn dag_metrics(vertical: bool) -> NodegraphMetrics { NodegraphMetrics { node_width: 168.0, wire_nearest_side: true, node_elevacion: 14.0, vertical, ..NodegraphMetrics::default() } } // ── Color: variación de matiz por nodo/arista desde el acento ──────────────── fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) { let max = r.max(g).max(b); let min = r.min(g).min(b); let l = (max + min) * 0.5; let d = max - min; if d.abs() < 1e-6 { return (0.0, 0.0, l); } let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; let h = if max == r { (g - b) / d + if g < b { 6.0 } else { 0.0 } } else if max == g { (b - r) / d + 2.0 } else { (r - g) / d + 4.0 }; (h * 60.0, s, l) } fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) { if s.abs() < 1e-6 { return (l, l, l); } let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; let p = 2.0 * l - q; let hk = h / 360.0; let tc = |mut t: f32| { if t < 0.0 { t += 1.0; } if t > 1.0 { t -= 1.0; } if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t } else if t < 0.5 { q } else if t < 2.0 / 3.0 { p + (q - p) * (2.0 / 3.0 - t) * 6.0 } else { p } }; (tc(hk + 1.0 / 3.0), tc(hk), tc(hk - 1.0 / 3.0)) } /// Rota el matiz de `base` `deg` grados (HSL), conservando saturación, luz y /// alfa. Base de la variación de color por nodo/arista. fn hue_shift(base: Color, deg: f32) -> Color { let [r, g, b, a] = base.components; let (h, s, l) = rgb_to_hsl(r, g, b); let (nr, ng, nb) = hsl_to_rgb((h + deg).rem_euclid(360.0), s, l); Color { components: [nr, ng, nb, a], ..base } } /// Baja la saturación de `base` por `factor` (0..1). Para nodos apagados /// (stale/huérfano). fn desaturar(base: Color, factor: f32) -> Color { let [r, g, b, a] = base.components; let (h, s, l) = rgb_to_hsl(r, g, b); let (nr, ng, nb) = hsl_to_rgb(h, s * factor, l); Color { components: [nr, ng, nb, a], ..base } } /// Sube la luz de `base` por `amt` (aclara). Para resaltar el cono. fn aclarar(base: Color, amt: f32) -> Color { let [r, g, b, a] = base.components; let (h, s, l) = rgb_to_hsl(r, g, b); let (nr, ng, nb) = hsl_to_rgb(h, s, (l + amt).clamp(0.0, 1.0)); Color { components: [nr, ng, nb, a], ..base } } /// El color del título de un nodo según su clase e índice: el nodo activo lleva /// el acento pleno, los apagados van desaturados, y los corrientes **varían su /// matiz** por índice (paso ~43° para separar hues vecinos). fn color_de_nodo(kind: NavKind, id: NodeId, accent: Color) -> Color { match kind { NavKind::Monad => accent, NavKind::Other => desaturar(accent, 0.5), _ => hue_shift(accent, (id as f32) * 43.0), } } /// Mapea un [`GrafoNavegable`] a nodos planos + aristas del cockpit. Único punto /// de traducción trait→visor, así el render y el encuadre coinciden exactos. fn flat_from_grafo(grafo: &G) -> (Vec, Vec<(NavId, NavId)>) { let nodes = grafo .nodos() .into_iter() .map(|n| NavFlatNode { id: n.id, label: n.etiqueta, kind: navkind_de_rol(n.rol) }) .collect(); let edges = grafo.aristas(); (nodes, edges) } /// Posiciona los nodos planos por rango topológico y devuelve los `NodeSpec` /// listos para pintar + las aristas en espacio de índices. Compartido por /// [`navigator_dag_view`] y [`grafo_fit_camera`] para que el encuadre calce con /// lo que se dibuja. fn dag_positioned_nodes( nodes: &[NavFlatNode], edges: &[(NavId, NavId)], metrics: &NodegraphMetrics, vertical: bool, overrides: &[(NavId, f32, f32)], ) -> (Vec, Vec<(NodeId, NodeId)>) { let index: HashMap = nodes.iter().enumerate().map(|(i, n)| (n.id, i as NodeId)).collect(); let edges_ii: Vec<(NodeId, NodeId)> = edges .iter() .filter_map(|(f, t)| Some((*index.get(f)?, *index.get(t)?))) .collect(); let ids: Vec = (0..nodes.len() as NodeId).collect(); let pos = layout_por_rango(&ids, &edges_ii, metrics, LayoutRango { vertical, ..LayoutRango::default() }); let posmap: HashMap = pos.iter().map(|p| (p.id, (p.x, p.y))).collect(); let mut specs: Vec = nodes .iter() .enumerate() .map(|(i, n)| { let (x, y) = posmap.get(&(i as NodeId)).copied().unwrap_or((0.0, 0.0)); NodeSpec { id: i as NodeId, label: format!("{} {}", kind_glyph(n.kind), n.label), x, y, inputs: Vec::new(), outputs: Vec::new(), } }) .collect(); // Overrides de drag manual: pisan la posición del auto-layout. for &(navid, x, y) in overrides { if let Some(&ci) = index.get(&navid) { if let Some(s) = specs.get_mut(ci as usize) { s.x = x; s.y = y; } } } (specs, edges_ii) } /// Cámara que **encuadra** el grafo de un [`GrafoNavegable`] en un viewport dado, /// con el mismo layout que dibuja [`cockpit_grafo_view`]. Usar en el `init` de la /// app para que el DAG aparezca centrado y a escala en vez de arrancar en una /// esquina con la cámara identidad. pub fn grafo_fit_camera( grafo: &G, viewport_w: f32, viewport_h: f32, vertical: bool, ) -> Camera { let (nodes, edges) = flat_from_grafo(grafo); let metrics = dag_metrics(vertical); let (specs, _) = dag_positioned_nodes(&nodes, &edges, &metrics, vertical, &[]); fit_to_view(&specs, &metrics, viewport_w, viewport_h) } /// Devuelve la posición `(id, x, y)` que el **auto-layout** asigna a cada nodo /// del grafo, en la orientación dada. Sirve para **sembrar** las posiciones /// iniciales de un cockpit con drag manual (`NavDagSpec::posiciones`): la app /// arranca con estas y luego actualiza la del nodo arrastrado. pub fn grafo_posiciones(grafo: &G, vertical: bool) -> Vec<(NavId, f32, f32)> { let (nodes, edges) = flat_from_grafo(grafo); let metrics = dag_metrics(vertical); let (specs, _) = dag_positioned_nodes(&nodes, &edges, &metrics, vertical, &[]); nodes .iter() .zip(specs.iter()) .map(|(n, s)| (n.id, s.x, s.y)) .collect() } /// Compone el **cockpit de DAG**: un grafo general navegable sobre /// [`nodegraph_view_spec`] (pan/zoom/selección reales), con auto-layout por /// rango topológico ([`layout_por_rango`]) y aristas simples ([`Edge`]) — sin /// pins dummy. Es el visor universal de DAGs; el caller mapea cada [`NavId`] a /// su dominio (un commit, un mensaje, una Mónada) y decide los verbos. /// /// No sustituye a [`navigator_view`] (árbol ↔ grafo de contención); es la /// entrada para grafos con múltiples padres que aquél no puede expresar. /// /// - `on_select(id)` — click en un nodo (o soltar su arrastre); /// - `on_context(id)` — right-click sobre el nodo (paleta de verbos); `None`; /// - `on_pan(phase, dx, dy)` — arrastre del fondo; /// - `on_zoom(dy)` — rueda (resolver con `zoom_step`/`zoom_at_center`). #[allow(clippy::too_many_arguments)] pub fn navigator_dag_view( spec: NavDagSpec, on_select: FSel, on_context: Option, on_move: FMove, on_pan: FPan, on_zoom: FZoom, ) -> View where Msg: Clone + Send + Sync + 'static, FSel: Fn(NavId) -> Msg + Send + Sync + 'static, FCtx: Fn(NavId) -> Msg + Send + Sync + 'static, FMove: Fn(NavId, f32, f32) -> Option + Send + Sync + 'static, FPan: Fn(DragPhase, f32, f32) -> Option + Send + Sync + 'static, FZoom: Fn(f32) -> Option + Send + Sync + 'static, { let metrics = dag_metrics(spec.vertical); // id de dominio → índice compacto (NodeId), y su inverso para los callbacks. let index: HashMap = spec .nodes .iter() .enumerate() .map(|(i, n)| (n.id, i as NodeId)) .collect(); let nav_ids: Vec = spec.nodes.iter().map(|n| n.id).collect(); // Nodos posicionados por rango topológico + aristas en espacio de índices // (mismo helper que usa el encuadre, para que calcen). let (nodes, edges_ii) = dag_positioned_nodes(spec.nodes, spec.edges, &metrics, spec.vertical, spec.posiciones); let edges: Vec = edges_ii .iter() .map(|(f, t)| Edge { from_node: *f, to_node: *t }) .collect(); // Selección + cono descendente resaltado (previsualiza qué toca cercenar). let sel_idx: Option = spec.selected.and_then(|s| index.get(&s).copied()); let cone: HashSet = match sel_idx { Some(s) => cono_descendente(&edges_ii, s).into_iter().collect(), None => HashSet::new(), }; let accent = spec.palette.accent; // Callbacks. `on_select` se comparte entre click-en-nodo y fin-de-arrastre. let on_select: Arc Msg + Send + Sync> = Arc::new(on_select); // Arrastre: el delta (ya en coords de canvas) va a `on_move`, y al soltar // se selecciona. La app acumula la posición (sembrada con `grafo_posiciones`). let move_h: Arc Option + Send + Sync> = Arc::new(on_move); let drag_ids = nav_ids.clone(); let sel_drag = on_select.clone(); let move_drag = move_h.clone(); let on_drag_node: Arc< dyn Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync, > = Arc::new(move |id, phase, dx, dy| { let nid = *drag_ids.get(id as usize)?; match phase { DragPhase::Move => (move_drag)(nid, dx, dy), DragPhase::End => Some((sel_drag)(nid)), } }); let on_connect: Arc< dyn Fn(NodeId, u16, NodeId, u16) -> Option + Send + Sync, > = Arc::new(|_, _, _, _| None); let sel_click = on_select.clone(); let click_ids = nav_ids.clone(); let on_select_node: Option Option + Send + Sync>> = Some(Arc::new(move |id| { click_ids.get(id as usize).map(|nid| (sel_click)(*nid)) })); let on_right_click_node: Option Option + Send + Sync>> = on_context.map(|f| { let ctx_ids = nav_ids.clone(); Arc::new(move |id: NodeId| ctx_ids.get(id as usize).map(|nid| f(*nid))) as Arc Option + Send + Sync> }); // Estilo. Cada nodo lleva un color de título DERIVADO del acento y variado // por su índice/clase; cada arista hereda el color de su nodo origen. El cono // descendente de la selección se aclara (nodos) y va al acento (aristas) para // previsualizar qué toca cercenar; el seleccionado lleva borde de acento. let kinds: Vec = spec.nodes.iter().map(|n| n.kind).collect(); let kinds_nodo = kinds.clone(); let cone_nodes = cone.clone(); let node_tint = move |id: NodeId| -> Option { let kind = kinds_nodo.get(id as usize).copied().unwrap_or(NavKind::Other); let mut title = color_de_nodo(kind, id, accent); if cone_nodes.contains(&id) && Some(id) != sel_idx { title = aclarar(title, 0.14); } Some(NodeTint { bg_title: Some(title), ..NodeTint::default() }) }; let selected_pred = move |id: NodeId| Some(id) == sel_idx; let cone_edges = cone; let kinds_edge = kinds; let edge_tint = move |e: &Edge| -> Option { if cone_edges.contains(&e.from_node) && cone_edges.contains(&e.to_node) { return Some(accent); } let kind = kinds_edge.get(e.from_node as usize).copied().unwrap_or(NavKind::Group); Some(color_de_nodo(kind, e.from_node, accent)) }; let palette = spec.palette.graph; nodegraph_view_spec(NodegraphSpec { nodes: &nodes, wires: &[], edges: &edges, palette: &palette, metrics: &metrics, camera: spec.camera, on_drag_node, on_connect, on_right_click_node, node_tint: Some(&node_tint), wire_tint: None, wire_dash: None, edge_tint: Some(&edge_tint), edge_dash: None, flujo: spec.flujo, selected: Some(&selected_pred), on_select_node, on_canvas_click: None, on_pan: Some(Arc::new(on_pan)), on_zoom: Some(Arc::new(on_zoom)), // El cockpit universal pinta topología, no contenido: un cuerpo por // nodo lo volvería un lienzo de dominio, que es justo lo que no es. cuerpo: None, }) } /// Traduce el [`Rol`] de dominio de un nodo a su icono en el cockpit. El rol es /// estructural/semántico (activo, stale, huérfano); aquí sólo elige el glifo. fn navkind_de_rol(rol: Rol) -> NavKind { match rol { Rol::Activo => NavKind::Monad, // el nodo en foco: diamante de acento Rol::Stale => NavKind::Other, // desactualizado: punto tenue Rol::Huerfano => NavKind::Other, // colgante: punto tenue Rol::Normal => NavKind::Group, // corriente: cuadrado } } /// Pinta cualquier [`GrafoNavegable`] en el cockpit: mapea sus nodos/aristas a /// [`navigator_dag_view`] y traduce el [`Rol`] de cada nodo a su icono. Es la /// entrada única **dominio → visor universal** (F1): un adaptador de dominio /// implementa el trait y llama aquí, sin saber de layout ni de cámara. El caller /// arma su menú de verbos con `grafo.acciones(seleccion)` + [`grafo_nav::Verbo`] /// y lo dispara desde `on_context`. /// `vertical` elige la orientación del layout (apilado arriba→abajo vs. /// izquierda→derecha, autoequilibrado); combinar con /// [`grafo_fit_camera`] con el mismo flag para el encuadre inicial. /// `posiciones` son overrides de drag manual (sembrar con [`grafo_posiciones`], /// vacío = auto-layout); `on_move(id, dx, dy)` recibe el delta de arrastre en /// coords de canvas para que la app acumule la nueva posición del nodo. #[allow(clippy::too_many_arguments)] pub fn cockpit_grafo_view<'a, G, Msg, FSel, FCtx, FMove, FPan, FZoom>( grafo: &G, camera: Camera, selected: Option, palette: NavPalette, vertical: bool, posiciones: &'a [(NavId, f32, f32)], flujo: f32, on_select: FSel, on_context: Option, on_move: FMove, on_pan: FPan, on_zoom: FZoom, ) -> View where G: GrafoNavegable, Msg: Clone + Send + Sync + 'static, FSel: Fn(NavId) -> Msg + Send + Sync + 'static, FCtx: Fn(NavId) -> Msg + Send + Sync + 'static, FMove: Fn(NavId, f32, f32) -> Option + Send + Sync + 'static, FPan: Fn(DragPhase, f32, f32) -> Option + Send + Sync + 'static, FZoom: Fn(f32) -> Option + Send + Sync + 'static, { let (nodes, edges) = flat_from_grafo(grafo); navigator_dag_view( NavDagSpec { nodes: &nodes, edges: &edges, camera, selected, palette, vertical, posiciones, flujo }, on_select, on_context, on_move, on_pan, on_zoom, ) } /// Glifo del verbo para el menú contextual. fn glifo_verbo(v: Verbo) -> &'static str { match v { Verbo::Saltar => "→", Verbo::Restablecer => "⟳", Verbo::Cercenar => "✂", Verbo::Conciliar => "⑃", } } /// Construye el **menú de verbos** del cockpit a partir de los verbos que el /// dominio declara para la selección (`grafo.acciones(sel)`). Cada item despacha /// su [`Verbo`] vía `on_pick`; *Cercenar* se marca destructivo. Es la pieza que /// traduce el vocabulario compartido en UI: la app abre este menú al recibir el /// `on_context` del cockpit (con el ancla del cursor) y lo cierra con /// `on_dismiss`, sin que el cockpit conozca los `Msg` de cada dominio. pub fn menu_verbos_view( verbos: &[Verbo], anchor: (f32, f32), viewport: (f32, f32), active: usize, on_pick: F, on_dismiss: Msg, theme: &Theme, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(Verbo) -> Msg + Send + Sync + 'static, { let items: Vec = verbos .iter() .map(|v| { let it = ContextMenuItem::action(v.etiqueta()).icon(glifo_verbo(*v)); if *v == Verbo::Cercenar { it.destructive() } else { it } }) .collect(); let verbos_owned: Vec = verbos.to_vec(); context_menu_view(ContextMenuSpec { anchor, viewport, header: None, items, active, on_pick: alloc::sync::Arc::new(move |idx| on_pick(verbos_owned[idx])), on_dismiss, palette: ContextMenuPalette::from_theme(theme), }) } // ===================================================================== // Icono vectorial por clase (para el árbol) // ===================================================================== /// Un mini-canvas con el icono de la clase, tinte `color`. Diamante para /// Mónada, cuadrado para grupo/dir, círculo para archivo. fn kind_icon_view(kind: NavKind, color: Color) -> View { View::new(Style { size: Size { width: llimphi_compositor::llimphi_layout::taffy::prelude::length(ICON_PX), height: llimphi_compositor::llimphi_layout::taffy::prelude::length(ICON_PX), }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .paint_with(move |scene, _ts, rect| { use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Circle, Point, RoundedRect}; use llimphi_compositor::peniko::Fill; if rect.w <= 0.0 || rect.h <= 0.0 { return; } let cx = (rect.x + rect.w * 0.5) as f64; let cy = (rect.y + rect.h * 0.5) as f64; let r = (rect.w.min(rect.h) as f64 * 0.34).max(1.5); match kind { NavKind::Monad => { // Diamante (cuadrado a 45°). let mut p = BezPath::new(); p.move_to(Point::new(cx, cy - r)); p.line_to(Point::new(cx + r, cy)); p.line_to(Point::new(cx, cy + r)); p.line_to(Point::new(cx - r, cy)); p.close_path(); scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p); } NavKind::Group | NavKind::Dir => { let sq = RoundedRect::new(cx - r, cy - r, cx + r, cy + r, 2.0); scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &sq); } NavKind::File | NavKind::Other => { let dot = (rect.w.min(rect.h) as f64 * 0.22).max(1.0); scene.fill( Fill::NonZero, Affine::IDENTITY, color, None, &Circle::new((cx, cy), dot), ); } } }) } #[cfg(test)] mod tests { use super::*; #[derive(Clone, Debug, PartialEq)] enum Msg { Toggle(NavId), Select(NavId), Open(NavId), } fn forest() -> Vec { vec![NavNode::branch( 1, "Mónada src", NavKind::Monad, vec![ NavNode::leaf(11, "lib.rs", NavKind::File), NavNode::leaf(12, "main.rs", NavKind::File), ], )] } #[test] fn navmode_toggle_e_indices() { assert_eq!(NavMode::Tree.toggled(), NavMode::Graph); assert_eq!(NavMode::Graph.toggled(), NavMode::Tree); assert_eq!(NavMode::Tree.index(), 0); assert_eq!(NavMode::from_index(1), NavMode::Graph); assert_eq!(NavMode::from_index(0), NavMode::Tree); } #[test] fn navnode_constructores() { let n = NavNode::leaf(1, "x", NavKind::File); assert!(!n.has_children()); let b = NavNode::branch(2, "y", NavKind::Monad, vec![n]); assert!(b.has_children()); assert_eq!(b.children.len(), 1); } #[test] fn flatten_grafo_respeta_expansion() { let roots = forest(); // Colapsado: sólo la raíz. let collapsed = flatten_for_graph(&roots, &|_| false); assert_eq!(collapsed.len(), 1); assert_eq!(collapsed[0].id, 1); assert!(collapsed[0].parent.is_none()); assert!(collapsed[0].has_children); // Expandido: raíz + 2 hijos, con parent = índice 0. let expanded = flatten_for_graph(&roots, &|id| id == 1); assert_eq!(expanded.len(), 3); assert_eq!(expanded[1].parent, Some(0)); assert_eq!(expanded[2].parent, Some(0)); assert_eq!(expanded[1].depth, 1); } #[test] fn dag_cockpit_construye_con_multiples_padres() { // Un DAG con un merge (nodo 4 con dos padres) — imposible en NavNode. let nodes = vec![ NavFlatNode::new(1, "raíz", NavKind::Monad), NavFlatNode::new(2, "rama A", NavKind::Group), NavFlatNode::new(3, "rama B", NavKind::Group), NavFlatNode::new(4, "merge", NavKind::File), ]; let edges = vec![(1u64, 2u64), (1, 3), (2, 4), (3, 4)]; let _v: View = navigator_dag_view( NavDagSpec { nodes: &nodes, edges: &edges, camera: Camera::default(), selected: Some(1), palette: NavPalette::default(), vertical: false, posiciones: &[], flujo: 0.0, }, Msg::Select, Some(Msg::Open), |_id, _dx, _dy| None, |_phase, _dx, _dy| None, |_dy| None, ); } #[test] fn color_por_nodo_varia_y_es_determinista() { let accent = Color::from_rgba8(80, 140, 240, 255); // Rotar 0° ≈ identidad. let c0 = hue_shift(accent, 0.0); for k in 0..4 { assert!((c0.components[k] - accent.components[k]).abs() < 1e-3); } // Nodos corrientes distintos → colores distintos (varía el matiz). let a = color_de_nodo(NavKind::Group, 1, accent); let b = color_de_nodo(NavKind::Group, 2, accent); assert!(a.components != b.components, "cada nodo varía su matiz"); // Determinista para el mismo índice. assert_eq!( color_de_nodo(NavKind::Group, 5, accent).components, color_de_nodo(NavKind::Group, 5, accent).components ); // El activo lleva el acento pleno. assert_eq!(color_de_nodo(NavKind::Monad, 3, accent).components, accent.components); } #[test] fn grafo_fit_camera_encuadra_y_clampa() { use grafo_nav::{Nodo, NodoId, Verbo}; struct G; impl GrafoNavegable for G { fn nodos(&self) -> Vec { (1..=5).map(|i| Nodo::new(i, "n", Rol::Normal)).collect() } fn aristas(&self) -> Vec<(NodoId, NodoId)> { vec![(1, 2), (2, 3), (3, 4), (4, 5)] } fn acciones(&self, _: Option) -> Vec { vec![Verbo::Saltar] } } let cam = grafo_fit_camera(&G, 800.0, 600.0, false); let cam_v = grafo_fit_camera(&G, 800.0, 600.0, true); assert!(cam_v.zoom >= 0.25 && cam_v.zoom <= 4.0); assert!(cam.zoom >= 0.25 && cam.zoom <= 4.0, "zoom clampeado"); // Cadena de 5 nodos anchos ⇒ hay que alejar para que quepa a lo ancho. assert!(cam.zoom < 1.0, "encuadre reduce el zoom para caber"); } #[test] fn menu_de_verbos_se_construye_desde_las_acciones() { use grafo_nav::Verbo; // Los verbos vendrían de grafo.acciones(sel); aquí un set con Cercenar // (destructivo) para ejercitar el mapeo completo. let verbos = [Verbo::Saltar, Verbo::Restablecer, Verbo::Cercenar]; let _v: View = menu_verbos_view( &verbos, (120.0, 80.0), (800.0, 600.0), usize::MAX, |verbo| match verbo { Verbo::Cercenar => Msg::Open(0), _ => Msg::Select(0), }, Msg::Toggle(0), &Theme::dark(), ); // Glifos definidos para los cuatro verbos. for v in Verbo::TODOS { assert!(!glifo_verbo(v).is_empty()); } } #[test] fn cockpit_grafo_view_pinta_un_grafonavegable() { use grafo_nav::{Nodo, NodoId, Verbo}; // Adaptador mínimo: diamante con merge (nodo 4, dos padres). struct G; impl GrafoNavegable for G { fn nodos(&self) -> Vec { vec![ Nodo::new(1, "raíz", Rol::Normal), Nodo::new(2, "A", Rol::Normal), Nodo::new(3, "B", Rol::Stale), Nodo::new(4, "merge", Rol::Activo), ] } fn aristas(&self) -> Vec<(NodoId, NodoId)> { vec![(1, 2), (1, 3), (2, 4), (3, 4)] } fn acciones(&self, sel: Option) -> Vec { if sel.is_some() { vec![Verbo::Saltar, Verbo::Cercenar] } else { vec![Verbo::Saltar] } } } let g = G; let _v: View = cockpit_grafo_view( &g, Camera::default(), Some(4), NavPalette::default(), true, // vertical &[], // sin overrides de posición 0.0, // sin flujo Msg::Select, Some(Msg::Open), |_id, _dx, _dy| None, |_p, _dx, _dy| None, |_dy| None, ); // El trait deriva la estructura sin que el adaptador la calcule. assert_eq!(g.raices(), vec![1]); assert_eq!(g.cabezas(), vec![4]); } #[test] fn navigator_view_construye_en_ambos_modos() { // No paniquea construyendo el View en cada modo (smoke). let roots = forest(); let palette = NavPalette::default(); for mode in [NavMode::Tree, NavMode::Graph] { let _v: View = navigator_view( NavSpec { roots: &roots, mode, selected: Some(1), palette, guides: true, }, |id| id == 1, Msg::Toggle, Msg::Select, Some(Msg::Open), ); } } }