tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main widgets/navigator/src/lib.rs Rust · 1285 líneas · 43.7 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1//! `llimphi-widget-navigator` — navegador **data-agnóstico** de nodos en
2//! dos modos conmutables: **árbol** (`tree`) y **grafo** (`nodegraph`).
3//!
4//! Nació para que `pata` muestre las **Mónadas** de nouser y sus archivos
5//! en un sidebar, pero el widget no sabe de nouser: recibe un bosque de
6//! [`NavNode`]s (id opaco + label + [`NavKind`] + hijos) y emite `Msg`s al
7//! interactuar. El caller mapea cada `id` a lo suyo (un `MonadId`, un path)
8//! y decide qué hacer al seleccionar/abrir.
9//!
10//! Igual que el resto de widgets Llimphi, es **render-only y stateless**:
11//! el estado (qué nodos están expandidos, cuál está seleccionado, en qué
12//! modo está) vive en el `Model` del App; el widget sólo pinta y avisa.
13//!
14//! - **Árbol** — reusa [`llimphi_widget_tree`]. El navegador aplana el
15//!   bosque respetando `is_expanded`, dibuja un icono por [`NavKind`] entre
16//!   el chevron y el label, y cablea toggle / select / context por fila.
17//! - **Grafo** — reusa [`llimphi_widget_nodegraph`]. Coloca los nodos
18//!   visibles en columnas por profundidad, con cables de **contención**
19//!   (padre→hijo). El nodo seleccionado se resalta; arrastrar un nodo lo
20//!   selecciona; el right-click abre el menú contextual.
21//!
22//! ```ignore
23//! navigator_view(
24//!     NavSpec { roots: &model.nodes, mode: model.mode,
25//!               selected: model.selected, palette, guides: true },
26//!     |id| model.expanded.contains(&id),
27//!     Msg::Toggle, Msg::Select, Some(Msg::Open),
28//! )
29//! ```
30
31#![forbid(unsafe_code)]
32
33
34// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
35// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
36// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
37#![no_std]
38
39extern crate alloc;
40
41use alloc::boxed::Box;
42use alloc::string::String;
43use alloc::vec::Vec;
44use alloc::vec;
45use alloc::format;
46// `hashbrown` ES el `HashMap` de std por dentro: en Linux no cambia nada, y
47// sin sistema operativo es lo único que existe.
48use hashbrown::{HashMap, HashSet};
49use alloc::sync::Arc;
50
51use llimphi_compositor::llimphi_layout::taffy::{
52    prelude::{Size, Style},
53    AlignItems, JustifyContent,
54};
55use llimphi_compositor::peniko::Color;
56use llimphi_compositor::{DragPhase, View};
57use llimphi_theme::Theme;
58use llimphi_widget_nodegraph::{
59    cono_descendente, fit_to_view, layout_por_rango, nodegraph_view_styled, nodegraph_view_spec,
60    Camera, Edge, LayoutRango, NodeId, NodeSpec, NodeTint, NodegraphMetrics, NodegraphPalette,
61    NodegraphSpec, Wire,
62};
63use llimphi_widget_tree::{tree_view, TreePalette, TreeRow, TreeSpec};
64use llimphi_widget_context_menu::{
65    context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
66};
67use grafo_nav::{GrafoNavegable, Rol, Verbo};
68
69/// Identificador opaco de un nodo. El caller lo asigna y lo recibe de vuelta
70/// sin que el widget lo interprete (típicamente un índice a su propio mapa
71/// `id → MonadId | PathBuf`).
72pub type NavId = u64;
73
74/// Naturaleza de un nodo — sólo para elegir su icono y tinte. El widget no
75/// asume semántica de dominio más allá de esto.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum NavKind {
78    /// Una Mónada (cluster semántico de nouser). Diamante de acento.
79    Monad,
80    /// Una agrupación intermedia (carpeta lógica, categoría). Cuadrado.
81    Group,
82    /// Un directorio del filesystem. Cuadrado tenue.
83    Dir,
84    /// Un archivo hoja. Punto.
85    File,
86    /// Cualquier otra cosa. Punto tenue.
87    Other,
88}
89
90/// Un nodo del bosque que el navegador pinta. La jerarquía es explícita
91/// (`children`); el navegador la aplana según el estado de expansión.
92#[derive(Debug, Clone)]
93pub struct NavNode {
94    pub id: NavId,
95    pub label: String,
96    pub kind: NavKind,
97    pub children: Vec<NavNode>,
98}
99
100impl NavNode {
101    /// Un nodo hoja (sin hijos).
102    pub fn leaf(id: NavId, label: impl Into<String>, kind: NavKind) -> Self {
103        Self {
104            id,
105            label: label.into(),
106            kind,
107            children: Vec::new(),
108        }
109    }
110
111    /// Un nodo con hijos.
112    pub fn branch(
113        id: NavId,
114        label: impl Into<String>,
115        kind: NavKind,
116        children: Vec<NavNode>,
117    ) -> Self {
118        Self {
119            id,
120            label: label.into(),
121            kind,
122            children,
123        }
124    }
125
126    /// `true` si tiene al menos un hijo.
127    pub fn has_children(&self) -> bool {
128        !self.children.is_empty()
129    }
130}
131
132/// Modo de visualización del navegador.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum NavMode {
135    /// Árbol indentado con expand/collapse.
136    Tree,
137    /// Grafo de nodos con cables de contención.
138    Graph,
139}
140
141impl NavMode {
142    /// Etiquetas para un control segmentado (en el mismo orden que
143    /// [`NavMode::index`] / [`NavMode::from_index`]).
144    pub const LABELS: [&'static str; 2] = ["Árbol", "Grafo"];
145
146    /// El otro modo (para un botón de toggle simple).
147    pub fn toggled(self) -> Self {
148        match self {
149            NavMode::Tree => NavMode::Graph,
150            NavMode::Graph => NavMode::Tree,
151        }
152    }
153
154    /// Índice 0/1 — para alimentar un control segmentado.
155    pub fn index(self) -> usize {
156        match self {
157            NavMode::Tree => 0,
158            NavMode::Graph => 1,
159        }
160    }
161
162    /// Recupera el modo desde un índice de control segmentado (≥1 = grafo).
163    pub fn from_index(i: usize) -> Self {
164        if i == 0 {
165            NavMode::Tree
166        } else {
167            NavMode::Graph
168        }
169    }
170}
171
172/// Paleta del navegador: hereda las de tree y nodegraph del [`Theme`]
173/// semántico, más los tintes por [`NavKind`] para los iconos.
174#[derive(Debug, Clone, Copy)]
175pub struct NavPalette {
176    pub tree: TreePalette,
177    pub graph: NodegraphPalette,
178    pub accent: Color,
179    pub monad: Color,
180    pub group: Color,
181    pub dir: Color,
182    pub file: Color,
183    pub other: Color,
184}
185
186impl Default for NavPalette {
187    fn default() -> Self {
188        Self::from_theme(&Theme::dark())
189    }
190}
191
192impl NavPalette {
193    pub fn from_theme(t: &Theme) -> Self {
194        Self {
195            tree: TreePalette::from_theme(t),
196            graph: NodegraphPalette::from_theme(t),
197            accent: t.accent,
198            monad: t.accent,
199            group: t.fg_text,
200            dir: t.fg_muted,
201            file: t.fg_text,
202            other: t.fg_muted,
203        }
204    }
205
206    /// El color del icono de un nodo según su clase.
207    pub fn kind_color(&self, kind: NavKind) -> Color {
208        match kind {
209            NavKind::Monad => self.monad,
210            NavKind::Group => self.group,
211            NavKind::Dir => self.dir,
212            NavKind::File => self.file,
213            NavKind::Other => self.other,
214        }
215    }
216}
217
218/// Lo que el navegador necesita saber para pintar, sin los callbacks.
219pub struct NavSpec<'a> {
220    /// Las raíces del bosque a mostrar.
221    pub roots: &'a [NavNode],
222    /// Modo activo.
223    pub mode: NavMode,
224    /// Nodo seleccionado (resaltado en ambos modos). `None` = ninguno.
225    pub selected: Option<NavId>,
226    /// Paleta.
227    pub palette: NavPalette,
228    /// Dibujar líneas guía de indentación en el árbol.
229    pub guides: bool,
230}
231
232/// Alto de fila del árbol / paso vertical del grafo.
233const ROW_H: f32 = 24.0;
234/// Tamaño del icono de clase (px).
235const ICON_PX: f32 = 14.0;
236
237/// Compone el navegador. Los callbacks se identifican por [`NavId`]:
238/// - `is_expanded(id)` → si un nodo rama está abierto (sólo árbol);
239/// - `on_toggle(id)` → al click en el chevron (sólo árbol);
240/// - `on_select(id)` → al click en la fila (árbol) o al arrastrar el nodo
241///   (grafo);
242/// - `on_context(id)` → al right-click (ambos modos); `None` = sin menú.
243pub fn navigator_view<Msg, FExp, FTog, FSel, FCtx>(
244    spec: NavSpec,
245    is_expanded: FExp,
246    on_toggle: FTog,
247    on_select: FSel,
248    on_context: Option<FCtx>,
249) -> View<Msg>
250where
251    Msg: Clone + Send + Sync + 'static,
252    FExp: Fn(NavId) -> bool,
253    FTog: Fn(NavId) -> Msg,
254    FSel: Fn(NavId) -> Msg + Send + Sync + 'static,
255    FCtx: Fn(NavId) -> Msg,
256{
257    match spec.mode {
258        NavMode::Tree => tree_mode(spec, is_expanded, on_toggle, on_select, on_context),
259        NavMode::Graph => graph_mode(spec, is_expanded, on_select, on_context),
260    }
261}
262
263// =====================================================================
264// Árbol
265// =====================================================================
266
267fn tree_mode<Msg, FExp, FTog, FSel, FCtx>(
268    spec: NavSpec,
269    is_expanded: FExp,
270    on_toggle: FTog,
271    on_select: FSel,
272    on_context: Option<FCtx>,
273) -> View<Msg>
274where
275    Msg: Clone + Send + Sync + 'static,
276    FExp: Fn(NavId) -> bool,
277    FTog: Fn(NavId) -> Msg,
278    FSel: Fn(NavId) -> Msg,
279    FCtx: Fn(NavId) -> Msg,
280{
281    let mut rows: Vec<TreeRow<Msg>> = Vec::new();
282    for root in spec.roots {
283        push_rows(
284            root,
285            0,
286            &spec,
287            &is_expanded,
288            &on_toggle,
289            &on_select,
290            &on_context,
291            &mut rows,
292        );
293    }
294    tree_view(TreeSpec {
295        rows,
296        row_height: ROW_H,
297        indent_px: 14.0,
298        palette: spec.palette.tree,
299        guides: spec.guides,
300    })
301}
302
303#[allow(clippy::too_many_arguments)]
304fn push_rows<Msg, FExp, FTog, FSel, FCtx>(
305    node: &NavNode,
306    depth: usize,
307    spec: &NavSpec,
308    is_expanded: &FExp,
309    on_toggle: &FTog,
310    on_select: &FSel,
311    on_context: &Option<FCtx>,
312    out: &mut Vec<TreeRow<Msg>>,
313) where
314    Msg: Clone + Send + Sync + 'static,
315    FExp: Fn(NavId) -> bool,
316    FTog: Fn(NavId) -> Msg,
317    FSel: Fn(NavId) -> Msg,
318    FCtx: Fn(NavId) -> Msg,
319{
320    let has_children = node.has_children();
321    let expanded = has_children && is_expanded(node.id);
322    let icon = kind_icon_view::<Msg>(node.kind, spec.palette.kind_color(node.kind));
323    let mut row = TreeRow::new(
324        node.label.clone(),
325        depth,
326        has_children,
327        expanded,
328        spec.selected == Some(node.id),
329        on_toggle(node.id),
330        on_select(node.id),
331    )
332    .with_icon(icon);
333    if let Some(ctx) = on_context.as_ref().map(|f| f(node.id)) {
334        row = row.with_context(ctx);
335    }
336    out.push(row);
337
338    if expanded {
339        for child in &node.children {
340            push_rows(
341                child, depth + 1, spec, is_expanded, on_toggle, on_select, on_context, out,
342            );
343        }
344    }
345}
346
347// =====================================================================
348// Grafo
349// =====================================================================
350
351/// Un nodo visible aplanado para el grafo: su id, su label/kind y la posición
352/// (índice) de su padre en la lista (`None` = raíz).
353struct FlatNode {
354    id: NavId,
355    label: String,
356    kind: NavKind,
357    depth: usize,
358    parent: Option<usize>,
359    has_children: bool,
360}
361
362fn flatten_for_graph<FExp: Fn(NavId) -> bool>(
363    roots: &[NavNode],
364    is_expanded: &FExp,
365) -> Vec<FlatNode> {
366    let mut out = Vec::new();
367    for root in roots {
368        walk_graph(root, 0, None, is_expanded, &mut out);
369    }
370    out
371}
372
373fn walk_graph<FExp: Fn(NavId) -> bool>(
374    node: &NavNode,
375    depth: usize,
376    parent: Option<usize>,
377    is_expanded: &FExp,
378    out: &mut Vec<FlatNode>,
379) {
380    let has_children = node.has_children();
381    let me = out.len();
382    out.push(FlatNode {
383        id: node.id,
384        label: node.label.clone(),
385        kind: node.kind,
386        depth,
387        parent,
388        has_children,
389    });
390    if has_children && is_expanded(node.id) {
391        for child in &node.children {
392            walk_graph(child, depth + 1, Some(me), is_expanded, out);
393        }
394    }
395}
396
397fn graph_mode<Msg, FExp, FSel, FCtx>(
398    spec: NavSpec,
399    is_expanded: FExp,
400    on_select: FSel,
401    on_context: Option<FCtx>,
402) -> View<Msg>
403where
404    Msg: Clone + Send + Sync + 'static,
405    FExp: Fn(NavId) -> bool,
406    FSel: Fn(NavId) -> Msg + Send + Sync + 'static,
407    FCtx: Fn(NavId) -> Msg,
408{
409    let flat = flatten_for_graph(spec.roots, &is_expanded);
410    let metrics = NodegraphMetrics {
411        node_width: 150.0,
412        ..NodegraphMetrics::default()
413    };
414
415    // Layout: columna por profundidad, una fila por nodo visible.
416    const MARGIN: f32 = 24.0;
417    const COL_GAP: f32 = 36.0;
418    const ROW_GAP: f32 = 12.0;
419    let node_h = metrics.node_height(1, 1);
420    let col_w = metrics.node_width + COL_GAP;
421
422    let mut nodes: Vec<NodeSpec> = Vec::with_capacity(flat.len());
423    let mut wires: Vec<Wire> = Vec::new();
424    let ids: Vec<NavId> = flat.iter().map(|f| f.id).collect();
425
426    for (i, f) in flat.iter().enumerate() {
427        let inputs = if f.parent.is_some() {
428            vec![String::new()]
429        } else {
430            Vec::new()
431        };
432        let outputs = if f.has_children {
433            vec![String::new()]
434        } else {
435            Vec::new()
436        };
437        // Prefijo del icono en el label (el nodegraph no tiene slot de icono;
438        // un glifo simple por clase basta para distinguirlos de un vistazo).
439        let label = format!("{} {}", kind_glyph(f.kind), f.label);
440        nodes.push(NodeSpec {
441            id: i as NodeId,
442            label,
443            x: MARGIN + f.depth as f32 * col_w,
444            y: MARGIN + i as f32 * (node_h + ROW_GAP),
445            inputs,
446            outputs,
447        });
448        if let Some(p) = f.parent {
449            wires.push(Wire {
450                from_node: p as NodeId,
451                from_output: 0,
452                to_node: i as NodeId,
453                to_input: 0,
454            });
455        }
456    }
457
458    // Arrastrar un nodo lo selecciona (al soltar). El grafo no reposiciona
459    // por arrastre — el layout es derivado, no editable.
460    let drag_ids = ids.clone();
461    let on_drag = move |id: NodeId, phase: DragPhase, _dx: f32, _dy: f32| match phase {
462        DragPhase::End => drag_ids
463            .get(id as usize)
464            .map(|nav_id| on_select(*nav_id)),
465        DragPhase::Move => None,
466    };
467    // Sin conexiones: la contención es fija.
468    let on_connect = |_: NodeId, _: u16, _: NodeId, _: u16| None;
469
470    // Right-click → menú contextual (evaluado en build, por nodo).
471    let ctx_ids = &ids;
472    let on_right: Option<Box<dyn Fn(NodeId) -> Option<Msg>>> = on_context.map(|f| {
473        let f = move |id: NodeId| ctx_ids.get(id as usize).map(|nav_id| f(*nav_id));
474        Box::new(f) as Box<dyn Fn(NodeId) -> Option<Msg>>
475    });
476
477    // Resaltado del nodo seleccionado.
478    let sel_idx = spec
479        .selected
480        .and_then(|sid| ids.iter().position(|id| *id == sid));
481    let accent = spec.palette.accent;
482    let tint = move |id: NodeId| -> Option<NodeTint> {
483        if Some(id as usize) == sel_idx {
484            Some(NodeTint {
485                bg_title: Some(accent),
486                ..NodeTint::default()
487            })
488        } else {
489            None
490        }
491    };
492
493    nodegraph_view_styled(
494        &nodes,
495        &wires,
496        &spec.palette.graph,
497        &metrics,
498        on_drag,
499        on_connect,
500        on_right,
501        Some(&tint as &dyn Fn(NodeId) -> Option<NodeTint>),
502        None,
503    )
504}
505
506/// Glifo ASCII-ish por clase para el label del grafo.
507fn kind_glyph(kind: NavKind) -> &'static str {
508    match kind {
509        NavKind::Monad => "◈",
510        NavKind::Group => "▣",
511        NavKind::Dir => "▸",
512        NavKind::File => "·",
513        NavKind::Other => "·",
514    }
515}
516
517// =====================================================================
518// Cockpit de DAG — grafo general (múltiples padres) + cámara navegable
519// =====================================================================
520
521/// Un nodo **plano** de un DAG general. A diferencia de [`NavNode`] (un árbol
522/// de contención, un solo padre), aquí la topología va aparte en `edges`, así un
523/// nodo puede tener **varios padres** (un merge de commits, un mensaje que
524/// responde a dos, una celda con dos dependencias). El id sigue siendo opaco.
525#[derive(Debug, Clone)]
526pub struct NavFlatNode {
527    pub id: NavId,
528    pub label: String,
529    pub kind: NavKind,
530}
531
532impl NavFlatNode {
533    pub fn new(id: NavId, label: impl Into<String>, kind: NavKind) -> Self {
534        Self { id, label: label.into(), kind }
535    }
536}
537
538/// Lo que el cockpit necesita para pintar un DAG general, sin los callbacks.
539pub struct NavDagSpec<'a> {
540    /// Los nodos del grafo (lista plana).
541    pub nodes: &'a [NavFlatNode],
542    /// Aristas dirigidas `(from, to)` por id. Ids ausentes se ignoran.
543    pub edges: &'a [(NavId, NavId)],
544    /// Cámara (pan/zoom). El caller la guarda en su `Model`; usar
545    /// [`llimphi_widget_nodegraph::fit_to_view`] para el encuadre inicial.
546    pub camera: Camera,
547    /// Nodo seleccionado: borde de acento + resaltado de su **cono descendente**
548    /// (lo que se toca al *cercenar*). `None` = ninguno.
549    pub selected: Option<NavId>,
550    /// Paleta.
551    pub palette: NavPalette,
552    /// `true` = layout apilado de arriba hacia abajo (rangos en Y), aristas
553    /// verticales; `false` = de izquierda a derecha. Autoequilibrado en ambos.
554    pub vertical: bool,
555    /// Overrides de posición `(id, x, y)` (canvas) para nodos **arrastrados a
556    /// mano**. Los ids presentes usan su posición dada; el resto cae al
557    /// auto-layout. Vacío = todo automático. Sembrar con [`grafo_posiciones`].
558    pub posiciones: &'a [(NavId, f32, f32)],
559    /// Fase 0..1 del flujo animado de las aristas. `0.0` = sin flujo. La app la
560    /// avanza con un tick (`Handle::spawn_periodic`).
561    pub flujo: f32,
562}
563
564/// Métricas del cockpit de DAG, compartidas por el render y el encuadre. Nodos
565/// algo anchos para las etiquetas y `wire_nearest_side` para que en un grafo de
566/// relaciones los cables enganchen por el lado que mira al otro nodo (no dan la
567/// vuelta).
568fn dag_metrics(vertical: bool) -> NodegraphMetrics {
569    NodegraphMetrics {
570        node_width: 168.0,
571        wire_nearest_side: true,
572        node_elevacion: 14.0,
573        vertical,
574        ..NodegraphMetrics::default()
575    }
576}
577
578// ── Color: variación de matiz por nodo/arista desde el acento ────────────────
579
580fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
581    let max = r.max(g).max(b);
582    let min = r.min(g).min(b);
583    let l = (max + min) * 0.5;
584    let d = max - min;
585    if d.abs() < 1e-6 {
586        return (0.0, 0.0, l);
587    }
588    let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
589    let h = if max == r {
590        (g - b) / d + if g < b { 6.0 } else { 0.0 }
591    } else if max == g {
592        (b - r) / d + 2.0
593    } else {
594        (r - g) / d + 4.0
595    };
596    (h * 60.0, s, l)
597}
598
599fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
600    if s.abs() < 1e-6 {
601        return (l, l, l);
602    }
603    let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
604    let p = 2.0 * l - q;
605    let hk = h / 360.0;
606    let tc = |mut t: f32| {
607        if t < 0.0 {
608            t += 1.0;
609        }
610        if t > 1.0 {
611            t -= 1.0;
612        }
613        if t < 1.0 / 6.0 {
614            p + (q - p) * 6.0 * t
615        } else if t < 0.5 {
616            q
617        } else if t < 2.0 / 3.0 {
618            p + (q - p) * (2.0 / 3.0 - t) * 6.0
619        } else {
620            p
621        }
622    };
623    (tc(hk + 1.0 / 3.0), tc(hk), tc(hk - 1.0 / 3.0))
624}
625
626/// Rota el matiz de `base` `deg` grados (HSL), conservando saturación, luz y
627/// alfa. Base de la variación de color por nodo/arista.
628fn hue_shift(base: Color, deg: f32) -> Color {
629    let [r, g, b, a] = base.components;
630    let (h, s, l) = rgb_to_hsl(r, g, b);
631    let (nr, ng, nb) = hsl_to_rgb((h + deg).rem_euclid(360.0), s, l);
632    Color { components: [nr, ng, nb, a], ..base }
633}
634
635/// Baja la saturación de `base` por `factor` (0..1). Para nodos apagados
636/// (stale/huérfano).
637fn desaturar(base: Color, factor: f32) -> Color {
638    let [r, g, b, a] = base.components;
639    let (h, s, l) = rgb_to_hsl(r, g, b);
640    let (nr, ng, nb) = hsl_to_rgb(h, s * factor, l);
641    Color { components: [nr, ng, nb, a], ..base }
642}
643
644/// Sube la luz de `base` por `amt` (aclara). Para resaltar el cono.
645fn aclarar(base: Color, amt: f32) -> Color {
646    let [r, g, b, a] = base.components;
647    let (h, s, l) = rgb_to_hsl(r, g, b);
648    let (nr, ng, nb) = hsl_to_rgb(h, s, (l + amt).clamp(0.0, 1.0));
649    Color { components: [nr, ng, nb, a], ..base }
650}
651
652/// El color del título de un nodo según su clase e índice: el nodo activo lleva
653/// el acento pleno, los apagados van desaturados, y los corrientes **varían su
654/// matiz** por índice (paso ~43° para separar hues vecinos).
655fn color_de_nodo(kind: NavKind, id: NodeId, accent: Color) -> Color {
656    match kind {
657        NavKind::Monad => accent,
658        NavKind::Other => desaturar(accent, 0.5),
659        _ => hue_shift(accent, (id as f32) * 43.0),
660    }
661}
662
663/// Mapea un [`GrafoNavegable`] a nodos planos + aristas del cockpit. Único punto
664/// de traducción trait→visor, así el render y el encuadre coinciden exactos.
665fn flat_from_grafo<G: GrafoNavegable>(grafo: &G) -> (Vec<NavFlatNode>, Vec<(NavId, NavId)>) {
666    let nodes = grafo
667        .nodos()
668        .into_iter()
669        .map(|n| NavFlatNode { id: n.id, label: n.etiqueta, kind: navkind_de_rol(n.rol) })
670        .collect();
671    let edges = grafo.aristas();
672    (nodes, edges)
673}
674
675/// Posiciona los nodos planos por rango topológico y devuelve los `NodeSpec`
676/// listos para pintar + las aristas en espacio de índices. Compartido por
677/// [`navigator_dag_view`] y [`grafo_fit_camera`] para que el encuadre calce con
678/// lo que se dibuja.
679fn dag_positioned_nodes(
680    nodes: &[NavFlatNode],
681    edges: &[(NavId, NavId)],
682    metrics: &NodegraphMetrics,
683    vertical: bool,
684    overrides: &[(NavId, f32, f32)],
685) -> (Vec<NodeSpec>, Vec<(NodeId, NodeId)>) {
686    let index: HashMap<NavId, NodeId> =
687        nodes.iter().enumerate().map(|(i, n)| (n.id, i as NodeId)).collect();
688    let edges_ii: Vec<(NodeId, NodeId)> = edges
689        .iter()
690        .filter_map(|(f, t)| Some((*index.get(f)?, *index.get(t)?)))
691        .collect();
692    let ids: Vec<NodeId> = (0..nodes.len() as NodeId).collect();
693    let pos = layout_por_rango(&ids, &edges_ii, metrics, LayoutRango { vertical, ..LayoutRango::default() });
694    let posmap: HashMap<NodeId, (f32, f32)> =
695        pos.iter().map(|p| (p.id, (p.x, p.y))).collect();
696    let mut specs: Vec<NodeSpec> = nodes
697        .iter()
698        .enumerate()
699        .map(|(i, n)| {
700            let (x, y) = posmap.get(&(i as NodeId)).copied().unwrap_or((0.0, 0.0));
701            NodeSpec {
702                id: i as NodeId,
703                label: format!("{} {}", kind_glyph(n.kind), n.label),
704                x,
705                y,
706                inputs: Vec::new(),
707                outputs: Vec::new(),
708            }
709        })
710        .collect();
711    // Overrides de drag manual: pisan la posición del auto-layout.
712    for &(navid, x, y) in overrides {
713        if let Some(&ci) = index.get(&navid) {
714            if let Some(s) = specs.get_mut(ci as usize) {
715                s.x = x;
716                s.y = y;
717            }
718        }
719    }
720    (specs, edges_ii)
721}
722
723/// Cámara que **encuadra** el grafo de un [`GrafoNavegable`] en un viewport dado,
724/// con el mismo layout que dibuja [`cockpit_grafo_view`]. Usar en el `init` de la
725/// app para que el DAG aparezca centrado y a escala en vez de arrancar en una
726/// esquina con la cámara identidad.
727pub fn grafo_fit_camera<G: GrafoNavegable>(
728    grafo: &G,
729    viewport_w: f32,
730    viewport_h: f32,
731    vertical: bool,
732) -> Camera {
733    let (nodes, edges) = flat_from_grafo(grafo);
734    let metrics = dag_metrics(vertical);
735    let (specs, _) = dag_positioned_nodes(&nodes, &edges, &metrics, vertical, &[]);
736    fit_to_view(&specs, &metrics, viewport_w, viewport_h)
737}
738
739/// Devuelve la posición `(id, x, y)` que el **auto-layout** asigna a cada nodo
740/// del grafo, en la orientación dada. Sirve para **sembrar** las posiciones
741/// iniciales de un cockpit con drag manual (`NavDagSpec::posiciones`): la app
742/// arranca con estas y luego actualiza la del nodo arrastrado.
743pub fn grafo_posiciones<G: GrafoNavegable>(grafo: &G, vertical: bool) -> Vec<(NavId, f32, f32)> {
744    let (nodes, edges) = flat_from_grafo(grafo);
745    let metrics = dag_metrics(vertical);
746    let (specs, _) = dag_positioned_nodes(&nodes, &edges, &metrics, vertical, &[]);
747    nodes
748        .iter()
749        .zip(specs.iter())
750        .map(|(n, s)| (n.id, s.x, s.y))
751        .collect()
752}
753
754/// Compone el **cockpit de DAG**: un grafo general navegable sobre
755/// [`nodegraph_view_spec`] (pan/zoom/selección reales), con auto-layout por
756/// rango topológico ([`layout_por_rango`]) y aristas simples ([`Edge`]) — sin
757/// pins dummy. Es el visor universal de DAGs; el caller mapea cada [`NavId`] a
758/// su dominio (un commit, un mensaje, una Mónada) y decide los verbos.
759///
760/// No sustituye a [`navigator_view`] (árbol ↔ grafo de contención); es la
761/// entrada para grafos con múltiples padres que aquél no puede expresar.
762///
763/// - `on_select(id)` — click en un nodo (o soltar su arrastre);
764/// - `on_context(id)` — right-click sobre el nodo (paleta de verbos); `None`;
765/// - `on_pan(phase, dx, dy)` — arrastre del fondo;
766/// - `on_zoom(dy)` — rueda (resolver con `zoom_step`/`zoom_at_center`).
767#[allow(clippy::too_many_arguments)]
768pub fn navigator_dag_view<Msg, FSel, FCtx, FMove, FPan, FZoom>(
769    spec: NavDagSpec,
770    on_select: FSel,
771    on_context: Option<FCtx>,
772    on_move: FMove,
773    on_pan: FPan,
774    on_zoom: FZoom,
775) -> View<Msg>
776where
777    Msg: Clone + Send + Sync + 'static,
778    FSel: Fn(NavId) -> Msg + Send + Sync + 'static,
779    FCtx: Fn(NavId) -> Msg + Send + Sync + 'static,
780    FMove: Fn(NavId, f32, f32) -> Option<Msg> + Send + Sync + 'static,
781    FPan: Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
782    FZoom: Fn(f32) -> Option<Msg> + Send + Sync + 'static,
783{
784    let metrics = dag_metrics(spec.vertical);
785
786    // id de dominio → índice compacto (NodeId), y su inverso para los callbacks.
787    let index: HashMap<NavId, NodeId> = spec
788        .nodes
789        .iter()
790        .enumerate()
791        .map(|(i, n)| (n.id, i as NodeId))
792        .collect();
793    let nav_ids: Vec<NavId> = spec.nodes.iter().map(|n| n.id).collect();
794
795    // Nodos posicionados por rango topológico + aristas en espacio de índices
796    // (mismo helper que usa el encuadre, para que calcen).
797    let (nodes, edges_ii) =
798        dag_positioned_nodes(spec.nodes, spec.edges, &metrics, spec.vertical, spec.posiciones);
799    let edges: Vec<Edge> = edges_ii
800        .iter()
801        .map(|(f, t)| Edge { from_node: *f, to_node: *t })
802        .collect();
803
804    // Selección + cono descendente resaltado (previsualiza qué toca cercenar).
805    let sel_idx: Option<NodeId> = spec.selected.and_then(|s| index.get(&s).copied());
806    let cone: HashSet<NodeId> = match sel_idx {
807        Some(s) => cono_descendente(&edges_ii, s).into_iter().collect(),
808        None => HashSet::new(),
809    };
810    let accent = spec.palette.accent;
811
812    // Callbacks. `on_select` se comparte entre click-en-nodo y fin-de-arrastre.
813    let on_select: Arc<dyn Fn(NavId) -> Msg + Send + Sync> = Arc::new(on_select);
814
815    // Arrastre: el delta (ya en coords de canvas) va a `on_move`, y al soltar
816    // se selecciona. La app acumula la posición (sembrada con `grafo_posiciones`).
817    let move_h: Arc<dyn Fn(NavId, f32, f32) -> Option<Msg> + Send + Sync> = Arc::new(on_move);
818    let drag_ids = nav_ids.clone();
819    let sel_drag = on_select.clone();
820    let move_drag = move_h.clone();
821    let on_drag_node: Arc<
822        dyn Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync,
823    > = Arc::new(move |id, phase, dx, dy| {
824        let nid = *drag_ids.get(id as usize)?;
825        match phase {
826            DragPhase::Move => (move_drag)(nid, dx, dy),
827            DragPhase::End => Some((sel_drag)(nid)),
828        }
829    });
830
831    let on_connect: Arc<
832        dyn Fn(NodeId, u16, NodeId, u16) -> Option<Msg> + Send + Sync,
833    > = Arc::new(|_, _, _, _| None);
834
835    let sel_click = on_select.clone();
836    let click_ids = nav_ids.clone();
837    let on_select_node: Option<Arc<dyn Fn(NodeId) -> Option<Msg> + Send + Sync>> =
838        Some(Arc::new(move |id| {
839            click_ids.get(id as usize).map(|nid| (sel_click)(*nid))
840        }));
841
842    let on_right_click_node: Option<Arc<dyn Fn(NodeId) -> Option<Msg> + Send + Sync>> =
843        on_context.map(|f| {
844            let ctx_ids = nav_ids.clone();
845            Arc::new(move |id: NodeId| ctx_ids.get(id as usize).map(|nid| f(*nid)))
846                as Arc<dyn Fn(NodeId) -> Option<Msg> + Send + Sync>
847        });
848
849    // Estilo. Cada nodo lleva un color de título DERIVADO del acento y variado
850    // por su índice/clase; cada arista hereda el color de su nodo origen. El cono
851    // descendente de la selección se aclara (nodos) y va al acento (aristas) para
852    // previsualizar qué toca cercenar; el seleccionado lleva borde de acento.
853    let kinds: Vec<NavKind> = spec.nodes.iter().map(|n| n.kind).collect();
854    let kinds_nodo = kinds.clone();
855    let cone_nodes = cone.clone();
856    let node_tint = move |id: NodeId| -> Option<NodeTint> {
857        let kind = kinds_nodo.get(id as usize).copied().unwrap_or(NavKind::Other);
858        let mut title = color_de_nodo(kind, id, accent);
859        if cone_nodes.contains(&id) && Some(id) != sel_idx {
860            title = aclarar(title, 0.14);
861        }
862        Some(NodeTint { bg_title: Some(title), ..NodeTint::default() })
863    };
864    let selected_pred = move |id: NodeId| Some(id) == sel_idx;
865    let cone_edges = cone;
866    let kinds_edge = kinds;
867    let edge_tint = move |e: &Edge| -> Option<Color> {
868        if cone_edges.contains(&e.from_node) && cone_edges.contains(&e.to_node) {
869            return Some(accent);
870        }
871        let kind = kinds_edge.get(e.from_node as usize).copied().unwrap_or(NavKind::Group);
872        Some(color_de_nodo(kind, e.from_node, accent))
873    };
874
875    let palette = spec.palette.graph;
876    nodegraph_view_spec(NodegraphSpec {
877        nodes: &nodes,
878        wires: &[],
879        edges: &edges,
880        palette: &palette,
881        metrics: &metrics,
882        camera: spec.camera,
883        on_drag_node,
884        on_connect,
885        on_right_click_node,
886        node_tint: Some(&node_tint),
887        wire_tint: None,
888        wire_dash: None,
889        edge_tint: Some(&edge_tint),
890        edge_dash: None,
891        flujo: spec.flujo,
892        selected: Some(&selected_pred),
893        on_select_node,
894        on_canvas_click: None,
895        on_pan: Some(Arc::new(on_pan)),
896        on_zoom: Some(Arc::new(on_zoom)),
897        // El cockpit universal pinta topología, no contenido: un cuerpo por
898        // nodo lo volvería un lienzo de dominio, que es justo lo que no es.
899        cuerpo: None,
900    })
901}
902
903/// Traduce el [`Rol`] de dominio de un nodo a su icono en el cockpit. El rol es
904/// estructural/semántico (activo, stale, huérfano); aquí sólo elige el glifo.
905fn navkind_de_rol(rol: Rol) -> NavKind {
906    match rol {
907        Rol::Activo => NavKind::Monad,   // el nodo en foco: diamante de acento
908        Rol::Stale => NavKind::Other,    // desactualizado: punto tenue
909        Rol::Huerfano => NavKind::Other, // colgante: punto tenue
910        Rol::Normal => NavKind::Group,   // corriente: cuadrado
911    }
912}
913
914/// Pinta cualquier [`GrafoNavegable`] en el cockpit: mapea sus nodos/aristas a
915/// [`navigator_dag_view`] y traduce el [`Rol`] de cada nodo a su icono. Es la
916/// entrada única **dominio → visor universal** (F1): un adaptador de dominio
917/// implementa el trait y llama aquí, sin saber de layout ni de cámara. El caller
918/// arma su menú de verbos con `grafo.acciones(seleccion)` + [`grafo_nav::Verbo`]
919/// y lo dispara desde `on_context`.
920/// `vertical` elige la orientación del layout (apilado arriba→abajo vs.
921/// izquierda→derecha, autoequilibrado); combinar con
922/// [`grafo_fit_camera`] con el mismo flag para el encuadre inicial.
923/// `posiciones` son overrides de drag manual (sembrar con [`grafo_posiciones`],
924/// vacío = auto-layout); `on_move(id, dx, dy)` recibe el delta de arrastre en
925/// coords de canvas para que la app acumule la nueva posición del nodo.
926#[allow(clippy::too_many_arguments)]
927pub fn cockpit_grafo_view<'a, G, Msg, FSel, FCtx, FMove, FPan, FZoom>(
928    grafo: &G,
929    camera: Camera,
930    selected: Option<NavId>,
931    palette: NavPalette,
932    vertical: bool,
933    posiciones: &'a [(NavId, f32, f32)],
934    flujo: f32,
935    on_select: FSel,
936    on_context: Option<FCtx>,
937    on_move: FMove,
938    on_pan: FPan,
939    on_zoom: FZoom,
940) -> View<Msg>
941where
942    G: GrafoNavegable,
943    Msg: Clone + Send + Sync + 'static,
944    FSel: Fn(NavId) -> Msg + Send + Sync + 'static,
945    FCtx: Fn(NavId) -> Msg + Send + Sync + 'static,
946    FMove: Fn(NavId, f32, f32) -> Option<Msg> + Send + Sync + 'static,
947    FPan: Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
948    FZoom: Fn(f32) -> Option<Msg> + Send + Sync + 'static,
949{
950    let (nodes, edges) = flat_from_grafo(grafo);
951    navigator_dag_view(
952        NavDagSpec { nodes: &nodes, edges: &edges, camera, selected, palette, vertical, posiciones, flujo },
953        on_select,
954        on_context,
955        on_move,
956        on_pan,
957        on_zoom,
958    )
959}
960
961/// Glifo del verbo para el menú contextual.
962fn glifo_verbo(v: Verbo) -> &'static str {
963    match v {
964        Verbo::Saltar => "→",
965        Verbo::Restablecer => "⟳",
966        Verbo::Cercenar => "✂",
967        Verbo::Conciliar => "⑃",
968    }
969}
970
971/// Construye el **menú de verbos** del cockpit a partir de los verbos que el
972/// dominio declara para la selección (`grafo.acciones(sel)`). Cada item despacha
973/// su [`Verbo`] vía `on_pick`; *Cercenar* se marca destructivo. Es la pieza que
974/// traduce el vocabulario compartido en UI: la app abre este menú al recibir el
975/// `on_context` del cockpit (con el ancla del cursor) y lo cierra con
976/// `on_dismiss`, sin que el cockpit conozca los `Msg` de cada dominio.
977pub fn menu_verbos_view<Msg, F>(
978    verbos: &[Verbo],
979    anchor: (f32, f32),
980    viewport: (f32, f32),
981    active: usize,
982    on_pick: F,
983    on_dismiss: Msg,
984    theme: &Theme,
985) -> View<Msg>
986where
987    Msg: Clone + Send + Sync + 'static,
988    F: Fn(Verbo) -> Msg + Send + Sync + 'static,
989{
990    let items: Vec<ContextMenuItem> = verbos
991        .iter()
992        .map(|v| {
993            let it = ContextMenuItem::action(v.etiqueta()).icon(glifo_verbo(*v));
994            if *v == Verbo::Cercenar {
995                it.destructive()
996            } else {
997                it
998            }
999        })
1000        .collect();
1001    let verbos_owned: Vec<Verbo> = verbos.to_vec();
1002    context_menu_view(ContextMenuSpec {
1003        anchor,
1004        viewport,
1005        header: None,
1006        items,
1007        active,
1008        on_pick: alloc::sync::Arc::new(move |idx| on_pick(verbos_owned[idx])),
1009        on_dismiss,
1010        palette: ContextMenuPalette::from_theme(theme),
1011    })
1012}
1013
1014// =====================================================================
1015// Icono vectorial por clase (para el árbol)
1016// =====================================================================
1017
1018/// Un mini-canvas con el icono de la clase, tinte `color`. Diamante para
1019/// Mónada, cuadrado para grupo/dir, círculo para archivo.
1020fn kind_icon_view<Msg: Clone + 'static>(kind: NavKind, color: Color) -> View<Msg> {
1021    View::new(Style {
1022        size: Size {
1023            width: llimphi_compositor::llimphi_layout::taffy::prelude::length(ICON_PX),
1024            height: llimphi_compositor::llimphi_layout::taffy::prelude::length(ICON_PX),
1025        },
1026        align_items: Some(AlignItems::CENTER),
1027        justify_content: Some(JustifyContent::CENTER),
1028        ..Default::default()
1029    })
1030    .paint_with(move |scene, _ts, rect| {
1031        use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Circle, Point, RoundedRect};
1032        use llimphi_compositor::peniko::Fill;
1033        if rect.w <= 0.0 || rect.h <= 0.0 {
1034            return;
1035        }
1036        let cx = (rect.x + rect.w * 0.5) as f64;
1037        let cy = (rect.y + rect.h * 0.5) as f64;
1038        let r = (rect.w.min(rect.h) as f64 * 0.34).max(1.5);
1039        match kind {
1040            NavKind::Monad => {
1041                // Diamante (cuadrado a 45°).
1042                let mut p = BezPath::new();
1043                p.move_to(Point::new(cx, cy - r));
1044                p.line_to(Point::new(cx + r, cy));
1045                p.line_to(Point::new(cx, cy + r));
1046                p.line_to(Point::new(cx - r, cy));
1047                p.close_path();
1048                scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p);
1049            }
1050            NavKind::Group | NavKind::Dir => {
1051                let sq = RoundedRect::new(cx - r, cy - r, cx + r, cy + r, 2.0);
1052                scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &sq);
1053            }
1054            NavKind::File | NavKind::Other => {
1055                let dot = (rect.w.min(rect.h) as f64 * 0.22).max(1.0);
1056                scene.fill(
1057                    Fill::NonZero,
1058                    Affine::IDENTITY,
1059                    color,
1060                    None,
1061                    &Circle::new((cx, cy), dot),
1062                );
1063            }
1064        }
1065    })
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    #[derive(Clone, Debug, PartialEq)]
1073    enum Msg {
1074        Toggle(NavId),
1075        Select(NavId),
1076        Open(NavId),
1077    }
1078
1079    fn forest() -> Vec<NavNode> {
1080        vec![NavNode::branch(
1081            1,
1082            "Mónada src",
1083            NavKind::Monad,
1084            vec![
1085                NavNode::leaf(11, "lib.rs", NavKind::File),
1086                NavNode::leaf(12, "main.rs", NavKind::File),
1087            ],
1088        )]
1089    }
1090
1091    #[test]
1092    fn navmode_toggle_e_indices() {
1093        assert_eq!(NavMode::Tree.toggled(), NavMode::Graph);
1094        assert_eq!(NavMode::Graph.toggled(), NavMode::Tree);
1095        assert_eq!(NavMode::Tree.index(), 0);
1096        assert_eq!(NavMode::from_index(1), NavMode::Graph);
1097        assert_eq!(NavMode::from_index(0), NavMode::Tree);
1098    }
1099
1100    #[test]
1101    fn navnode_constructores() {
1102        let n = NavNode::leaf(1, "x", NavKind::File);
1103        assert!(!n.has_children());
1104        let b = NavNode::branch(2, "y", NavKind::Monad, vec![n]);
1105        assert!(b.has_children());
1106        assert_eq!(b.children.len(), 1);
1107    }
1108
1109    #[test]
1110    fn flatten_grafo_respeta_expansion() {
1111        let roots = forest();
1112        // Colapsado: sólo la raíz.
1113        let collapsed = flatten_for_graph(&roots, &|_| false);
1114        assert_eq!(collapsed.len(), 1);
1115        assert_eq!(collapsed[0].id, 1);
1116        assert!(collapsed[0].parent.is_none());
1117        assert!(collapsed[0].has_children);
1118        // Expandido: raíz + 2 hijos, con parent = índice 0.
1119        let expanded = flatten_for_graph(&roots, &|id| id == 1);
1120        assert_eq!(expanded.len(), 3);
1121        assert_eq!(expanded[1].parent, Some(0));
1122        assert_eq!(expanded[2].parent, Some(0));
1123        assert_eq!(expanded[1].depth, 1);
1124    }
1125
1126    #[test]
1127    fn dag_cockpit_construye_con_multiples_padres() {
1128        // Un DAG con un merge (nodo 4 con dos padres) — imposible en NavNode.
1129        let nodes = vec![
1130            NavFlatNode::new(1, "raíz", NavKind::Monad),
1131            NavFlatNode::new(2, "rama A", NavKind::Group),
1132            NavFlatNode::new(3, "rama B", NavKind::Group),
1133            NavFlatNode::new(4, "merge", NavKind::File),
1134        ];
1135        let edges = vec![(1u64, 2u64), (1, 3), (2, 4), (3, 4)];
1136        let _v: View<Msg> = navigator_dag_view(
1137            NavDagSpec {
1138                nodes: &nodes,
1139                edges: &edges,
1140                camera: Camera::default(),
1141                selected: Some(1),
1142                palette: NavPalette::default(),
1143                vertical: false,
1144                posiciones: &[],
1145                flujo: 0.0,
1146            },
1147            Msg::Select,
1148            Some(Msg::Open),
1149            |_id, _dx, _dy| None,
1150            |_phase, _dx, _dy| None,
1151            |_dy| None,
1152        );
1153    }
1154
1155    #[test]
1156    fn color_por_nodo_varia_y_es_determinista() {
1157        let accent = Color::from_rgba8(80, 140, 240, 255);
1158        // Rotar 0° ≈ identidad.
1159        let c0 = hue_shift(accent, 0.0);
1160        for k in 0..4 {
1161            assert!((c0.components[k] - accent.components[k]).abs() < 1e-3);
1162        }
1163        // Nodos corrientes distintos → colores distintos (varía el matiz).
1164        let a = color_de_nodo(NavKind::Group, 1, accent);
1165        let b = color_de_nodo(NavKind::Group, 2, accent);
1166        assert!(a.components != b.components, "cada nodo varía su matiz");
1167        // Determinista para el mismo índice.
1168        assert_eq!(
1169            color_de_nodo(NavKind::Group, 5, accent).components,
1170            color_de_nodo(NavKind::Group, 5, accent).components
1171        );
1172        // El activo lleva el acento pleno.
1173        assert_eq!(color_de_nodo(NavKind::Monad, 3, accent).components, accent.components);
1174    }
1175
1176    #[test]
1177    fn grafo_fit_camera_encuadra_y_clampa() {
1178        use grafo_nav::{Nodo, NodoId, Verbo};
1179        struct G;
1180        impl GrafoNavegable for G {
1181            fn nodos(&self) -> Vec<Nodo> {
1182                (1..=5).map(|i| Nodo::new(i, "n", Rol::Normal)).collect()
1183            }
1184            fn aristas(&self) -> Vec<(NodoId, NodoId)> {
1185                vec![(1, 2), (2, 3), (3, 4), (4, 5)]
1186            }
1187            fn acciones(&self, _: Option<NodoId>) -> Vec<Verbo> {
1188                vec![Verbo::Saltar]
1189            }
1190        }
1191        let cam = grafo_fit_camera(&G, 800.0, 600.0, false);
1192        let cam_v = grafo_fit_camera(&G, 800.0, 600.0, true);
1193        assert!(cam_v.zoom >= 0.25 && cam_v.zoom <= 4.0);
1194        assert!(cam.zoom >= 0.25 && cam.zoom <= 4.0, "zoom clampeado");
1195        // Cadena de 5 nodos anchos ⇒ hay que alejar para que quepa a lo ancho.
1196        assert!(cam.zoom < 1.0, "encuadre reduce el zoom para caber");
1197    }
1198
1199    #[test]
1200    fn menu_de_verbos_se_construye_desde_las_acciones() {
1201        use grafo_nav::Verbo;
1202        // Los verbos vendrían de grafo.acciones(sel); aquí un set con Cercenar
1203        // (destructivo) para ejercitar el mapeo completo.
1204        let verbos = [Verbo::Saltar, Verbo::Restablecer, Verbo::Cercenar];
1205        let _v: View<Msg> = menu_verbos_view(
1206            &verbos,
1207            (120.0, 80.0),
1208            (800.0, 600.0),
1209            usize::MAX,
1210            |verbo| match verbo {
1211                Verbo::Cercenar => Msg::Open(0),
1212                _ => Msg::Select(0),
1213            },
1214            Msg::Toggle(0),
1215            &Theme::dark(),
1216        );
1217        // Glifos definidos para los cuatro verbos.
1218        for v in Verbo::TODOS {
1219            assert!(!glifo_verbo(v).is_empty());
1220        }
1221    }
1222
1223    #[test]
1224    fn cockpit_grafo_view_pinta_un_grafonavegable() {
1225        use grafo_nav::{Nodo, NodoId, Verbo};
1226        // Adaptador mínimo: diamante con merge (nodo 4, dos padres).
1227        struct G;
1228        impl GrafoNavegable for G {
1229            fn nodos(&self) -> Vec<Nodo> {
1230                vec![
1231                    Nodo::new(1, "raíz", Rol::Normal),
1232                    Nodo::new(2, "A", Rol::Normal),
1233                    Nodo::new(3, "B", Rol::Stale),
1234                    Nodo::new(4, "merge", Rol::Activo),
1235                ]
1236            }
1237            fn aristas(&self) -> Vec<(NodoId, NodoId)> {
1238                vec![(1, 2), (1, 3), (2, 4), (3, 4)]
1239            }
1240            fn acciones(&self, sel: Option<NodoId>) -> Vec<Verbo> {
1241                if sel.is_some() { vec![Verbo::Saltar, Verbo::Cercenar] } else { vec![Verbo::Saltar] }
1242            }
1243        }
1244        let g = G;
1245        let _v: View<Msg> = cockpit_grafo_view(
1246            &g,
1247            Camera::default(),
1248            Some(4),
1249            NavPalette::default(),
1250            true, // vertical
1251            &[],  // sin overrides de posición
1252            0.0,  // sin flujo
1253            Msg::Select,
1254            Some(Msg::Open),
1255            |_id, _dx, _dy| None,
1256            |_p, _dx, _dy| None,
1257            |_dy| None,
1258        );
1259        // El trait deriva la estructura sin que el adaptador la calcule.
1260        assert_eq!(g.raices(), vec![1]);
1261        assert_eq!(g.cabezas(), vec![4]);
1262    }
1263
1264    #[test]
1265    fn navigator_view_construye_en_ambos_modos() {
1266        // No paniquea construyendo el View en cada modo (smoke).
1267        let roots = forest();
1268        let palette = NavPalette::default();
1269        for mode in [NavMode::Tree, NavMode::Graph] {
1270            let _v: View<Msg> = navigator_view(
1271                NavSpec {
1272                    roots: &roots,
1273                    mode,
1274                    selected: Some(1),
1275                    palette,
1276                    guides: true,
1277                },
1278                |id| id == 1,
1279                Msg::Toggle,
1280                Msg::Select,
1281                Some(Msg::Open),
1282            );
1283        }
1284    }
1285}