tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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}