feat: nahual standalone — visores open-with sobre Llimphi (front-door, git-dep al monorepo)

Stack de visores extraído como front-door limpio: solo los crates nahual-*
(shell open-with + ~14 visores: texto/imagen/audio/video/svg/mapa/fuente/hex/
tabla/markdown/archivo/card/gallery/árbol + cores + meta-runtime). Todo lo
fundacional (discern, decoders media, fuentes content-addressed, hojas shared)
y Llimphi se consumen por git-dep del monorepo gioser.git — cero vendoring.
cargo check --workspace pasa (0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:32:14 +00:00
commit f63e78141d
100 changed files with 27657 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
# ARQUITECTURA.md — nahual
> Descripción técnico-arquitectónica densa, optimizada para consumo por IA.
> Snapshot: 2026-05-30. Fuente autoritativa cuando difiera con la prosa de los READMEs.
```yaml
DOMINIO: nahual
CUADRANTE: 02_ruway (HACER)
NOMBRE: nahuatl "espíritu acompañante"
TESIS: meta-app "open-with universal" — abre cualquier dato heterogéneo despachando al visor correcto
según la NATURALEZA DISCERNIDA del contenido, NO la extensión del archivo
PARADIGMA: shell + file-explorer + preview-pane; despacho por contenido (shuma-discern → viewer_registry::pick)
PRINCIPIO: VIEWERS, NO EDITORS — visualización read-only; para editar se usa otra app (nada/pineal)
TAMAÑO: ~9.5 KLoC; 2 libs + 11 visores productivos (+1 stub SVG)
```
## Flujo central (el corazón del dominio)
```
Archivo → read(8 KB header) → shuma-discern (pipeline de probes) → Discernment{ty, mime, lens, confidence}
→ viewer_registry::pick(Discernment) → ViewerKind → Shell::load_for → PreviewPane::<X>(state) → <X>_viewer_view()
EJEMPLO CLAVE: un .png renombrado a .txt → MagicBytes detecta 0x89PNG (conf 0.99) → ViewerKind::Image.
Acierta pese a la extensión mentirosa. Esa es la tesis entera: contenido > extensión.
```
## Detección: `shuma-discern` (en `02_ruway/shuma/sandbox/shuma-discern`)
```
DiscernPipeline::default = [MagicBytes, CardProbe, JsonProbe, TomlProbe, TabularProbe, Utf8Probe]
ESTRATEGIA: primer discerner con confidence ≥ 0.9 gana; si ninguno, el de mayor confidence.
MagicBytes (0.99): PNG/JPEG/PDF/ELF/WASM/gzip/ZIP/tar(ustar@257)/GIF/WEBP/WAVE/FLAC/Ogg/MP3/EBML/IVF/TTF/OTF/ttcf.
CardProbe (0.97): JSON con keys {schema_version,id,payload} → TypeRef::Wit{package:"brahman:card"}, lens "card". ANTES que JsonProbe.
JsonProbe (0.95): serde_json parsea → lens "tree".
TomlProbe (0.93): secciones [..]/clave=valor → lens "tree".
TabularProbe (0.93): hint path .csv/.tsv + delimitador en 1ª línea → lens "table".
Utf8Probe (0.5): UTF-8 válido, controles <5%; lens por extensión (.md→markdown, .rs/.py/.go/.js/.ts→code). Fallback universal.
Discernment{ ty: TypeRef, confidence: f32, mime: Option<String>, lens: Option<String> } // TypeRef viene de card-core
```
## Despacho: `viewer_registry::pick` (nahual-shell-llimphi/src/viewer_registry.rs, 200 LOC, 11 tests)
```
PRECEDENCIA:
1. lens explícito gallery→Image · video→Video · audio→Audio · card→Card · tree→Tree · table→Table · markdown→Markdown · font→Font
EXCEPCIÓN: image/gif SIEMPRE → Video (anima aunque el lens sea gallery)
2. mime prefix image/* → Image · video/* → Video · audio/* → Audio
3. mime contenedor application/{zip,x-tar,gzip} → Archive
4. mime binario application/{x-executable,wasm} → Hex
5. fallback Text (nunca falla feo)
```
## Visores implementados (11) — cada uno 200600 LOC, bajo acoplamiento
```
Text fallback UTF-8 + syntax por extensión (read-only sobre widget text-editor)
Image PNG/JPEG/WebP + GIF estático; pan/zoom, fit, magnifier, EXIF
Video WebM/MKV(AV1 nativo puro-Rust)/IVF + GIF animado; demuxer por extensión
Audio WAV/MP3/FLAC/Opus/Vorbis; espectro 48 bandas (Goertzel); sink cpal !Send vive en Model
Card shared/card (schema_version/id/payload) como campos legibles
Tree JSON/TOML indentado (legible aun minificado)
Hex ELF/wasm/binarios: dump offset+hex+ascii, 16 B/fila, sin deps
Table CSV/TSV columnas alineadas
Markdown .md vía pulldown-cmark 0.12 (encabezados h1..h6, código, listas, citas)
Archive ZIP/jar/apk/epub/OOXML (zip 2.4) · tar(ustar@257) · tar.gz(flate2 streaming)
Font TTF/OTF: metadatos + MUESTRA DIBUJADA — ttf_parser::OutlineBuilder → kurbo::BezPath → paint_with
(showcase de render vectorial directo; se ve aunque la fuente no esté instalada)
```
## Libs declarativas (presentes, aún NO usadas por el shell)
```
meta-schema (1172) schema declarativo de UIs data-driven: Module · EntitySpec · View{List,Form,Detail,Dashboard,Report,Graph}
meta-runtime (2360) helpers puros: parseo tipado, validación, delta, formato/presentación
USO ACTUAL: consumidos por Nakui/otros, NO por nahual-shell. Reservados para definir visores en JSON sin código Rust (futuro).
```
## Cómo registrar un visor nuevo (procedimiento real)
```
1. crate nahual-<x>-viewer-llimphi: struct <X>Preview + fn load_<x>(path) + fn <x>_viewer_view()
2. shell main.rs: import + variante en enum PreviewPane + arm en load_for
3. viewer_registry: variante ViewerKind::<X> + fila en pick() (lens/mime) + test
4. shuma-discern: nuevo Discerner si hay magic-bytes/heurística nueva, o reusar lens existente
5. (futuro) meta-schema: cuando exista AppBus, registro dinámico en tabla en vez de hardcode in-process
```
## Faltantes y limitaciones conocidas
```
PDF shuma lo detecta (%PDF-, lens "reader") pero NO hay rasterizador PDF puro-Rust en el workspace ⇒ cae a Text. BLOQUEADO.
SVG stub (1 LOC), en construcción por otro agente.
Deck presentaciones — futuro.
seek/scrub video/audio sólo Space play/pausa; audio estima playhead con reloj (AudioSource type-erased, falta exponer Seekable).
AppBus NO existe aún: el registro de visores es HARDCODE in-process. Sin visores out-of-process ni registro dinámico.
```
## Relaciones inter-dominio
```
shuma : shuma-discern es el cerebro de detección (pipeline de probes por magic-bytes + heurística).
llimphi : toda la UI — llimphi-ui/theme/layout(taffy)/text/raster(kurbo,peniko) + widgets {list,splitter,text-editor,tree}.
card : card-core aporta TypeRef/Discernment; shared/card es el formato del Card viewer. "brahman:card" es nombre legacy.
media : media-source-{av1,webm,gif} + media-audio-cpal + media-core(AudioProbe, Spectrum) para video/audio.
wawa-config: preferencias (theme/lang) compartidas con el monorepo; watcher reactivo sin reinicio.
minga : card-discovery (en minga) es el widget de descubrimiento de Cards consumido por nahual-shell. ← NEXO BRAHMAN
```
## Estado (2026-05-31)
### Hecho
- 11 visores en-proceso (Text/Image/Video/Audio/Card/Tree/Hex/Table/Markdown/Archive/Font), cada uno 200600 LOC, bajo acoplamiento.
- Shell con split draggable, navegación por teclado (↑↓ Enter Backspace Space) y despacho por contenido (no extensión) vía `shuma-discern``viewer_registry::pick`.
- Galería de miniaturas (`nahual-gallery-llimphi`): `thumb-core` (generación + cache en disco + planificador), fast-path EXIF embebido, orientación EXIF, zoom de grilla, eviction RAM, badge de tamaño, slideshow, ordenamiento, preview a tamaño completo.
- Espina del front universal: trait `Source` + 4 adapters (POSIX, wawa-image, NouserSource/Mónadas, MingaSource/DAG de AST); el shell monta nouser y minga por atajo.
- Render vectorial directo en el font viewer; GIF reusa el video viewer sin crate nuevo. Menú principal + contextual en las vitrinas.
### Pendiente
- PDF: detectado por `shuma` pero sin rasterizador puro-Rust en el workspace → cae a Text (BLOQUEADO).
- SVG: stub (1 LOC), en construcción.
- Deck (presentaciones), seek/scrub real en video/audio (hoy sólo play/pausa), play por click.
- AppBus / registro dinámico: hoy el `viewer_registry` es hardcode in-process; sin visores out-of-process registrados por `(lens, mime, priority)`.
- Libs `meta-schema`/`meta-runtime` presentes pero aún NO consumidas por el shell (reservadas para visores data-driven en JSON).
## Estado vs aspiración
```
ASPIRA_A:
- PDF (bloqueado por rasterizador) · SVG (WIP) · Deck · seek/scrub · play por click.
- AppBus / EntityType: visores FUERA-DE-PROCESO que se registran con (lens, mime, priority);
shell publica EntitySelected, viewers suscriben; registro pasa de hardcode a TABLA DINÁMICA.
- mover viewer_registry a crate compartido si otra app además del shell lo necesita.
NORTE_ARQUITECTÓNICO:
nahual es el "abridor universal" de la suite: dado cualquier byte, discierne su naturaleza y lo muestra bien.
Hoy el registro de visores es estático e in-process. Su destino declarado es el AppBus: que cualquier app/servicio
registre un visor por (lens, mime, priority) y el shell despache out-of-process — exactamente el patrón de discovery
tipado que Brahman ya implementa para datos. nahual+AppBus ES el caso de uso natural de las Cards a nivel de UI.
```
---
**Síntesis de una línea para otra IA:** nahual es una meta-app "open-with universal" que lee los primeros 8 KB de
cualquier archivo, discierne su naturaleza por contenido (no extensión) vía un pipeline de probes (`shuma-discern`), y
despacha al visor correcto de entre 11 (`viewer_registry::pick`) — read-only por principio, con detección robusta ante
archivos mal renombrados, cuyo norte es reemplazar el registro estático in-process por un AppBus de visores
out-of-process registrados por `(lens, mime, priority)`, que es justamente el patrón de discovery tipado de Brahman llevado a la UI.
+35
View File
@@ -0,0 +1,35 @@
# nahual
> `nahual` (náhuatl: *espíritu acompañante*). Visores cotidianos sobre Llimphi.
Conjunto mínimo de viewers que el usuario espera de un escritorio: shell de archivos, viewer de texto, viewer de imagen. Implementados con la misma framework de UI; comparten preferencias con el resto del monorepo via `wawa-config`. Más un meta-runtime para definir nuevos viewers via schema sin escribir Rust desde cero.
## Instalación
```sh
cargo run --release -p nahual-shell-llimphi
cargo run --release -p nahual-file-explorer-llimphi
cargo run --release -p nahual-text-viewer-llimphi
cargo run --release -p nahual-image-viewer-llimphi
```
## Compatibilidad
- **Linux / macOS / Windows** — UI Llimphi nativa.
- **Wawa** — los viewers compilan adentro del kernel; el file explorer habla con `wawa-fs`.
## Crates
| Crate | Rol |
|---|---|
| [`meta-schema`](libs/meta-schema/README.md) | Schema declarativo de viewers. |
| [`meta-runtime`](libs/meta-runtime/README.md) | Runtime que monta un viewer desde schema. |
| [`nahual-shell-llimphi`](nahual-shell-llimphi/README.md) | Shell de archivos: navegación + acciones básicas. |
| [`nahual-file-explorer-llimphi`](nahual-file-explorer-llimphi/README.md) | File explorer con tree + previews. |
| [`nahual-text-viewer-llimphi`](nahual-text-viewer-llimphi/README.md) | Viewer de texto plano. |
| [`nahual-image-viewer-llimphi`](nahual-image-viewer-llimphi/README.md) | Viewer de imagen (PNG/JPEG/WebP). |
## Consideraciones
- **Visualizadores, no editores.** Si querés editar el archivo, `nada`. Si querés editar la imagen, `pineal` o un editor externo.
- El meta-runtime permite **definir un viewer en JSON** y obtener una app Llimphi sin código.
+26
View File
@@ -0,0 +1,26 @@
# nahual
> `nahual` (Nahuatl: *companion spirit*). Everyday viewers over Llimphi.
Minimal set of viewers a desktop user expects: file shell, text viewer, image viewer. Built on the same UI framework; share preferences with the rest of the monorepo via `wawa-config`. Plus a meta-runtime to define new viewers via schema without writing Rust from scratch.
## Install
```sh
cargo run --release -p nahual-shell-llimphi
cargo run --release -p nahual-file-explorer-llimphi
cargo run --release -p nahual-text-viewer-llimphi
cargo run --release -p nahual-image-viewer-llimphi
```
## Compatibility
- **Linux / macOS / Windows** — native Llimphi UI.
- **Wawa** — viewers compile inside the kernel; file explorer speaks `wawa-fs`.
Crates listed in [README.md](README.md).
## Considerations
- **Viewers, not editors.** Edit the file → `nada`. Edit the image → `pineal` or external.
- The meta-runtime lets you **define a viewer in JSON** and get a Llimphi app without code.
+28
View File
@@ -0,0 +1,28 @@
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
# nahual
> `nahual` (nahuatl: *kasqachay espíritu*). Sapanka viewerkuna Llimphi patapi.
Minimo viewerkuna runa escritorio suyay: archivo shell, qillqa viewer, siq'i viewer. Kikin UI framework patapi; preferencias monorepupawan `wawa-config`-rayku huñunku. Hinaspa meta-runtime: musuq viewers schema-pi ruway, mana Rust ñawpaqmanta qillqaspa.
## Churay
```sh
cargo run --release -p nahual-shell-llimphi
cargo run --release -p nahual-file-explorer-llimphi
cargo run --release -p nahual-text-viewer-llimphi
cargo run --release -p nahual-image-viewer-llimphi
```
## Tinkuy
- **Linux / macOS / Windows** — natural Llimphi UI.
- **Wawa** — viewerkuna kernel ukhupi wiñankun; file explorer `wawa-fs`-wan rimaq.
Crateskuna listako [README.md](README.md)-pi.
## Yuyaykunaq
- **Viewerkuna, mana editorkuna.** Tikrana archivo → `nada`. Tikrana siq'i → `pineal` utaq hawanka.
- Meta-runtime: huk **viewer JSON-pi munakuy**, Llimphi app aypay manchu kódigo.
@@ -0,0 +1,12 @@
[package]
name = "nahual-meta-runtime"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — meta-runtime: helpers puros (parse, delta, validación, format) que cualquier widget metainterfaz consume sobre `yahweh-meta-schema`. Sin GPUI, sin backend específico — toma cierres/closures para acceder al store."
[dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
nahual-meta-schema = { path = "../meta-schema" }
@@ -0,0 +1,9 @@
# meta-runtime
> Runtime que monta un viewer desde [`meta-schema`](../meta-schema/README.md) de [nahual](../../README.md).
Toma un `Schema` y produce un `View<Msg>` Llimphi. Permite "definir un viewer en JSON" sin escribir Rust.
## Deps
- [`meta-schema`](../meta-schema/README.md), [`llimphi-ui`](../../../llimphi/)
@@ -0,0 +1,9 @@
# meta-runtime
> Runtime mounting a viewer from [`meta-schema`](../meta-schema/README.md) of [nahual](../../README.md).
Takes a `Schema` and produces a Llimphi `View<Msg>`. Lets you "define a viewer in JSON" without writing Rust.
## Deps
- [`meta-schema`](../meta-schema/README.md), [`llimphi-ui`](../../../llimphi/)
@@ -0,0 +1,288 @@
//! `MetaBackend` trait — la frontera entre el widget metainterfaz
//! (nahual) y la implementación concreta de persistencia/ejecución
//! (nakui-core, Surreal, mocks para tests).
//!
//! El widget consume este trait; el binario lo implementa con su
//! stack particular. Esto es lo que hace que el widget sea reusable.
//!
//! Convenciones documentadas en el doc del trait abajo.
use std::collections::BTreeMap;
use serde_json::Value;
use uuid::Uuid;
/// Resultado uniforme de una operación de escritura del backend.
///
/// La UI lo usa para componer el toast: `id` para mostrar el
/// short_uuid, `changed` para diferenciar "actualizado X (3 campos)"
/// vs "sin cambios", `post_status` para concatenar mensajes
/// emitidos por hooks internos del backend (ej. "auto-compact:
/// snapshot @ seq 49") sin que la UI tenga que conocer el detalle.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WriteOutcome {
/// Id del record afectado. `Some` para seed/update/delete;
/// `None` para morphism cuando afecta múltiples records.
pub id: Option<Uuid>,
/// Cantidad de cambios efectivos. `0` = no-op (edit que no
/// modificó ningún campo, etc.).
pub changed: usize,
/// Mensaje de status opcional para concatenar al toast del op
/// original con el separator estándar.
pub post_status: Option<String>,
}
impl WriteOutcome {
/// Constructor para no-op writes (edits sin cambios).
pub fn no_change(id: Uuid) -> Self {
Self {
id: Some(id),
changed: 0,
post_status: None,
}
}
}
/// Backend que un widget de metainterfaz usa para leer y mutar
/// records. Decoupla el widget (nahual) de la implementación
/// concreta (nakui-core, Surreal, mock para tests).
///
/// # Convención sobre ids
///
/// `Uuid` canónico. Backends que internamente usan otros tipos
/// deben mapear via Uuid (hash determinista, wrapper, lo que sirva).
/// Esto evita generic associated types que complicarían el dispatch
/// en `cx.listener` de GPUI.
///
/// # Convención sobre validación
///
/// El backend ES la fuente de verdad sobre invariantes (KCL/Nickel
/// post-checks, conservación, etc.). El widget pre-valida shape
/// (nahual-meta-runtime: `parse_field_value`, `validate_entity_refs`)
/// pero el backend puede rebotar con `Err(...)` si su validación
/// adicional falla — el widget muestra el error al usuario.
///
/// # Convención sobre threading
///
/// `'static` (no `Send + Sync`): el widget vive en `Entity<MetaApp<B>>`
/// que requiere `'static`, pero los handlers son single-threaded en
/// el main UI thread de GPUI. Si en el futuro un backend necesita
/// `cx.spawn`, agregamos los marker traits.
///
/// # Convención sobre delta computation
///
/// El widget pre-computa `set` y `clear` con
/// [`crate::delta::compute_field_delta`] +
/// [`crate::delta::compute_clear_fields`] *antes* de llamar a
/// [`MetaBackend::update`]. El backend no recomputa: si recibe ambos
/// vacíos devuelve `changed = 0` sin escribir nada. Esto evita
/// double-roundtrip al store por el mismo dato.
pub trait MetaBackend: 'static {
/// Snapshot ordenado de records de una entity.
/// Orden estable (lexicográfico por id) para UI determinista.
/// Vacío si no hay records.
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)>;
/// Lee un record por id. `None` si no existe.
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value>;
/// Crea un record nuevo. El backend asigna el `Uuid`
/// (devuelve en `WriteOutcome.id`). `changed = 1` siempre.
fn seed(
&mut self,
entity: &str,
data: serde_json::Map<String, Value>,
) -> Result<WriteOutcome, String>;
/// Edita un record existente. Aplica `set` (overrides) y
/// `clear` (key removal). `changed = set.len() + clear.len()`.
/// Si ambos están vacíos (no-op edit), devuelve
/// `WriteOutcome::no_change(id)` sin error y sin escribir al log.
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String>;
/// Borra un record. `changed = 1` si existía, error si no.
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String>;
/// Ejecuta un morphism declarado por un módulo. El backend
/// resuelve la implementación, valida, computa ops, las aplica.
/// `changed = N ops aplicadas`.
///
/// `module_id` ubica al módulo (el trait no asume estructura del
/// manifest — el backend lo resuelve internamente).
fn morphism(
&mut self,
module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String>;
}
#[cfg(test)]
mod tests {
//! Tests del trait via [`crate::testing::MockBackend`]. Verifican
//! el contrato genérico (object-safety, semantica de seed/update/
//! delete) sin atar a un backend concreto. Los tests del mock en
//! sí (constructores, with_morphism, etc.) viven en
//! `crate::testing::tests`.
use super::*;
use crate::testing::MockBackend;
use serde_json::json;
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn seed_then_load_round_trip() {
let mut b = MockBackend::new();
let out = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap();
let id = out.id.expect("seed devuelve id");
assert_eq!(out.changed, 1);
assert!(out.post_status.is_none());
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme")));
}
#[test]
fn list_records_filters_by_entity_and_orders_stably() {
let mut b = MockBackend::new();
let _ = b.seed("A", map_of(&[("k", json!(1))])).unwrap();
let _ = b.seed("B", map_of(&[("k", json!(2))])).unwrap();
let _ = b.seed("A", map_of(&[("k", json!(3))])).unwrap();
let a = b.list_records("A");
assert_eq!(a.len(), 2);
let b_only = b.list_records("B");
assert_eq!(b_only.len(), 1);
let none = b.list_records("Missing");
assert!(none.is_empty());
// Orden estable: re-llamadas devuelven mismo orden.
let a_again = b.list_records("A");
assert_eq!(
a.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
a_again.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
);
}
#[test]
fn update_with_set_changes_field() {
let mut b = MockBackend::new();
let id = b
.seed(
"Customer",
map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
)
.unwrap()
.id
.unwrap();
let out = b
.update(
"Customer",
id,
map_of(&[("name", json!("Acme S.A."))]),
vec![],
)
.unwrap();
assert_eq!(out.changed, 1);
assert_eq!(out.id, Some(id));
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme S.A.")));
assert_eq!(rec.get("notes"), Some(&json!("x")), "notes intacto");
}
#[test]
fn update_with_clear_removes_key() {
let mut b = MockBackend::new();
let id = b
.seed(
"Customer",
map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
)
.unwrap()
.id
.unwrap();
let out = b
.update("Customer", id, serde_json::Map::new(), vec!["notes".into()])
.unwrap();
assert_eq!(out.changed, 1);
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme")));
assert!(rec.get("notes").is_none(), "notes debería estar borrado");
}
#[test]
fn update_with_empty_set_and_clear_returns_no_change() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap()
.id
.unwrap();
let out = b
.update("Customer", id, serde_json::Map::new(), vec![])
.unwrap();
assert_eq!(out, WriteOutcome::no_change(id));
}
#[test]
fn update_on_missing_record_errors() {
let mut b = MockBackend::new();
let id = Uuid::new_v4();
let err = b
.update("Customer", id, map_of(&[("x", json!(1))]), vec![])
.unwrap_err();
assert!(err.contains("not found"));
}
#[test]
fn delete_removes_and_then_load_returns_none() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap()
.id
.unwrap();
let out = b.delete("Customer", id).unwrap();
assert_eq!(out.changed, 1);
assert_eq!(out.id, Some(id));
assert!(b.load_record("Customer", id).is_none());
}
#[test]
fn delete_on_missing_record_errors() {
let mut b = MockBackend::new();
let id = Uuid::new_v4();
assert!(b.delete("Customer", id).is_err());
}
/// Sanity: el trait acepta llamadas via `&mut dyn MetaBackend`
/// (object-safety). Esto permite que el widget tenga
/// `Box<dyn MetaBackend>` si el use case requiere borrado de
/// tipo (vs. el path normal con `MetaApp<B: MetaBackend>`).
#[test]
fn trait_is_object_safe() {
let mut b: Box<dyn MetaBackend> = Box::new(MockBackend::new());
let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
assert_eq!(b.list_records("X").len(), 1);
}
}
@@ -0,0 +1,67 @@
//! Serialización de filas a CSV (RFC 4180) para exportar listas.
/// Arma un documento CSV: una línea de headers + una por fila. Cada
/// celda se escapa si contiene coma, comilla o salto de línea.
pub fn to_csv(headers: &[String], rows: &[Vec<String>]) -> String {
let mut out = String::new();
push_csv_line(&mut out, headers);
for row in rows {
push_csv_line(&mut out, row);
}
out
}
/// Agrega una línea CSV (celdas separadas por coma + `\n` final).
fn push_csv_line(out: &mut String, cells: &[String]) {
for (i, cell) in cells.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&csv_escape(cell));
}
out.push('\n');
}
/// Escapa una celda: la envuelve en comillas y duplica las comillas
/// internas si contiene coma, comilla, CR o LF. Si no, va tal cual.
fn csv_escape(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_cells_unquoted() {
let csv = to_csv(
&["Nombre".into(), "Edad".into()],
&[vec!["Ana".into(), "30".into()]],
);
assert_eq!(csv, "Nombre,Edad\nAna,30\n");
}
#[test]
fn cells_with_comma_or_quote_are_escaped() {
let csv = to_csv(
&["a".into(), "b".into()],
&[vec!["x,y".into(), "dijo \"hola\"".into()]],
);
assert_eq!(csv, "a,b\n\"x,y\",\"dijo \"\"hola\"\"\"\n");
}
#[test]
fn newline_in_cell_is_quoted() {
let csv = to_csv(&["n".into()], &[vec!["línea1\nlínea2".into()]]);
assert_eq!(csv, "n\n\"línea1\nlínea2\"\n");
}
#[test]
fn empty_rows_yields_just_header() {
assert_eq!(to_csv(&["x".into()], &[]), "x\n");
}
}
@@ -0,0 +1,137 @@
//! Cálculo del delta entre el record actual y la propuesta del form.
//!
//! Sirve a un runtime de edición para emitir SOLO los Set/Clear que
//! cambian algo: log + apply minimales, no-op edits = 0 entries.
use serde_json::Value;
/// Calcula el delta entre el record actual y los valores propuestos
/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
///
/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
/// `current=Value::Null` (record no encontrado) hace que todos los
/// campos del `proposed` sean considerados nuevos. Un campo del
/// proposed que coincide con el del current se omite. Campos que
/// están en current pero NO en proposed se preservan tal cual (el
/// edit no los toca; ver [`compute_clear_fields`] para borrar
/// explícito desde un input vacío).
pub fn compute_field_delta(
current: &Value,
proposed: &serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
proposed
.iter()
.filter(|(field, value)| current.get(field.as_str()) != Some(*value))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
/// Decide cuáles fields del `to_clear` candidate list ameritan
/// realmente un `FieldOp::Clear`: sólo los que existen en el current
/// con un valor non-null. Para fields ausentes o ya null, Clear es
/// no-op semántico (el post-state es el mismo) y dropearlos
/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
///
/// Preserva el orden del input para que el log entry sea estable.
pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
to_clear
.iter()
.filter(|f| match current.get(f.as_str()) {
None | Some(Value::Null) => false,
Some(_) => true,
})
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn delta_empty_when_all_fields_match() {
let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"});
let proposed = map(&[
("name", json!("Acme")),
("saldo", json!(100_i64)),
("currency", json!("USD")),
]);
assert!(compute_field_delta(&current, &proposed).is_empty());
}
#[test]
fn delta_includes_only_changed_field() {
let current = json!({"name": "Acme", "saldo": 100_i64});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert_eq!(d.get("saldo"), Some(&json!(200_i64)));
}
#[test]
fn delta_treats_missing_record_as_all_new() {
let current = Value::Null;
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]);
assert_eq!(compute_field_delta(&current, &proposed).len(), 2);
}
#[test]
fn delta_distinguishes_int_from_string_repr() {
let current = json!({"qty": 100_i64});
let proposed = map(&[("qty", json!(100_i64))]);
assert!(compute_field_delta(&current, &proposed).is_empty());
let current_str = json!({"qty": "100"});
let proposed_int = map(&[("qty", json!(100_i64))]);
assert_eq!(compute_field_delta(&current_str, &proposed_int).len(), 1);
}
#[test]
fn delta_skips_fields_absent_from_proposed() {
let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert!(!d.contains_key("extra"));
}
#[test]
fn clear_fields_skips_absent_and_null() {
let current = json!({"name": "Acme", "notes": "lorem", "tag": null});
let to_clear = vec![
"name".into(),
"notes".into(),
"tag".into(),
"missing".into(),
];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["name".to_string(), "notes".to_string()]
);
}
#[test]
fn clear_fields_preserves_input_order() {
let current = json!({"a": 1, "b": 2, "c": 3});
let to_clear = vec!["c".into(), "a".into(), "b".into()];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["c", "a", "b"]
);
}
#[test]
fn clear_fields_empty_when_current_is_null() {
let current = Value::Null;
let to_clear = vec!["name".into()];
assert!(compute_clear_fields(&current, &to_clear).is_empty());
}
}
@@ -0,0 +1,344 @@
//! Helpers de presentación humana para records y values.
//!
//! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea
//! en `div().child(...)` o equivalente.
use std::cmp::Ordering;
use serde_json::Value;
use uuid::Uuid;
use nahual_meta_schema::ValueFormat;
/// Compara dos valores de celda para ordenar una lista. `None`/`null`
/// ordenan antes que cualquier valor. Números por valor numérico,
/// strings case-insensitive, bools `false < true`; tipos mixtos por su
/// forma string (orden estable, no semántico).
pub fn cmp_values(a: Option<&Value>, b: Option<&Value>) -> Ordering {
let nullish = |v: Option<&Value>| matches!(v, None | Some(Value::Null));
match (nullish(a), nullish(b)) {
(true, true) => return Ordering::Equal,
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
(false, false) => {}
}
match (a, b) {
(Some(Value::Number(x)), Some(Value::Number(y))) => x
.as_f64()
.partial_cmp(&y.as_f64())
.unwrap_or(Ordering::Equal),
(Some(Value::String(x)), Some(Value::String(y))) => x.to_lowercase().cmp(&y.to_lowercase()),
(Some(Value::Bool(x)), Some(Value::Bool(y))) => x.cmp(y),
(Some(x), Some(y)) => x.to_string().cmp(&y.to_string()),
// Inalcanzable: el chequeo nullish de arriba cubre los None.
_ => Ordering::Equal,
}
}
/// Etiqueta humana para representar un record en el selector de
/// EntityRef y en columnas de referencia. Heurística: prefiere campos
/// de nombre comunes (ES + EN); fallback al UUID corto.
pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
for key in [
"name", "nombre", "label", "title", "titulo", "sku", "sku_id",
] {
if let Some(v) = value.get(key).and_then(Value::as_str) {
if !v.is_empty() {
return format!("{} ({})", v, short_uuid(id));
}
}
}
short_uuid(id)
}
/// Render legible de un `Value` arbitrario para mostrar en una celda
/// de lista. Strings van pelados; bools como ✓/✗; el resto via
/// `Display`.
pub fn render_value(v: Option<&Value>) -> String {
match v {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(Value::Bool(b)) => if *b { "" } else { "" }.to_string(),
Some(Value::Number(n)) => n.to_string(),
Some(other) => other.to_string(),
}
}
/// Render de un valor de celda según un [`ValueFormat`]. `Plain`
/// delega en [`render_value`]; `Number`/`Currency` agrupan miles. Un
/// valor no numérico bajo `Number`/`Currency` cae a `render_value`.
pub fn format_value(v: Option<&Value>, fmt: &ValueFormat) -> String {
match fmt {
ValueFormat::Plain => render_value(v),
ValueFormat::Number => match v {
Some(Value::Number(n)) => group_thousands(n),
_ => render_value(v),
},
ValueFormat::Currency { symbol } => match v {
Some(Value::Number(n)) => format!("{symbol}{}", group_thousands(n)),
_ => render_value(v),
},
}
}
/// Formatea un `Number` con separador de miles. Enteros sin decimales;
/// flotantes con dos.
fn group_thousands(n: &serde_json::Number) -> String {
if let Some(i) = n.as_i64() {
group_int(i)
} else if let Some(f) = n.as_f64() {
let neg = f.is_sign_negative();
let cents = (f.abs() * 100.0).round() as i64;
format!(
"{}{}.{:02}",
if neg { "-" } else { "" },
group_int(cents / 100),
cents % 100,
)
} else {
n.to_string()
}
}
/// Inserta comas cada tres dígitos en un entero con signo.
fn group_int(i: i64) -> String {
let digits = i.unsigned_abs().to_string();
let bytes = digits.as_bytes();
let mut out = String::new();
for (idx, &b) in bytes.iter().enumerate() {
if idx > 0 && (bytes.len() - idx).is_multiple_of(3) {
out.push(',');
}
out.push(b as char);
}
if i < 0 {
format!("-{out}")
} else {
out
}
}
/// Conversión inversa a `parse_field_value`: del JSON al texto raw
/// que un input puede tomar y volver a parsearse igual al submit.
/// Usado para pre-llenar inputs en modo edit.
pub fn value_to_input_text(v: &Value) -> String {
match v {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
/// Primeros 8 chars del UUID en forma canónica. Útil para logs y UI
/// donde el UUID full es ruido visual.
pub fn short_uuid(id: &Uuid) -> String {
id.to_string().chars().take(8).collect()
}
/// Hex string de los primeros 4 bytes de un hash SHA-256 (8
/// caracteres). Útil para mostrar bundle/schema hashes en UI sin
/// quemar pantalla con los 64 chars completos.
pub fn short_hash(h: &[u8; 32]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(8);
for b in h.iter().take(4) {
let _ = write!(s, "{:02x}", b);
}
s
}
/// Renderea un `serde_json::Value` en una sola línea, truncado a
/// `max` caracteres con `...` al final si excede. Para preview en
/// timelines/cards/listas — NO para edición.
///
/// `max` es un upper-bound aproximado: el resultado nunca excede
/// `max` chars, pero puede ser más corto si el value es chico.
pub fn preview_value(v: &Value, max: usize) -> String {
let s = v.to_string();
if s.chars().count() <= max {
s
} else if max < 3 {
s.chars().take(max).collect()
} else {
let truncated: String = s.chars().take(max - 3).collect();
format!("{truncated}...")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn human_label_prefers_name_over_id() {
let id = Uuid::new_v4();
let v = json!({"name": "Acme S.A.", "email": "x@y.z"});
let label = human_label_for_record(&v, &id);
assert!(label.starts_with("Acme S.A."));
assert!(label.contains(&short_uuid(&id)));
}
#[test]
fn human_label_falls_back_through_label_title_sku() {
let id = Uuid::new_v4();
let only_label = json!({"label": "X"});
assert!(human_label_for_record(&only_label, &id).starts_with("X "));
let only_title = json!({"title": "Y"});
assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
let only_sku = json!({"sku": "Z"});
assert!(human_label_for_record(&only_sku, &id).starts_with("Z "));
let only_sku_id = json!({"sku_id": "W"});
assert!(human_label_for_record(&only_sku_id, &id).starts_with("W "));
}
#[test]
fn human_label_falls_back_to_short_uuid_when_no_keys_match() {
let id = Uuid::new_v4();
let v = json!({"random": "field"});
assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
}
#[test]
fn human_label_recognizes_spanish_name_fields() {
let id = Uuid::new_v4();
assert!(human_label_for_record(&json!({"nombre": "Acme"}), &id).starts_with("Acme "));
assert!(human_label_for_record(&json!({"titulo": "Trato"}), &id).starts_with("Trato "));
}
#[test]
fn format_value_number_groups_thousands() {
assert_eq!(
format_value(Some(&json!(12000)), &ValueFormat::Number),
"12,000"
);
assert_eq!(format_value(Some(&json!(5)), &ValueFormat::Number), "5");
assert_eq!(
format_value(Some(&json!(-1234567)), &ValueFormat::Number),
"-1,234,567"
);
}
#[test]
fn format_value_currency_prefixes_symbol() {
let fmt = ValueFormat::Currency { symbol: "$".into() };
assert_eq!(format_value(Some(&json!(25000)), &fmt), "$25,000");
}
#[test]
fn format_value_float_gets_two_decimals() {
assert_eq!(
format_value(Some(&json!(1234.5)), &ValueFormat::Number),
"1,234.50"
);
}
#[test]
fn cmp_values_orders_numbers_strings_nulls() {
// Números por valor, no lexicográfico.
assert_eq!(
cmp_values(Some(&json!(2)), Some(&json!(10))),
Ordering::Less
);
// Strings case-insensitive.
assert_eq!(
cmp_values(Some(&json!("banana")), Some(&json!("Apple"))),
Ordering::Greater
);
// null/None ordena primero.
assert_eq!(cmp_values(None, Some(&json!(1))), Ordering::Less);
assert_eq!(
cmp_values(Some(&Value::Null), Some(&json!("x"))),
Ordering::Less
);
assert_eq!(
cmp_values(Some(&json!(5)), Some(&json!(5))),
Ordering::Equal
);
// Bools.
assert_eq!(
cmp_values(Some(&json!(false)), Some(&json!(true))),
Ordering::Less
);
}
#[test]
fn format_value_non_number_falls_back_to_render_value() {
assert_eq!(
format_value(Some(&json!("hola")), &ValueFormat::Plain),
"hola"
);
let fmt = ValueFormat::Currency { symbol: "$".into() };
assert_eq!(format_value(Some(&json!("x")), &fmt), "x");
assert_eq!(format_value(None, &ValueFormat::Number), "");
}
#[test]
fn render_value_handles_basic_kinds() {
assert_eq!(render_value(None), "");
assert_eq!(render_value(Some(&Value::Null)), "");
assert_eq!(render_value(Some(&json!("hola"))), "hola");
assert_eq!(render_value(Some(&json!(true))), "");
assert_eq!(render_value(Some(&json!(false))), "");
assert_eq!(render_value(Some(&json!(42))), "42");
}
#[test]
fn value_to_input_text_round_trip_with_strings_and_numbers() {
assert_eq!(value_to_input_text(&Value::Null), "");
assert_eq!(value_to_input_text(&json!("x")), "x");
assert_eq!(value_to_input_text(&json!(true)), "true");
assert_eq!(value_to_input_text(&json!(false)), "false");
assert_eq!(value_to_input_text(&json!(42)), "42");
}
#[test]
fn short_hash_takes_first_4_bytes_hex() {
let mut h = [0u8; 32];
h[0] = 0xaa;
h[1] = 0xbb;
h[2] = 0xcc;
h[3] = 0xdd;
assert_eq!(short_hash(&h), "aabbccdd");
}
#[test]
fn short_hash_zeros() {
let h = [0u8; 32];
assert_eq!(short_hash(&h), "00000000");
}
#[test]
fn preview_value_keeps_short_strings_intact() {
let v = json!({"a": 1});
assert_eq!(preview_value(&v, 30), "{\"a\":1}");
}
#[test]
fn preview_value_truncates_long_strings_with_ellipsis() {
let v = json!({"a": "x".repeat(200)});
let p = preview_value(&v, 30);
assert!(p.chars().count() <= 30);
assert!(p.ends_with("..."));
}
#[test]
fn preview_value_handles_max_smaller_than_ellipsis() {
// Edge case: max < 3 (no espacio para "..."). Devuelve
// los primeros `max` chars sin sufijo, sin panic.
let v = json!("xxxxxxxxxxxxxxxx");
let p = preview_value(&v, 2);
assert!(p.chars().count() <= 2);
}
#[test]
fn short_uuid_returns_first_8_chars() {
let id = Uuid::parse_str("01ARZ3ND-EKTS-V4RR-FFQ6-9G5FAV000000").ok();
// Si el parse falla, usamos uno fresco — el invariant es la
// longitud, no el contenido.
let id = id.unwrap_or_else(Uuid::new_v4);
assert_eq!(short_uuid(&id).len(), 8);
}
}
@@ -0,0 +1,46 @@
//! `nahual-meta-runtime` — helpers puros para runtimes metainterfaz.
//!
//! Consume [`nahual_meta_schema`] (los tipos `Module`/`View`/`FieldSpec`/
//! `FieldKind`/`Action`/etc.) y aporta funciones puras que cualquier
//! widget renderer o backend ejecutor necesita:
//!
//! - **Parse**: convertir el texto de un input a `serde_json::Value`
//! tipado según el `FieldKind` del spec.
//! - **Delta**: calcular qué cambió entre el estado actual y la
//! propuesta del form (Set + Clear).
//! - **Validation**: verificar que cada EntityRef apunte a un record
//! que existe (toma cierre `load`, no trait).
//! - **Format**: presentación humana de records (label heurístico,
//! render de values, UUID corto, round-trip a input text).
//!
//! Sin GPUI, sin acoplamiento a un backend específico. Cualquier
//! implementación de store/log puede consumirlos.
//!
//! El widget render (form/list/modal) vive en otro crate nahual
//! que esto consume; el runtime concreto (`nakui-ui`) implementa la
//! conexión a su event-log/executor y compone ambos.
#![forbid(unsafe_code)]
pub mod backend;
pub mod csv;
pub mod delta;
pub mod format;
pub mod metric;
pub mod parse;
pub mod refs;
pub mod testing;
pub use backend::{MetaBackend, WriteOutcome};
pub use csv::to_csv;
pub use delta::{compute_clear_fields, compute_field_delta};
pub use format::{
cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash,
short_uuid, value_to_input_text,
};
pub use metric::{
breakdown_to_csv, bucket_date, compute_metric, cumulative_breakdown, limit_breakdown,
record_matches, sort_breakdown_by_key, MetricResult, OTROS_LABEL,
};
pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
pub use refs::validate_entity_refs;
@@ -0,0 +1,917 @@
//! Cómputo de los agregados de un tablero (`DashboardCard`).
use std::collections::BTreeMap;
use serde_json::Value;
use uuid::Uuid;
use std::cmp::Ordering;
use nahual_meta_schema::{CardFilter, DateBucket, FilterOp, Metric};
/// Resultado de computar una [`Metric`] sobre un conjunto de records.
#[derive(Debug, Clone, PartialEq)]
pub enum MetricResult {
/// Un único número — `Count` / `Sum` / `Avg` / `Min` / `Max`.
Scalar(f64),
/// Conteo por grupo, ordenado de mayor a menor — `GroupBy`.
Breakdown(Vec<(String, usize)>),
/// Valor numérico agregado por grupo, ordenado de mayor a menor —
/// `SumBy` / `AvgBy`. Se formatea con el `ValueFormat` de la
/// tarjeta (p.ej. moneda), a diferencia del conteo de `Breakdown`.
ValueBreakdown(Vec<(String, f64)>),
/// Desglose de **dos dimensiones** — `SumBySeries`. `groups` es el
/// eje principal (x), ordenado; `series` es una lista de
/// `(etiqueta_serie, valores)` donde `valores[i]` es el agregado de
/// esa serie en `groups[i]` (0.0 si no hay datos). Cada serie queda
/// alineada 1:1 con `groups`. Las series se ordenan por su total
/// descendente.
MultiBreakdown {
groups: Vec<String>,
series: Vec<(String, Vec<f64>)>,
},
}
/// Computa el agregado de una tarjeta sobre `records`, aplicando el
/// `filter` si lo hay.
pub fn compute_metric(
metric: &Metric,
filter: Option<&CardFilter>,
records: &[(Uuid, Value)],
) -> MetricResult {
let passes = |v: &Value| match filter {
None => true,
Some(f) => filter_passes(v, f),
};
match metric {
Metric::Count => {
let n = records.iter().filter(|(_, v)| passes(v)).count();
MetricResult::Scalar(n as f64)
}
Metric::Sum { field } => {
let total: f64 = records
.iter()
.filter(|(_, v)| passes(v))
.filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
.sum();
MetricResult::Scalar(total)
}
Metric::Avg { field } => {
let nums: Vec<f64> = records
.iter()
.filter(|(_, v)| passes(v))
.filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
.collect();
let avg = if nums.is_empty() {
0.0
} else {
nums.iter().sum::<f64>() / nums.len() as f64
};
MetricResult::Scalar(avg)
}
Metric::Min { field } => {
let m = records
.iter()
.filter(|(_, v)| passes(v))
.filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
.fold(f64::INFINITY, f64::min);
MetricResult::Scalar(if m.is_finite() { m } else { 0.0 })
}
Metric::Max { field } => {
let m = records
.iter()
.filter(|(_, v)| passes(v))
.filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
.fold(f64::NEG_INFINITY, f64::max);
MetricResult::Scalar(if m.is_finite() { m } else { 0.0 })
}
Metric::CountDistinct { field } => {
let distinct: std::collections::BTreeSet<String> = records
.iter()
.filter(|(_, v)| passes(v))
.filter_map(|(_, v)| field_as_text(v, field).filter(|s| !s.is_empty()))
.collect();
MetricResult::Scalar(distinct.len() as f64)
}
Metric::GroupBy { field } => {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
let key = field_as_text(v, field).unwrap_or_else(|| "(vacío)".to_string());
*counts.entry(key).or_default() += 1;
}
let mut ranked: Vec<(String, usize)> = counts.into_iter().collect();
// Mayor conteo primero; empates ordenados por nombre.
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
MetricResult::Breakdown(ranked)
}
Metric::SumBy { group, value } => {
MetricResult::ValueBreakdown(grouped_aggregate(records, &passes, group, value, false))
}
Metric::AvgBy { group, value } => {
MetricResult::ValueBreakdown(grouped_aggregate(records, &passes, group, value, true))
}
Metric::SumBySeries {
group,
series,
value,
} => series_aggregate(records, &passes, group, series, value),
}
}
/// Acumula `value` por cada combinación de `group` × `series`,
/// devolviendo un [`MetricResult::MultiBreakdown`]: el eje `groups`
/// ordenado por total descendente y, por cada serie, sus valores
/// alineados 1:1 con `groups` (0.0 donde no hay datos). Las series
/// también se ordenan por total descendente.
fn series_aggregate(
records: &[(Uuid, Value)],
passes: &impl Fn(&Value) -> bool,
group: &str,
series: &str,
value: &str,
) -> MetricResult {
// (grupo, serie) → suma.
let mut acc: BTreeMap<(String, String), f64> = BTreeMap::new();
// Totales para ordenar ejes.
let mut group_total: BTreeMap<String, f64> = BTreeMap::new();
let mut series_total: BTreeMap<String, f64> = BTreeMap::new();
for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
let Some(n) = v.get(value).and_then(Value::as_f64) else {
continue;
};
let g = field_as_text(v, group).unwrap_or_else(|| "(vacío)".to_string());
let s = field_as_text(v, series).unwrap_or_else(|| "(vacío)".to_string());
*acc.entry((g.clone(), s.clone())).or_default() += n;
*group_total.entry(g).or_default() += n;
*series_total.entry(s).or_default() += n;
}
// Ejes ordenados por total desc, empates por nombre.
let rank = |m: BTreeMap<String, f64>| -> Vec<String> {
let mut v: Vec<(String, f64)> = m.into_iter().collect();
v.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
v.into_iter().map(|(k, _)| k).collect()
};
let groups = rank(group_total);
let series_keys = rank(series_total);
let series: Vec<(String, Vec<f64>)> = series_keys
.into_iter()
.map(|s| {
let row = groups
.iter()
.map(|g| acc.get(&(g.clone(), s.clone())).copied().unwrap_or(0.0))
.collect();
(s, row)
})
.collect();
MetricResult::MultiBreakdown { groups, series }
}
/// Acumula `value` por cada valor distinto de `group`, devolviendo la
/// suma (`avg = false`) o el promedio (`avg = true`) por grupo,
/// ordenado de mayor a menor (empates por nombre de grupo).
fn grouped_aggregate(
records: &[(Uuid, Value)],
passes: &impl Fn(&Value) -> bool,
group: &str,
value: &str,
avg: bool,
) -> Vec<(String, f64)> {
// (suma, cuenta-de-numéricos) por grupo.
let mut acc: BTreeMap<String, (f64, usize)> = BTreeMap::new();
for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
let key = field_as_text(v, group).unwrap_or_else(|| "(vacío)".to_string());
let entry = acc.entry(key).or_insert((0.0, 0));
if let Some(n) = v.get(value).and_then(Value::as_f64) {
entry.0 += n;
entry.1 += 1;
}
}
let mut ranked: Vec<(String, f64)> = acc
.into_iter()
.map(|(k, (sum, count))| {
let out = if avg && count > 0 {
sum / count as f64
} else if avg {
0.0
} else {
sum
};
(k, out)
})
.collect();
ranked.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
ranked
}
/// Versión pública del predicado de filtro: decide si un record entra
/// dado un [`CardFilter`]. Útil para componer filtros fuera del motor
/// (p.ej. controles interactivos que pre-filtran los records).
pub fn record_matches(v: &Value, f: &CardFilter) -> bool {
filter_passes(v, f)
}
/// Decide si un record pasa el filtro de una tarjeta. Las comparaciones
/// de orden (`gt`/`lt`/`between`) son numéricas cuando ambos lados
/// parsean como número, y lexicográficas si no — lo que cubre rangos
/// de fecha en ISO-8601 sin parser de fechas.
fn filter_passes(v: &Value, f: &CardFilter) -> bool {
let cell = field_as_text(v, &f.field);
match f.op {
FilterOp::Eq => cell.as_deref() == f.value.as_deref(),
FilterOp::Ne => cell.as_deref() != f.value.as_deref(),
FilterOp::NonEmpty => cell.map(|s| !s.is_empty()).unwrap_or(false),
FilterOp::Gt | FilterOp::Gte | FilterOp::Lt | FilterOp::Lte => {
let (Some(cell), Some(bound)) = (cell, f.value.as_ref()) else {
return false;
};
let ord = cmp_text(&cell, bound);
match f.op {
FilterOp::Gt => ord == Ordering::Greater,
FilterOp::Gte => ord != Ordering::Less,
FilterOp::Lt => ord == Ordering::Less,
FilterOp::Lte => ord != Ordering::Greater,
_ => unreachable!(),
}
}
FilterOp::Between => {
let Some(cell) = cell else {
return false;
};
let lo_ok = f
.min
.as_ref()
.map_or(true, |lo| cmp_text(&cell, lo) != Ordering::Less);
let hi_ok = f
.max
.as_ref()
.map_or(true, |hi| cmp_text(&cell, hi) != Ordering::Greater);
lo_ok && hi_ok
}
}
}
/// Orden entre dos valores como texto: numérico si ambos parsean,
/// lexicográfico en caso contrario.
fn cmp_text(a: &str, b: &str) -> Ordering {
match (a.parse::<f64>(), b.parse::<f64>()) {
(Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
_ => a.cmp(b),
}
}
/// Serializa un desglose (`Breakdown` conteo o `ValueBreakdown` valor)
/// a CSV de dos columnas. `value_header` rotula la segunda columna.
/// Reusa el `to_csv` del runtime para el quoting.
pub fn breakdown_to_csv(
result: &MetricResult,
group_header: &str,
value_header: &str,
) -> Option<String> {
let rows: Vec<Vec<String>> = match result {
MetricResult::Breakdown(rows) => rows
.iter()
.map(|(k, n)| vec![k.clone(), n.to_string()])
.collect(),
MetricResult::ValueBreakdown(rows) => rows
.iter()
.map(|(k, v)| {
let n = if v.fract() == 0.0 {
format!("{}", *v as i64)
} else {
v.to_string()
};
vec![k.clone(), n]
})
.collect(),
// Matriz: una columna por serie. `value_header` se ignora — los
// encabezados de valor son los nombres de las series.
MetricResult::MultiBreakdown { groups, series } => {
let mut headers = vec![group_header.to_string()];
headers.extend(series.iter().map(|(name, _)| name.clone()));
let rows: Vec<Vec<String>> = groups
.iter()
.enumerate()
.map(|(i, g)| {
let mut row = vec![g.clone()];
for (_, vals) in series {
let v = vals.get(i).copied().unwrap_or(0.0);
let n = if v.fract() == 0.0 {
format!("{}", v as i64)
} else {
v.to_string()
};
row.push(n);
}
row
})
.collect();
return Some(crate::csv::to_csv(&headers, &rows));
}
MetricResult::Scalar(_) => return None,
};
Some(crate::csv::to_csv(
&[group_header.to_string(), value_header.to_string()],
&rows,
))
}
/// Etiqueta de la fila que agrupa el resto de un desglose recortado.
pub const OTROS_LABEL: &str = "Otros";
/// Recorta un desglose a sus `limit` filas de mayor valor (el motor ya
/// las entrega ordenadas de mayor a menor) y colapsa el resto en una
/// fila [`OTROS_LABEL`]. Devuelve `true` si efectivamente agregó esa
/// fila (hubo más de `limit` grupos), para que la capa de presentación
/// la marque como no-navegable.
///
/// El valor de "Otros" es la **suma** del resto para conteos
/// (`GroupBy`) y para sumas (`SumBy`, `additive = true`); para
/// promedios (`AvgBy`, `additive = false`) es el promedio aritmético de
/// los valores de los grupos restantes (aproximación clara: la fila
/// "Otros" no es un grupo real). `limit == 0` no recorta nada.
pub fn limit_breakdown(result: &mut MetricResult, limit: usize, additive: bool) -> bool {
if limit == 0 {
return false;
}
match result {
MetricResult::Breakdown(rows) if rows.len() > limit => {
let tail: usize = rows[limit..].iter().map(|(_, n)| *n).sum();
rows.truncate(limit);
rows.push((OTROS_LABEL.to_string(), tail));
true
}
MetricResult::ValueBreakdown(rows) if rows.len() > limit => {
let rest = &rows[limit..];
let value = if additive {
rest.iter().map(|(_, v)| *v).sum()
} else {
let sum: f64 = rest.iter().map(|(_, v)| *v).sum();
sum / rest.len() as f64
};
rows.truncate(limit);
rows.push((OTROS_LABEL.to_string(), value));
true
}
// El recorte top-N no aplica a desgloses de dos dimensiones.
_ => false,
}
}
/// Trunca una fecha ISO-8601 a la granularidad pedida, para agrupar
/// series temporales (`2026-01-15T10:30Z` / `2026-01-15` → `2026`,
/// `2026-01` o `2026-01-15`). Si `raw` no empieza con un año de 4
/// dígitos seguido de `-` (o no es una fecha reconocible), se devuelve
/// sin cambios — el bucketing es inofensivo sobre datos no-fecha.
pub fn bucket_date(raw: &str, bucket: DateBucket) -> String {
// Reconocer el prefijo `YYYY-`: 4 dígitos + guión.
let looks_like_date = raw.len() >= 5
&& raw.as_bytes()[..4].iter().all(u8::is_ascii_digit)
&& raw.as_bytes()[4] == b'-';
if !looks_like_date {
return raw.to_string();
}
// Fecha = los primeros 10 chars (`YYYY-MM-DD`) si los hay.
let date = raw.get(0..10).unwrap_or(raw);
let end = match bucket {
DateBucket::Year => 4,
DateBucket::Month => 7,
DateBucket::Day => 10,
};
date.get(0..end).unwrap_or(date).to_string()
}
/// Reordena las filas de un desglose por su clave de grupo ascendente
/// (lexicográfico — coincide con el orden cronológico para claves ISO
/// de [`bucket_date`]). No-op sobre escalares.
pub fn sort_breakdown_by_key(result: &mut MetricResult) {
match result {
MetricResult::Breakdown(rows) => rows.sort_by(|a, b| a.0.cmp(&b.0)),
MetricResult::ValueBreakdown(rows) => rows.sort_by(|a, b| a.0.cmp(&b.0)),
// Reordena el eje `groups` por clave y permuta cada serie con la
// misma permutación, manteniendo la alineación 1:1.
MetricResult::MultiBreakdown { groups, series } => {
let mut perm: Vec<usize> = (0..groups.len()).collect();
perm.sort_by(|&a, &b| groups[a].cmp(&groups[b]));
*groups = perm.iter().map(|&i| groups[i].clone()).collect();
for (_, vals) in series.iter_mut() {
*vals = perm.iter().map(|&i| vals[i]).collect();
}
}
MetricResult::Scalar(_) => {}
}
}
/// Reescribe un desglose ordenado a su **acumulado** (running total):
/// cada valor pasa a ser la suma corrida de sí mismo y todos los
/// anteriores. Pensado para series temporales (orden cronológico, vía
/// `bucket`) — p.ej. el saldo acumulado de tesorería mes a mes. En
/// multi-serie, cada serie acumula de forma independiente a lo largo
/// del eje de grupos. `Breakdown` (conteo) se acumula igual. No-op
/// sobre `Scalar`. Asume que `result` ya está en el orden deseado
/// (típicamente tras `sort_breakdown_by_key`); sólo tiene sentido sobre
/// métricas aditivas (`Count`/`Sum`).
pub fn cumulative_breakdown(result: &mut MetricResult) {
match result {
MetricResult::Breakdown(rows) => {
let mut acc = 0usize;
for (_, v) in rows.iter_mut() {
acc += *v;
*v = acc;
}
}
MetricResult::ValueBreakdown(rows) => {
let mut acc = 0.0;
for (_, v) in rows.iter_mut() {
acc += *v;
*v = acc;
}
}
MetricResult::MultiBreakdown { series, .. } => {
for (_, vals) in series.iter_mut() {
let mut acc = 0.0;
for v in vals.iter_mut() {
acc += *v;
*v = acc;
}
}
}
MetricResult::Scalar(_) => {}
}
}
/// Valor de un campo de nivel superior como texto plano, para comparar
/// (filtros) o agrupar (`GroupBy`).
fn field_as_text(v: &Value, field: &str) -> Option<String> {
match v.get(field)? {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn recs(items: &[Value]) -> Vec<(Uuid, Value)> {
items.iter().map(|v| (Uuid::new_v4(), v.clone())).collect()
}
#[test]
fn bucket_date_truncates_iso() {
assert_eq!(bucket_date("2026-01-15", DateBucket::Year), "2026");
assert_eq!(bucket_date("2026-01-15", DateBucket::Month), "2026-01");
assert_eq!(bucket_date("2026-01-15", DateBucket::Day), "2026-01-15");
// Con hora.
assert_eq!(bucket_date("2026-03-09T10:30:00Z", DateBucket::Month), "2026-03");
// No-fecha → sin cambios (inofensivo).
assert_eq!(bucket_date("activo", DateBucket::Month), "activo");
assert_eq!(bucket_date("", DateBucket::Year), "");
}
#[test]
fn sort_breakdown_by_key_is_chronological_for_iso() {
let mut r = MetricResult::ValueBreakdown(vec![
("2026-03".into(), 30.0),
("2026-01".into(), 10.0),
("2026-02".into(), 20.0),
]);
sort_breakdown_by_key(&mut r);
assert_eq!(
r,
MetricResult::ValueBreakdown(vec![
("2026-01".into(), 10.0),
("2026-02".into(), 20.0),
("2026-03".into(), 30.0),
])
);
}
#[test]
fn cumulative_breakdown_running_total() {
// Serie de valores → saldo acumulado.
let mut r = MetricResult::ValueBreakdown(vec![
("2026-01".into(), 10.0),
("2026-02".into(), 20.0),
("2026-03".into(), 5.0),
]);
cumulative_breakdown(&mut r);
assert_eq!(
r,
MetricResult::ValueBreakdown(vec![
("2026-01".into(), 10.0),
("2026-02".into(), 30.0),
("2026-03".into(), 35.0),
])
);
// Conteo acumulado.
let mut c = MetricResult::Breakdown(vec![("a".into(), 2), ("b".into(), 3), ("c".into(), 1)]);
cumulative_breakdown(&mut c);
assert_eq!(
c,
MetricResult::Breakdown(vec![("a".into(), 2), ("b".into(), 5), ("c".into(), 6)])
);
// Multi-serie: cada serie acumula por separado.
let mut m = MetricResult::MultiBreakdown {
groups: vec!["ene".into(), "feb".into(), "mar".into()],
series: vec![
("pagado".into(), vec![1.0, 2.0, 3.0]),
("no".into(), vec![10.0, 0.0, 5.0]),
],
};
cumulative_breakdown(&mut m);
assert_eq!(
m,
MetricResult::MultiBreakdown {
groups: vec!["ene".into(), "feb".into(), "mar".into()],
series: vec![
("pagado".into(), vec![1.0, 3.0, 6.0]),
("no".into(), vec![10.0, 10.0, 15.0]),
],
}
);
// No-op sobre escalar.
let mut s = MetricResult::Scalar(42.0);
cumulative_breakdown(&mut s);
assert_eq!(s, MetricResult::Scalar(42.0));
}
#[test]
fn limit_breakdown_counts_sums_the_tail() {
let mut r = MetricResult::Breakdown(vec![
("a".into(), 10),
("b".into(), 6),
("c".into(), 3),
("d".into(), 1),
]);
let collapsed = limit_breakdown(&mut r, 2, true);
assert!(collapsed);
assert_eq!(
r,
MetricResult::Breakdown(vec![
("a".into(), 10),
("b".into(), 6),
(OTROS_LABEL.into(), 4), // 3 + 1
])
);
}
#[test]
fn limit_breakdown_avg_bucket_is_mean_not_sum() {
let mut r = MetricResult::ValueBreakdown(vec![
("a".into(), 100.0),
("b".into(), 80.0),
("c".into(), 60.0),
("d".into(), 40.0),
]);
// additive = false (AvgBy): el bucket promedia los restantes.
let collapsed = limit_breakdown(&mut r, 2, false);
assert!(collapsed);
assert_eq!(
r,
MetricResult::ValueBreakdown(vec![
("a".into(), 100.0),
("b".into(), 80.0),
(OTROS_LABEL.into(), 50.0), // (60 + 40) / 2
])
);
}
#[test]
fn limit_breakdown_noop_when_within_limit() {
let mut r = MetricResult::Breakdown(vec![("a".into(), 3), ("b".into(), 1)]);
let before = r.clone();
assert!(!limit_breakdown(&mut r, 5, true));
assert!(!limit_breakdown(&mut r, 0, true));
assert_eq!(r, before);
}
#[test]
fn count_all_and_filtered() {
let rs = recs(&[
json!({"etapa": "ganada"}),
json!({"etapa": "ganada"}),
json!({"etapa": "perdida"}),
]);
assert_eq!(
compute_metric(&Metric::Count, None, &rs),
MetricResult::Scalar(3.0)
);
let f = CardFilter {
field: "etapa".into(),
op: FilterOp::Eq,
value: Some("ganada".into()),
min: None,
max: None,
};
assert_eq!(
compute_metric(&Metric::Count, Some(&f), &rs),
MetricResult::Scalar(2.0)
);
}
#[test]
fn count_distinct_counts_unique_non_empty_values() {
let rs = recs(&[
json!({"cliente": "acme"}),
json!({"cliente": "acme"}),
json!({"cliente": "globex"}),
json!({"cliente": ""}), // vacío → no cuenta
json!({"otro": "x"}), // sin el campo → no cuenta
]);
assert_eq!(
compute_metric(&Metric::CountDistinct { field: "cliente".into() }, None, &rs),
MetricResult::Scalar(2.0) // acme, globex
);
// Con filtro: sólo los records que pasan entran al set distinto.
let f = CardFilter {
field: "cliente".into(),
op: FilterOp::Eq,
value: Some("acme".into()),
min: None,
max: None,
};
assert_eq!(
compute_metric(&Metric::CountDistinct { field: "cliente".into() }, Some(&f), &rs),
MetricResult::Scalar(1.0)
);
}
fn filt(field: &str, op: FilterOp, value: Option<&str>) -> CardFilter {
CardFilter {
field: field.into(),
op,
value: value.map(Into::into),
min: None,
max: None,
}
}
#[test]
fn numeric_range_filters() {
let rs = recs(&[
json!({"monto": 100}),
json!({"monto": 500}),
json!({"monto": 900}),
]);
// gte 500 → 500 y 900.
assert_eq!(
compute_metric(&Metric::Count, Some(&filt("monto", FilterOp::Gte, Some("500"))), &rs),
MetricResult::Scalar(2.0)
);
// lt 500 → solo 100.
assert_eq!(
compute_metric(&Metric::Count, Some(&filt("monto", FilterOp::Lt, Some("500"))), &rs),
MetricResult::Scalar(1.0)
);
// between [200, 800] → solo 500.
let between = CardFilter {
field: "monto".into(),
op: FilterOp::Between,
value: None,
min: Some("200".into()),
max: Some("800".into()),
};
assert_eq!(
compute_metric(&Metric::Count, Some(&between), &rs),
MetricResult::Scalar(1.0)
);
}
#[test]
fn date_range_is_lexicographic() {
let rs = recs(&[
json!({"fecha": "2026-01-15"}),
json!({"fecha": "2026-06-30"}),
json!({"fecha": "2027-02-01"}),
]);
let q1_h1 = CardFilter {
field: "fecha".into(),
op: FilterOp::Between,
value: None,
min: Some("2026-01-01".into()),
max: Some("2026-12-31".into()),
};
assert_eq!(
compute_metric(&Metric::Count, Some(&q1_h1), &rs),
MetricResult::Scalar(2.0)
);
}
#[test]
fn non_empty_filter() {
let rs = recs(&[json!({"nota": "x"}), json!({"nota": ""}), json!({"otro": 1})]);
assert_eq!(
compute_metric(&Metric::Count, Some(&filt("nota", FilterOp::NonEmpty, None)), &rs),
MetricResult::Scalar(1.0)
);
}
#[test]
fn breakdown_csv_roundtrip() {
let res = MetricResult::ValueBreakdown(vec![
("ACME".into(), 1500.0),
("Globex".into(), 2000.0),
]);
let csv = breakdown_to_csv(&res, "Cliente", "Monto").unwrap();
assert_eq!(csv, "Cliente,Monto\nACME,1500\nGlobex,2000\n");
assert!(breakdown_to_csv(&MetricResult::Scalar(1.0), "a", "b").is_none());
}
#[test]
fn sum_skips_missing_and_non_numeric() {
let rs = recs(&[
json!({"monto": 1000}),
json!({"monto": 2500}),
json!({"otro": 1}),
]);
assert_eq!(
compute_metric(
&Metric::Sum {
field: "monto".into()
},
None,
&rs
),
MetricResult::Scalar(3500.0)
);
}
#[test]
fn group_by_counts_and_ranks_by_frequency() {
let rs = recs(&[
json!({"etapa": "prospecto"}),
json!({"etapa": "ganada"}),
json!({"etapa": "ganada"}),
]);
assert_eq!(
compute_metric(
&Metric::GroupBy {
field: "etapa".into()
},
None,
&rs
),
MetricResult::Breakdown(vec![
("ganada".to_string(), 2),
("prospecto".to_string(), 1),
])
);
}
#[test]
fn avg_min_max_over_numeric() {
let rs = recs(&[
json!({"monto": 100}),
json!({"monto": 300}),
json!({"otro": 1}), // ignorado
]);
assert_eq!(
compute_metric(&Metric::Avg { field: "monto".into() }, None, &rs),
MetricResult::Scalar(200.0)
);
assert_eq!(
compute_metric(&Metric::Min { field: "monto".into() }, None, &rs),
MetricResult::Scalar(100.0)
);
assert_eq!(
compute_metric(&Metric::Max { field: "monto".into() }, None, &rs),
MetricResult::Scalar(300.0)
);
}
#[test]
fn avg_empty_is_zero_not_nan() {
let rs = recs(&[json!({"otro": 1})]);
assert_eq!(
compute_metric(&Metric::Avg { field: "monto".into() }, None, &rs),
MetricResult::Scalar(0.0)
);
assert_eq!(
compute_metric(&Metric::Min { field: "monto".into() }, None, &rs),
MetricResult::Scalar(0.0)
);
}
#[test]
fn sum_by_aggregates_and_ranks_by_value() {
let rs = recs(&[
json!({"cliente": "ACME", "monto": 1000}),
json!({"cliente": "ACME", "monto": 500}),
json!({"cliente": "Globex", "monto": 2000}),
]);
assert_eq!(
compute_metric(
&Metric::SumBy {
group: "cliente".into(),
value: "monto".into()
},
None,
&rs
),
MetricResult::ValueBreakdown(vec![
("Globex".to_string(), 2000.0),
("ACME".to_string(), 1500.0),
])
);
}
#[test]
fn sum_by_series_builds_aligned_matrix() {
let rs = recs(&[
json!({"mes": "2026-01", "plan": "pro", "monto": 100}),
json!({"mes": "2026-01", "plan": "free", "monto": 30}),
json!({"mes": "2026-02", "plan": "pro", "monto": 200}),
// free no factura en feb → 0.0 alineado.
]);
let r = compute_metric(
&Metric::SumBySeries {
group: "mes".into(),
series: "plan".into(),
value: "monto".into(),
},
None,
&rs,
);
// groups por total desc: feb(200) > ene(130). series por total
// desc: pro(300) > free(30).
assert_eq!(
r,
MetricResult::MultiBreakdown {
groups: vec!["2026-02".into(), "2026-01".into()],
series: vec![
("pro".into(), vec![200.0, 100.0]),
("free".into(), vec![0.0, 30.0]),
],
}
);
}
#[test]
fn sort_multi_breakdown_permutes_series() {
let mut r = MetricResult::MultiBreakdown {
groups: vec!["2026-02".into(), "2026-01".into()],
series: vec![
("pro".into(), vec![200.0, 100.0]),
("free".into(), vec![0.0, 30.0]),
],
};
sort_breakdown_by_key(&mut r);
// groups cronológicos; cada serie sigue la misma permutación.
assert_eq!(
r,
MetricResult::MultiBreakdown {
groups: vec!["2026-01".into(), "2026-02".into()],
series: vec![
("pro".into(), vec![100.0, 200.0]),
("free".into(), vec![30.0, 0.0]),
],
}
);
}
#[test]
fn multi_breakdown_csv_is_a_matrix() {
let r = MetricResult::MultiBreakdown {
groups: vec!["2026-01".into(), "2026-02".into()],
series: vec![
("pro".into(), vec![100.0, 200.0]),
("free".into(), vec![30.0, 0.0]),
],
};
let csv = breakdown_to_csv(&r, "Mes", "ignorado").unwrap();
assert_eq!(csv, "Mes,pro,free\n2026-01,100,30\n2026-02,200,0\n");
}
#[test]
fn avg_by_is_per_group_mean() {
let rs = recs(&[
json!({"plan": "pro", "monto": 100}),
json!({"plan": "pro", "monto": 300}),
json!({"plan": "free", "monto": 50}),
]);
assert_eq!(
compute_metric(
&Metric::AvgBy {
group: "plan".into(),
value: "monto".into()
},
None,
&rs
),
MetricResult::ValueBreakdown(vec![
("pro".to_string(), 200.0),
("free".to_string(), 50.0),
])
);
}
}
@@ -0,0 +1,263 @@
//! Parseo de inputs del form a `serde_json::Value` tipado.
use serde_json::{json, Value};
use uuid::Uuid;
use nahual_meta_schema::{FieldKind, FieldSpec};
/// Convierte el texto raw de un input al `Value` tipado según el
/// `kind` del spec.
///
/// - `Text` / `Multiline` / `Date` → string passthrough.
/// - `EntityRef` → string del UUID **trimmed**, validado como UUID
/// parseable. Falla con mensaje claro si no parsea.
/// - `Boolean` → variantes comunes (`true/yes/1/on/y` y `false/no/0/off/n`).
/// - `Number` → i64 si parsea, sino f64.
pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
match kind {
// Select y AutoId guardan un string: el valor de la opción
// elegida y el UUID autogenerado, respectivamente.
FieldKind::Text
| FieldKind::Multiline
| FieldKind::Date
| FieldKind::Select
| FieldKind::AutoId => Ok(json!(raw)),
// EntityRef se almacena como string del UUID seleccionado.
// El selector clickable garantiza UUIDs válidos en happy
// path; este check protege paste manual o garbage tipeado.
FieldKind::EntityRef => {
let trimmed = raw.trim();
Uuid::parse_str(trimmed)
.map_err(|_| format!("'{raw}' no es UUID válido (usá el selector de records)"))?;
Ok(json!(trimmed))
}
FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
"true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
"" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
other => Err(format!("'{other}' no es booleano")),
},
FieldKind::Number => {
if let Ok(i) = raw.parse::<i64>() {
Ok(json!(i))
} else if let Ok(f) = raw.parse::<f64>() {
Ok(json!(f))
} else {
Err(format!("'{raw}' no es número"))
}
}
}
}
/// Resuelve un param de morphism a su `Value` según el `FieldSpec`
/// del form. **Strict path**: si hay spec, valida `required` y parsea
/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de
/// llegar al morphism). **Fallback path**: si no hay spec (param
/// declarado en `Action::Morphism.params` que no aparece en
/// `form.fields`), usa la heurística [`infer_param_value`] para no
/// quedar atado a un schema mal-formado.
///
/// Errores tienen el label legible del spec, así el toast de la UI
/// es interpretable.
pub fn resolve_param_value(
field_name: &str,
raw: &str,
spec: Option<&FieldSpec>,
) -> Result<Value, String> {
let Some(s) = spec else {
return Ok(infer_param_value(raw));
};
let label = if s.label.is_empty() {
field_name
} else {
&s.label
};
if s.required && raw.trim().is_empty() {
return Err(format!("param '{label}' es obligatorio y está vacío"));
}
if raw.is_empty() && !s.required {
return Ok(Value::Null);
}
parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
}
/// Inferencia de tipo para values pasados como `params` a un
/// morphism. Usada como fallback en [`resolve_param_value`] cuando el
/// param declarado en `Action::Morphism.params` no aparece en los
/// `form.fields` (módulo mal-formado).
///
/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
/// resto → string.
pub fn infer_param_value(raw: &str) -> Value {
if raw.is_empty() {
return Value::Null;
}
if let Ok(i) = raw.parse::<i64>() {
return json!(i);
}
if let Ok(f) = raw.parse::<f64>() {
return json!(f);
}
match raw {
"true" => return json!(true),
"false" => return json!(false),
_ => {}
}
json!(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use nahual_meta_schema::FieldSpec;
fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec {
FieldSpec {
name: name.into(),
label: name.into(),
kind,
default: None,
required,
help: None,
ref_entity: None,
options: Vec::new(),
section: None,
}
}
#[test]
fn infer_handles_basic_types() {
assert_eq!(infer_param_value(""), Value::Null);
assert_eq!(infer_param_value("42"), json!(42));
assert_eq!(infer_param_value("2.5"), json!(2.5));
assert_eq!(infer_param_value("true"), json!(true));
assert_eq!(infer_param_value("false"), json!(false));
assert_eq!(infer_param_value("hola"), json!("hola"));
}
#[test]
fn parse_text_passthrough() {
let v = parse_field_value(FieldKind::Text, "hola").unwrap();
assert_eq!(v, json!("hola"));
}
#[test]
fn parse_select_and_auto_id_passthrough() {
// Select guarda el valor de la opción elegida.
assert_eq!(
parse_field_value(FieldKind::Select, "ganada").unwrap(),
json!("ganada")
);
// AutoId guarda el UUID autogenerado tal cual.
let id = Uuid::new_v4().to_string();
assert_eq!(
parse_field_value(FieldKind::AutoId, &id).unwrap(),
json!(id)
);
}
#[test]
fn parse_number_i64_or_f64() {
assert_eq!(
parse_field_value(FieldKind::Number, "42").unwrap(),
json!(42)
);
assert_eq!(
parse_field_value(FieldKind::Number, "2.5").unwrap(),
json!(2.5)
);
assert!(parse_field_value(FieldKind::Number, "abc").is_err());
}
#[test]
fn parse_boolean_recognizes_variants() {
for s in ["true", "yes", "1", "on", "y"] {
assert_eq!(
parse_field_value(FieldKind::Boolean, s).unwrap(),
json!(true)
);
}
for s in ["false", "no", "0", "off", "n", ""] {
assert_eq!(
parse_field_value(FieldKind::Boolean, s).unwrap(),
json!(false)
);
}
assert!(parse_field_value(FieldKind::Boolean, "abc").is_err());
}
#[test]
fn parse_entity_ref_accepts_valid_uuid() {
let id = Uuid::new_v4();
let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
assert_eq!(v, json!(id.to_string()));
}
#[test]
fn parse_entity_ref_trims_whitespace() {
let id = Uuid::new_v4();
let padded = format!(" {id}\n");
let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
assert_eq!(v, json!(id.to_string()));
}
#[test]
fn parse_entity_ref_rejects_non_uuid() {
let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
assert!(err.contains("'abc-123'"));
assert!(err.contains("UUID") || err.contains("uuid"));
}
#[test]
fn parse_entity_ref_rejects_empty_string() {
let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
assert!(err.contains("UUID"));
}
#[test]
fn resolve_param_strict_number_parses_i64() {
let s = spec("qty", FieldKind::Number, true);
let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
assert_eq!(v, json!(42));
}
#[test]
fn resolve_param_strict_boolean_rejects_non_boolean() {
let s = spec("active", FieldKind::Boolean, true);
let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
assert!(err.contains("active"));
}
#[test]
fn resolve_param_required_empty_rejected() {
let s = spec("name", FieldKind::Text, true);
let err = resolve_param_value("name", " ", Some(&s)).unwrap_err();
assert!(err.contains("obligatorio"));
}
#[test]
fn resolve_param_optional_empty_returns_null() {
let s = spec("notes", FieldKind::Text, false);
let v = resolve_param_value("notes", "", Some(&s)).unwrap();
assert_eq!(v, Value::Null);
}
#[test]
fn resolve_param_no_spec_falls_back_to_infer() {
let v = resolve_param_value("foo", "42", None).unwrap();
assert_eq!(v, json!(42));
let v = resolve_param_value("foo", "true", None).unwrap();
assert_eq!(v, json!(true));
let v = resolve_param_value("foo", "x", None).unwrap();
assert_eq!(v, json!("x"));
}
#[test]
fn resolve_param_strict_entity_ref_propagates_error() {
let s = spec("stock_ref", FieldKind::EntityRef, true);
let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
assert!(err.contains("stock_ref"));
assert!(err.contains("UUID"));
}
}
@@ -0,0 +1,108 @@
//! Validación cross-field de EntityRefs contra el store actual.
//!
//! Decoupling: en vez de un `trait Store` que ate este crate a un
//! backend específico, tomamos un cierre `load: Fn(&str, Uuid) ->
//! Option<Value>`. El caller (nakui-ui o cualquier otro runtime)
//! puede pasarlo trivialmente sobre cualquier store (MemoryStore,
//! SurrealStore, mock, ...).
use serde_json::Value;
use uuid::Uuid;
use crate::format::short_uuid;
/// Valida que cada UUID en `refs` apunte a un record que realmente
/// existe en el store bajo la entity esperada. Devuelve el primer
/// error encontrado (fail-fast).
///
/// `refs` es una lista de `(label, target_entity, uuid)`. El label
/// va al error message, así que conviene que sea legible (ej:
/// `FieldSpec.label` en lugar de `FieldSpec.name`).
///
/// `load` es el cierre que el caller usa para mirar el store —
/// típicamente `|e, id| store.load(e, id)`.
pub fn validate_entity_refs<F>(load: F, refs: &[(String, String, Uuid)]) -> Result<(), String>
where
F: Fn(&str, Uuid) -> Option<Value>,
{
for (label, target, id) in refs {
if load(target, *id).is_none() {
return Err(format!(
"campo '{label}': record {} de '{target}' no existe en el store",
short_uuid(id)
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
/// "Mock store" minimalista para tests: HashMap por (entity, uuid).
fn mk_load(records: HashMap<(String, Uuid), Value>) -> impl Fn(&str, Uuid) -> Option<Value> {
move |e, id| records.get(&(e.to_string(), id)).cloned()
}
#[test]
fn passes_when_all_records_exist() {
let stock = Uuid::new_v4();
let caja = Uuid::new_v4();
let mut records = HashMap::new();
records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
records.insert(("Caja".into(), caja), json!({"name": "Principal"}));
let load = mk_load(records);
let refs = vec![
("Stock".into(), "Stock".into(), stock),
("Caja".into(), "Caja".into(), caja),
];
assert!(validate_entity_refs(load, &refs).is_ok());
}
#[test]
fn fails_on_first_missing() {
let stock = Uuid::new_v4();
let mut records = HashMap::new();
records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
let load = mk_load(records);
let missing_caja = Uuid::new_v4();
let refs = vec![
("Stock".into(), "Stock".into(), stock),
("Caja".into(), "Caja".into(), missing_caja),
];
let err = validate_entity_refs(load, &refs).unwrap_err();
assert!(err.contains("Caja"));
assert!(err.contains(&short_uuid(&missing_caja)));
}
#[test]
fn uses_label_not_entity_in_msg() {
let load = |_: &str, _: Uuid| -> Option<Value> { None };
let id = Uuid::new_v4();
let refs = vec![("Stock origen".into(), "Stock".into(), id)];
let err = validate_entity_refs(load, &refs).unwrap_err();
assert!(err.contains("Stock origen"));
}
#[test]
fn empty_list_is_ok() {
let load = |_: &str, _: Uuid| -> Option<Value> { None };
assert!(validate_entity_refs(load, &[]).is_ok());
}
#[test]
fn distinguishes_target_from_other_entities() {
let id = Uuid::new_v4();
let mut records = HashMap::new();
// Mismo UUID bajo Customer pero NO bajo Stock.
records.insert(("Customer".into(), id), json!({"name": "Acme"}));
let load = mk_load(records);
let refs = vec![("Stock".into(), "Stock".into(), id)];
assert!(validate_entity_refs(load, &refs).is_err());
}
}
@@ -0,0 +1,312 @@
//! Utilidades de testing para code que consume [`MetaBackend`].
//!
//! Provee [`MockBackend`]: implementación in-memory minimalista
//! del trait, sin acoplamiento a stores reales (event log,
//! SurrealDB, etc.). Útil para:
//!
//! - Tests del widget [`nahual_widget_meta_form::MetaApp`] que
//! necesitan un backend funcional sin levantar nakui-core.
//! - Tests de cualquier consumer que tome `B: MetaBackend` y quiera
//! asserts sobre lecturas/escrituras sin tocar disco.
//! - Fixtures pre-pobladas para demos/screenshots/CI.
//!
//! Está bajo `pub mod testing` (no `#[cfg(test)]`) deliberadamente
//! para que crates downstream puedan importarlo en sus dev/integ
//! tests. No tiene overhead en producción si no se usa.
use std::collections::{BTreeMap, HashMap};
use serde_json::Value;
use uuid::Uuid;
use crate::backend::{MetaBackend, WriteOutcome};
/// Backend in-memory para tests. Implementa el contrato completo
/// del [`MetaBackend`] con semantica simple:
///
/// - `seed`: genera Uuid v4, inserta record. `changed = 1`.
/// - `update`: aplica `set` (overrides) y `clear` (key removal).
/// Si ambos vacíos → `changed = 0`. Falla si record no existe.
/// - `delete`: remueve record. Falla si no existe.
/// - `morphism`: por default rebota con error
/// `"MockBackend no soporta morphism '<name>'"`. Si querés
/// simular morphisms, registrá callbacks via
/// [`MockBackend::with_morphism`].
/// - `list_records`: orden lexicográfico por id (estable).
/// - Sin `post_status`: el mock no tiene tick/compact.
///
/// Métodos de inspección públicos ([`total_records`],
/// [`records_for`], etc.) facilitan asserts en tests sin necesidad
/// de re-leer el state via las APIs del trait.
pub struct MockBackend {
records: HashMap<(String, Uuid), Value>,
morphisms: HashMap<String, MorphismHandler>,
}
type MorphismHandler =
Box<dyn Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync>;
impl Default for MockBackend {
fn default() -> Self {
Self::new()
}
}
impl MockBackend {
/// Backend vacío.
pub fn new() -> Self {
Self {
records: HashMap::new(),
morphisms: HashMap::new(),
}
}
/// Pre-popula el backend con records `(entity, uuid, data)`.
/// Útil para fixtures: asserts sobre lecturas sin tener que
/// armar seeds via `seed()`.
pub fn with_records<I>(records: I) -> Self
where
I: IntoIterator<Item = (String, Uuid, Value)>,
{
let mut b = Self::new();
for (entity, id, data) in records {
b.records.insert((entity, id), data);
}
b
}
/// Registra un handler para un morphism de nombre `name`.
/// El handler recibe inputs + params y devuelve `changed` o
/// `Err` para simular fallo del morphism. Sobrescribe cualquier
/// handler previo del mismo nombre.
pub fn with_morphism<F>(mut self, name: impl Into<String>, handler: F) -> Self
where
F: Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync + 'static,
{
self.morphisms.insert(name.into(), Box::new(handler));
self
}
/// Cantidad total de records en el backend (todas las entities).
pub fn total_records(&self) -> usize {
self.records.len()
}
/// Records de una entity como `Vec<(Uuid, &Value)>` sin clones
/// (más liviano que `list_records` cuando el caller sólo quiere
/// inspeccionar).
pub fn records_for<'a>(&'a self, entity: &str) -> Vec<(Uuid, &'a Value)> {
self.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v))
.collect()
}
}
impl MetaBackend for MockBackend {
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> {
let mut out: Vec<(Uuid, Value)> = self
.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v.clone()))
.collect();
out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
out
}
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value> {
self.records.get(&(entity.to_string(), id)).cloned()
}
fn seed(
&mut self,
entity: &str,
data: serde_json::Map<String, Value>,
) -> Result<WriteOutcome, String> {
let id = Uuid::new_v4();
self.records
.insert((entity.to_string(), id), Value::Object(data));
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String> {
if set.is_empty() && clear.is_empty() {
return Ok(WriteOutcome::no_change(id));
}
let rec = self
.records
.get_mut(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
let map = rec
.as_object_mut()
.ok_or_else(|| format!("not an object: {entity}/{id}"))?;
let changed = set.len() + clear.len();
for (k, v) in set {
map.insert(k, v);
}
for k in clear {
map.remove(&k);
}
Ok(WriteOutcome {
id: Some(id),
changed,
post_status: None,
})
}
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
self.records
.remove(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn morphism(
&mut self,
_module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String> {
match self.morphisms.get(name) {
Some(handler) => {
let changed = handler(&inputs, &params)?;
Ok(WriteOutcome {
id: None,
changed,
post_status: None,
})
}
None => Err(format!("MockBackend no soporta morphism '{name}'")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn with_records_populates_state() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
assert_eq!(b.total_records(), 1);
assert_eq!(b.load_record("Customer", id), Some(json!({"name": "Acme"})));
}
#[test]
fn seed_then_load_round_trip_via_trait() {
let mut b = MockBackend::new();
let out = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
let id = out.id.unwrap();
assert_eq!(out.changed, 1);
assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
}
#[test]
fn update_no_op_returns_no_change() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
let out = b.update("X", id, serde_json::Map::new(), vec![]).unwrap();
assert_eq!(out, WriteOutcome::no_change(id));
}
#[test]
fn update_set_and_clear_aplica_ambos() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([("X".into(), id, json!({"a": 1, "b": 2}))]);
let out = b
.update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
.unwrap();
assert_eq!(out.changed, 2);
let rec = b.load_record("X", id).unwrap();
assert_eq!(rec.get("a"), Some(&json!(10)));
assert!(rec.get("b").is_none());
}
#[test]
fn delete_then_load_returns_none() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
b.delete("X", id).unwrap();
assert!(b.load_record("X", id).is_none());
}
#[test]
fn morphism_without_handler_errors_clearly() {
let mut b = MockBackend::new();
let err = b
.morphism("mod", "foo", BTreeMap::new(), json!({}))
.unwrap_err();
assert!(err.contains("foo"));
}
#[test]
fn with_morphism_lets_caller_simulate_logic() {
let mut b = MockBackend::new().with_morphism("double_qty", |inputs, params| {
assert!(inputs.is_empty());
let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
if qty <= 0 {
return Err("qty must be positive".into());
}
Ok(qty as usize)
});
let out = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
.unwrap();
assert_eq!(out.changed, 7);
assert!(out.id.is_none(), "morphism no devuelve id por convención");
let err = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 0}))
.unwrap_err();
assert!(err.contains("positive"));
}
#[test]
fn list_records_orders_lexicographically() {
let id_a = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
let id_b = Uuid::parse_str("ffffffff-0000-0000-0000-000000000000").unwrap();
let b = MockBackend::with_records([
("X".into(), id_b, json!({"n": 2})),
("X".into(), id_a, json!({"n": 1})),
]);
let rows = b.list_records("X");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].0, id_a, "menor uuid primero (orden lex)");
}
#[test]
fn records_for_returns_borrowed_view() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
let view = b.records_for("X");
assert_eq!(view.len(), 1);
assert_eq!(view[0].0, id);
assert_eq!(view[0].1.get("k"), Some(&json!(1)));
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-meta-schema"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — meta-schema: descriptores declarativos de UI (entities, menús, listas, formularios, acciones) consumidos por widgets metainterfaz reusables. Independiente del backend: cualquier app que monte una UI dirigida por datos puede usarlo."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
@@ -0,0 +1,9 @@
# meta-schema
> Schema declarativo de viewers de [nahual](../../README.md).
Define la estructura JSON que describe un viewer: campos, layout, eventos. Lo que [`meta-runtime`](../meta-runtime/README.md) interpreta para montar la app.
## Deps
- `serde`, `serde_json`
@@ -0,0 +1,9 @@
# meta-schema
> Declarative viewer schema for [nahual](../../README.md).
Defines the JSON structure describing a viewer: fields, layout, events. What [`meta-runtime`](../meta-runtime/README.md) interprets to mount the app.
## Deps
- `serde`, `serde_json`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
//! Validación de los módulos demo que trae el shell `nakui-ui-llimphi`
//! en `01_yachay/nakui/nakui-ui-llimphi/examples/nakui-modules/`.
//!
//! Si esto está verde, garantizamos que un usuario que clone el repo y
//! corra `NAKUI_MODULES_DIR=01_yachay/nakui/nakui-ui-llimphi/examples/nakui-modules
//! cargo run -p nakui-ui-llimphi` va a obtener los módulos cargados sin
//! tocar nada.
use nahual_meta_schema::{load_modules_from_dir, FieldKind, View};
fn examples_dir() -> std::path::PathBuf {
// El crate vive en `02_ruway/nahual/libs/meta-schema`; el repo root
// queda 4 niveles arriba. Los módulos demo viven junto al shell.
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
here.join("../../../..")
.join("01_yachay/nakui/nakui-ui-llimphi/examples/nakui-modules")
}
#[test]
fn loads_demo_modules() {
let dir = examples_dir();
let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
panic!("load failed for {}: {e}", dir.display());
});
let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["tesoro", "ventas"],
"se esperaban los módulos demo 'tesoro' (tesorería) y 'ventas'"
);
}
#[test]
fn every_demo_module_has_list_and_form_views() {
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
let mut has_list = false;
let mut has_form = false;
for v in m.views.values() {
match v {
View::List(_) => has_list = true,
View::Form(_) => has_form = true,
View::Detail(_) | View::Dashboard(_) | View::Report(_) | View::Graph(_) => {}
}
}
assert!(
has_list && has_form,
"module {} should expose at least one list + one form view",
m.id
);
}
}
#[test]
fn ventas_has_dashboard_and_report_views() {
let mods = load_modules_from_dir(examples_dir()).unwrap();
let ventas = mods.iter().find(|m| m.id == "ventas").expect("ventas");
let has_dashboard = ventas.views.values().any(|v| matches!(v, View::Dashboard(_)));
let has_report = ventas.views.values().any(|v| matches!(v, View::Report(_)));
assert!(has_dashboard, "ventas debería tener un tablero");
assert!(has_report, "ventas debería tener un reporte");
}
#[test]
fn every_demo_form_field_kind_is_recognized() {
// Sanity: ningún módulo demo usa un kind que no esté en el enum
// (sería rechazado al parsear, pero check explícito no daña).
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
for v in m.views.values() {
if let View::Form(form) = v {
for f in &form.fields {
let _ok = matches!(
f.kind,
FieldKind::Text
| FieldKind::Multiline
| FieldKind::Number
| FieldKind::Boolean
| FieldKind::Date
| FieldKind::Select
| FieldKind::EntityRef
| FieldKind::AutoId
);
}
}
}
}
}
#[test]
fn every_module_validates_clean() {
// validate() chequea que cada MenuItem.view exista en views y que
// los row_detail apunten a una vista Detail. Un typo haría fallar.
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
m.validate()
.unwrap_or_else(|e| panic!("module {} failed validate: {e}", m.id));
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-archive-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-archive-viewer-llimphi — visor de archivos comprimidos sobre Llimphi. Lista las entradas (nombres, tamaño, ratio) de ZIP (y su familia .jar/.apk/.epub/.docx/.xlsx/.pptx), tar y tar.gz en vez del volcado hex. Décimo visor del shell nahual."
[dependencies]
nahual-viewer-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
@@ -0,0 +1,147 @@
//! `nahual-archive-viewer-llimphi` — visor de archivos comprimidos.
//!
//! Décimo visor del shell meta-app. Un `.zip`/`.tar`/`.tar.gz` lo detecta
//! `shuma-discern` por su magic, pero hasta ahora caían al **hex viewer**
//! (o al texto) — bytes ilegibles. Un archivo comprimido es un
//! *contenedor*: lo útil es ver qué hay **dentro**, no su entropía. Este
//! visor lista cada entrada con su tamaño (y, para ZIP, su ratio).
//!
//! Soporta tres formatos, decidido por el **contenido** (no la extensión):
//! - **ZIP** (`PK`): lee el directorio central con `by_index_raw`, sin
//! descomprimir. Cubre la familia entera — `.jar`/`.apk`/`.epub` y los
//! ofimáticos OOXML (`.docx`/`.xlsx`/`.pptx`) son ZIPs.
//! - **tar** (`ustar` en off 257): recorre los headers en streaming.
//! - **tar.gz** (`1f 8b`): descomprime en streaming con `flate2` y recorre
//! el tar interno; salta los datos de cada entrada (sólo lee headers),
//! así no carga el archivo entero en memoria.
//!
//! Patrón fino de los otros viewers: carga sync en [`load_archive`],
//! render en [`archive_viewer_view`]. No conoce el AppBus: el caller
//! pasa el path. MVP feo-primero: la lista es un bloque de texto
//! monoespaciado, estático (sin extraer entradas con click todavía).
#![forbid(unsafe_code)]
use std::path::Path;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
// El dominio (parseo + tipos) vive en `nahual-viewer-core`; lo
// re-exportamos para no romper a los consumidores.
pub use nahual_viewer_core::archive::*;
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct ArchiveViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_error: Color,
}
impl Default for ArchiveViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl ArchiveViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
}
}
}
/// Pinta header (nombre del archivo) + body con el listado monoespaciado.
pub fn archive_viewer_view<Msg>(
state: &ArchivePreview,
path: Option<&Path>,
palette: &ArchiveViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let header_text = match path {
Some(p) => format!(
"archive · {}",
p.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
None => "(seleccioná un ZIP/tar/tar.gz)".to_string(),
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let (body_text, body_color) = match state {
ArchivePreview::Empty => ("".to_string(), palette.fg_muted),
ArchivePreview::Listing(l) => (render_listing(l), palette.fg_text),
ArchivePreview::Error(e) => (format!("(no se pudo abrir: {e})"), palette.fg_error),
};
let body = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.text_aligned_full(
body_text,
12.0,
body_color,
Alignment::Start,
false,
Some("monospace".to_string()),
);
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
@@ -0,0 +1,25 @@
[package]
name = "nahual-audio-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-audio-viewer-llimphi — reproductor/visor de audio sobre Llimphi. Decodifica WAV/MP3/FLAC/Opus/Vorbis (media-source-*), reproduce por cpal (media-audio-cpal) y pinta un espectro log-band en vivo vía AudioProbe + Spectrum. Quinto visor del shell nahual."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
media-core = { workspace = true }
media-audio-cpal = { workspace = true }
media-source-wav = { workspace = true }
media-source-mp3 = { workspace = true }
media-source-flac = { workspace = true }
media-source-opus = { workspace = true }
media-source-vorbis = { workspace = true }
parking_lot = { workspace = true }
[[example]]
name = "audio_viewer_demo"
path = "examples/audio_viewer_demo.rs"
@@ -0,0 +1,81 @@
//! Showcase de `nahual-audio-viewer-llimphi`.
//!
//! `cargo run -p nahual-audio-viewer-llimphi --example audio_viewer_demo --release -- /path/clip.mp3`
//!
//! Sin argumento abre nada (placeholder); con un archivo WAV/MP3/FLAC/
//! Opus/OGG lo reproduce y muestra el espectro. Espacio: play/pausa.
use std::path::PathBuf;
use std::time::Duration;
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
use nahual_audio_viewer_llimphi::{audio_viewer_view, AudioViewerPalette, AudioViewerState};
const TICK: Duration = Duration::from_millis(33); // ~30 Hz
struct Model {
state: AudioViewerState,
}
#[derive(Clone)]
enum Msg {
Tick,
TogglePlay,
}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · audio viewer showcase"
}
fn initial_size() -> (u32, u32) {
(820, 420)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(TICK, || Msg::Tick);
let state = match std::env::args().nth(1).map(PathBuf::from) {
Some(p) => AudioViewerState::open(&p),
None => AudioViewerState::default(),
};
Model { state }
}
fn on_key(_model: &Model, e: &KeyEvent) -> Option<Msg> {
if e.state == KeyState::Pressed && matches!(&e.key, Key::Named(NamedKey::Space)) {
return Some(Msg::TogglePlay);
}
None
}
fn update(mut model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
match msg {
Msg::Tick => model.state.tick(TICK),
Msg::TogglePlay => model.state.toggle_play(),
}
model
}
fn view(model: &Model) -> View<Msg> {
let palette = AudioViewerPalette::default();
let viewer = audio_viewer_view::<Msg>(&model.state, &palette);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![viewer])
}
}
fn main() {
llimphi_ui::run::<Showcase>();
}
@@ -0,0 +1,467 @@
//! `nahual-audio-viewer-llimphi` — reproductor/visor de audio.
//!
//! Quinto visor del shell meta-app (tras texto/imagen/video/card). Abre
//! un archivo de audio (WAV/MP3/FLAC/Opus/Vorbis vía `media-source-*`),
//! lo reproduce por el sink cpal (`media-audio-cpal`) y pinta un
//! **espectro en vivo** — bandas log-espaciadas calculadas con el
//! `Spectrum` (Goertzel) de `media-core` sobre los samples que un
//! `AudioProbe` tapa del stream realtime.
//!
//! ## Cómo se sostiene el stream
//!
//! El [`AudioSink`] envuelve un `cpal::Stream` que es `!Send`/`!Sync` —
//! por eso vive **dentro** del estado del visor (que la app guarda en su
//! `Model`, sólo `'static`, no `Send`). Soltar el `AudioViewerState`
//! (cambiar de archivo, navegar a otra cosa) dropea el sink y para el
//! audio. No hay statics ni leaks: un visor = un stream.
//!
//! ## Posición
//!
//! La cadena `AudioSource → sink` está type-erased detrás de un
//! `Arc<Mutex<dyn AudioSource>>`, así que el visor NO lee `Seekable` de
//! la fuente: estima el playhead con su propio reloj (acumula `dt` en
//! [`AudioViewerState::tick`], como el video viewer). Es suficiente para
//! un meter; el seek real llegará cuando la cadena exponga `Seekable`.
#![forbid(unsafe_code)]
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use parking_lot::Mutex;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KurboRect};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use media_audio_cpal::AudioSink;
use media_core::{AudioProbe, AudioSource, Pause, PausableAudio, ProbedAudioSource, Spectrum};
/// Cantidad de bandas del espectro y su rango (Hz). 48 bandas entre
/// 40 Hz y 16 kHz es un compromiso legible: cubre el grueso musical sin
/// barras tan finas que no se distingan.
const SPECTRUM_BANDS: usize = 48;
const SPECTRUM_FMIN: f32 = 40.0;
const SPECTRUM_FMAX: f32 = 16_000.0;
/// Capacidad del ring del probe (samples intercalados). ≈ 8k da una
/// ventana de ~85 ms a 48 kHz stereo — responsivo sin titilar.
const PROBE_CAPACITY: usize = 8 * 1024;
/// Estado del reproductor de audio. No es `Clone` (ni `Send`): contiene
/// el `AudioSink`/`cpal::Stream`. La app lo guarda en su `Model`.
pub struct AudioViewerState {
/// Mantiene vivo el stream cpal. `None` si no se pudo abrir.
_sink: Option<AudioSink>,
/// Tap de samples del stream para el espectro.
probe: AudioProbe,
/// Analizador log-band (Goertzel + release suave).
spectrum: Spectrum,
/// Buffer reusado para el snapshot del probe (evita realloc/tick).
scratch: Vec<f32>,
/// Handle de pausa compartido con el `PausableAudio` de la cadena.
pause: Pause,
name: String,
sample_rate: u32,
channels: u16,
duration: Option<Duration>,
position: Duration,
playing: bool,
error: Option<String>,
}
impl Default for AudioViewerState {
fn default() -> Self {
Self {
_sink: None,
probe: AudioProbe::new(PROBE_CAPACITY),
spectrum: Spectrum::log_bands(SPECTRUM_BANDS, SPECTRUM_FMIN, SPECTRUM_FMAX),
scratch: Vec::new(),
pause: Pause::new(),
name: String::new(),
sample_rate: 0,
channels: 0,
duration: None,
position: Duration::ZERO,
playing: false,
error: None,
}
}
}
/// Una fuente de audio ya decodificada + sus metadatos de presentación.
/// La fuente se boxea para borrar el tipo concreto antes de entrar al
/// `Arc<Mutex<dyn AudioSource>>` que el sink consume.
struct DecodedAudio {
source: Box<dyn AudioSource + Send>,
channels: u16,
sample_rate: u32,
duration: Duration,
}
impl AudioViewerState {
/// Abre y reproduce un archivo de audio. La extensión elige el
/// decoder; el contenido ya fue discernido como audio por el shell.
/// Si falla el decode o el sink, queda en estado de error (lo
/// muestra el header) y sin sonido.
pub fn open(path: &Path) -> Self {
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let decoded = match decode(path) {
Ok(d) => d,
Err(e) => {
return Self {
name,
error: Some(e),
..Default::default()
};
}
};
let probe = AudioProbe::new(PROBE_CAPACITY);
let pause = Pause::new();
// Orden de la cadena: pausa primero (en pausa rellena silencio),
// probe después → el espectro ve silencio y decae al pausar.
let pausable = PausableAudio::new(decoded.source, pause.clone());
let probed = ProbedAudioSource::new(pausable, probe.clone());
let source: Arc<Mutex<dyn AudioSource + Send>> = Arc::new(Mutex::new(probed));
match AudioSink::open(source) {
Ok(sink) => Self {
_sink: Some(sink),
probe,
spectrum: Spectrum::log_bands(SPECTRUM_BANDS, SPECTRUM_FMIN, SPECTRUM_FMAX),
scratch: Vec::new(),
pause,
name,
sample_rate: decoded.sample_rate,
channels: decoded.channels,
duration: Some(decoded.duration),
position: Duration::ZERO,
playing: true,
error: None,
},
Err(e) => Self {
name,
error: Some(format!("sin salida de audio: {e}")),
..Default::default()
},
}
}
pub fn position(&self) -> Duration {
self.position
}
pub fn duration(&self) -> Option<Duration> {
self.duration
}
pub fn is_playing(&self) -> bool {
self.playing
}
/// Play/pausa. Congela el stream (silencio) y detiene el avance del
/// reloj de posición.
pub fn toggle_play(&mut self) {
if self._sink.is_none() {
return;
}
self.pause.toggle();
self.playing = !self.playing;
}
/// Avanza el reloj y refresca el espectro con el último tramo del
/// stream. Sin efecto si está en pausa o en error.
pub fn tick(&mut self, dt: Duration) {
if !self.playing || self._sink.is_none() {
return;
}
let (sr, ch) = self.probe.snapshot(&mut self.scratch);
if sr > 0 {
self.spectrum.analyze(&self.scratch, ch, sr);
}
let next = self.position.saturating_add(dt);
self.position = match self.duration {
Some(d) if next > d => d,
_ => next,
};
}
/// Magnitudes actuales del espectro (una por banda, [0,1]).
pub fn magnitudes(&self) -> &[f32] {
self.spectrum.magnitudes()
}
}
/// Decodifica el archivo según extensión a una fuente de audio + metadatos.
fn decode(path: &Path) -> Result<DecodedAudio, String> {
let ext = path
.extension()
.and_then(|s| s.to_str())
.map(str::to_ascii_lowercase);
macro_rules! build {
($src:expr) => {{
let s = $src.map_err(|e| e.to_string())?;
DecodedAudio {
channels: s.source_channels(),
sample_rate: s.source_sample_rate(),
duration: Duration::from_secs_f32(s.duration_seconds().max(0.0)),
source: Box::new(s),
}
}};
}
let decoded = match ext.as_deref() {
Some("wav") => build!(media_source_wav::WavSource::from_path(path)),
Some("mp3") => build!(media_source_mp3::Mp3Source::from_path(path)),
Some("flac") => build!(media_source_flac::FlacSource::from_path(path)),
Some("opus") => build!(media_source_opus::OpusSource::from_path(path)),
Some("ogg" | "oga") => build!(media_source_vorbis::VorbisSource::from_path(path)),
other => {
return Err(format!(
"formato de audio no soportado: .{}",
other.unwrap_or("?")
))
}
};
Ok(decoded)
}
/// Paleta del visor.
#[derive(Debug, Clone, Copy)]
pub struct AudioViewerPalette {
pub bg: Color,
pub fg: Color,
pub fg_muted: Color,
pub fg_error: Color,
pub accent: Color,
}
impl Default for AudioViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl AudioViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
accent: t.accent,
}
}
}
fn fmt_time(d: Duration) -> String {
let total = d.as_secs();
format!("{:02}:{:02}", total / 60, total % 60)
}
/// Pinta header (nombre · rate/ch · ▶/⏸ · mm:ss/mm:ss) + body con el
/// espectro (o un placeholder si no hay audio / hay error).
pub fn audio_viewer_view<Msg>(
state: &AudioViewerState,
palette: &AudioViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let name = if state.name.is_empty() {
"(seleccioná un audio)".to_string()
} else {
state.name.clone()
};
let header_text = if let Some(e) = &state.error {
format!("{name} · error: {e}")
} else if state._sink.is_some() {
let glyph = if state.playing { "" } else { "" };
let time = match state.duration {
Some(d) => format!("{} / {}", fmt_time(state.position), fmt_time(d)),
None => fmt_time(state.position),
};
format!(
"{name} · {} Hz · {} ch · {glyph} {time}",
state.sample_rate, state.channels
)
} else {
name
};
let header_color = if state.error.is_some() {
palette.fg_error
} else {
palette.fg_muted
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, header_color, Alignment::Start);
let body = match (&state.error, state._sink.is_some()) {
(Some(e), _) => placeholder_body(&format!("(error: {e})"), palette.fg_error),
(None, true) => spectrum_body(state.magnitudes().to_vec(), palette),
(None, false) => placeholder_body("", palette.fg_muted),
};
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
fn placeholder_body<Msg>(text: &str, color: Color) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.text_aligned(text.to_string(), 12.0, color, Alignment::Center)
}
/// Pinta las barras del espectro de abajo hacia arriba. `mags` se mueve
/// al closure (es chico: una banda por float) para que el painter sea
/// `Send + Sync` sin compartir el `Spectrum` mutable.
fn spectrum_body<Msg>(mags: Vec<f32>, palette: &AudioViewerPalette) -> View<Msg>
where
Msg: Clone + 'static,
{
let accent = palette.accent;
let track = palette.fg_muted;
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(8.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
if rect.w <= 4.0 || rect.h <= 4.0 || mags.is_empty() {
return;
}
let n = mags.len();
let slot_w = rect.w / n as f32;
let bar_w = (slot_w * 0.7).max(1.0);
let baseline = rect.y + rect.h;
for (i, &m) in mags.iter().enumerate() {
let x0 = rect.x + i as f32 * slot_w;
// Piso tenue de 1 px para que se vea la grilla aun en silencio.
let h = (m.clamp(0.0, 1.0) * rect.h).max(1.0);
let bar = KurboRect::new(
x0 as f64,
(baseline - h) as f64,
(x0 + bar_w) as f64,
baseline as f64,
);
// Mezcla accent→track según altura: barras altas más vivas.
let t = m.clamp(0.0, 1.0);
let color = lerp_color(track, accent, t);
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &bar);
}
})
}
/// Interpola linealmente dos colores en sRGB (suficiente para un meter).
fn lerp_color(a: Color, b: Color, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
let ca = a.to_rgba8();
let cb = b.to_rgba8();
let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t) as u8;
Color::from_rgba8(
mix(ca.r, cb.r),
mix(ca.g, cb.g),
mix(ca.b, cb.b),
255,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_time_basico() {
assert_eq!(fmt_time(Duration::from_secs(0)), "00:00");
assert_eq!(fmt_time(Duration::from_secs(125)), "02:05");
}
#[test]
fn open_inexistente_es_error() {
let st = AudioViewerState::open(Path::new("/no/existe.wav"));
assert!(st.error.is_some());
assert!(st._sink.is_none());
assert!(!st.is_playing());
}
#[test]
fn formato_desconocido_es_error() {
let st = AudioViewerState::open(Path::new("/x.xyz"));
assert!(st.error.is_some());
}
#[test]
fn estado_default_sin_audio() {
let st = AudioViewerState::default();
assert!(st._sink.is_none());
assert_eq!(st.position(), Duration::ZERO);
assert_eq!(st.magnitudes().len(), SPECTRUM_BANDS);
}
}
@@ -0,0 +1,15 @@
[package]
name = "nahual-card-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-card-viewer-llimphi — visor estructurado de Cards (shared/card) sobre Llimphi. Renderiza label/id/kind/payload/supervisión/capacidades/referencias como filas legibles en vez del JSON crudo. Análogo Llimphi del text/image/video viewer del shell nahual."
[dependencies]
nahual-viewer-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
card-core = { workspace = true }
@@ -0,0 +1,148 @@
//! `nahual-card-viewer-llimphi` — visor estructurado de Cards.
//!
//! Cuarto visor del shell meta-app (tras texto/imagen/video). Una Card
//! (`shared/card`) es JSON, así que el text viewer la abriría como tal;
//! pero el `lens` `card` que `shuma-discern` produce sobre su contenido
//! merece un visor que la **presente** — no el blob crudo. Este crate
//! lee la Card, extrae los campos salientes (identidad, naturaleza,
//! payload, supervisión, capacidades, permisos, referencias) y los pinta
//! como filas legibles.
//!
//! Sigue el patrón fino de los otros viewers: la carga vive en
//! [`load_card`] (sync — una Card es chica), el render en
//! [`card_viewer_view`]. No conoce el AppBus: el caller pasa el path.
//!
//! MVP feo-primero: el cuerpo es un bloque de texto `clave valor` por
//! línea, no una tabla con layout. Es legible y autocontenido; cuando un
//! widget de propiedades reusable exista en el elegance kit, se migra.
#![forbid(unsafe_code)]
use std::path::Path;
use card_core::CardKind;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
// El dominio (parseo + tipos) vive en `nahual-viewer-core`; lo
// re-exportamos para no romper a los consumidores.
pub use nahual_viewer_core::card::*;
/// Paleta del visor. Reusa los slots semánticos del tema.
#[derive(Debug, Clone, Copy)]
pub struct CardViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_error: Color,
pub accent: Color,
}
impl Default for CardViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl CardViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
accent: t.accent,
}
}
}
/// Pinta header (label · naturaleza) + body con las filas de la Card.
pub fn card_viewer_view<Msg>(
state: &CardPreview,
path: Option<&Path>,
palette: &CardViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let header_text = match state {
CardPreview::Card(c) => {
let kind = match c.kind {
CardKind::Ente => "ente",
CardKind::Data => "data",
};
format!("card · {} · {kind}", c.label)
}
_ => match path {
Some(p) => format!(
"card · {}",
p.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
None => "(seleccioná una card)".to_string(),
},
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.accent, Alignment::Start);
let (body_text, body_color) = match state {
CardPreview::Empty => ("".to_string(), palette.fg_muted),
CardPreview::Card(c) => (summarize(c), palette.fg_text),
CardPreview::Error(e) => (format!("(card inválida: {e})"), palette.fg_error),
};
let body = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.text_aligned(body_text, 12.0, body_color, Alignment::Start);
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-file-explorer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-file-explorer-llimphi — explorador de directorios sobre Llimphi. Crate fino con la lógica de scan/navigation (cwd + Vec<Entry> + selected + visible_offset + wheel_accum) en `FileExplorerState` y un `file_explorer_view` que pinta la lista virtualizada con `llimphi-widget-list`."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-list = { workspace = true }
@@ -0,0 +1,15 @@
# nahual-file-explorer-llimphi
> File explorer con tree + previews de [nahual](../README.md).
Tree de directorios a la izquierda, contenido + preview a la derecha. Preview por tipo: texto, imagen, MD, code.
## Uso
```sh
cargo run --release -p nahual-file-explorer-llimphi
```
## Deps
- [`llimphi-widget-tree`](../../llimphi/widgets/tree/README.md), [`splitter`](../../llimphi/widgets/splitter/README.md)
@@ -0,0 +1,15 @@
# nahual-file-explorer-llimphi
> File explorer with tree + previews of [nahual](../README.md).
Tree of directories on the left, content + preview on the right. Preview by type: text, image, MD, code.
## Usage
```sh
cargo run --release -p nahual-file-explorer-llimphi
```
## Deps
- [`llimphi-widget-tree`](../../llimphi/widgets/tree/README.md), [`splitter`](../../llimphi/widgets/splitter/README.md)
@@ -0,0 +1,287 @@
//! `nahual-file-explorer-llimphi` — explorador de directorios sobre
//! Llimphi.
//!
//! Reemplazo Llimphi del `nahual-file-explorer` GPUI. Crate fino que
//! encapsula:
//! - [`Entry`] / [`FileExplorerState`] — modelo (cwd, entries,
//! selección, scroll, ancho del pane si aplica).
//! - Transiciones puras: [`FileExplorerState::up`], `down`,
//! `open_selected`, `parent`, `select`, `scroll` y `apply_wheel`
//! (cada una devuelve un boolean o un [`OpenedFile`] que el caller
//! usa para decidir si previsualizar/abrir).
//! - [`file_explorer_view`] — pinta la lista virtualizada con
//! `llimphi-widget-list`, devolviendo Msgs vía un mapper de
//! `usize` (índice fila) a `Msg` que el caller provee.
//!
//! El estado es puro: no abre archivos, no llama a `fs::read` —
//! sólo navega. La lectura/preview es responsabilidad del consumidor
//! (`nahual-text-viewer-llimphi`, `nahual-image-viewer-llimphi`,
//! etc.). Reaccionar a `OpenedFile` es típicamente "cargar preview
//! con `load_preview`/`load_image` y guardarlo en mi modelo".
#![forbid(unsafe_code)]
use std::cmp::min;
use std::fs;
use std::path::{Path, PathBuf};
use llimphi_ui::View;
use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
/// Una entrada del directorio actual.
#[derive(Clone, Debug)]
pub struct Entry {
pub name: String,
pub is_dir: bool,
}
/// Cuántas filas mostramos a la vez. Calibrado para un viewport típico
/// (alto del pane ≈ 760 px ÷ 22 px/row ≈ 34 filas). El caller puede
/// crear el state con otro valor si tiene viewports distintos.
pub const DEFAULT_VISIBLE_ROWS: usize = 32;
/// Alto en px de cada fila. Lo usa `ListSpec`.
pub const DEFAULT_ROW_HEIGHT: f32 = 22.0;
/// "Líneas" de la rueda que equivalen a una fila.
pub const WHEEL_LINES_PER_ROW: f32 = 1.0;
/// Resultado de [`FileExplorerState::open_selected`]: si la selección
/// es un directorio, ya se hizo el `cd` (`Directory`); si es archivo,
/// devuelve el path para que el caller decida qué hacer (preview,
/// abrir con app externa, etc.).
#[derive(Clone, Debug)]
pub enum OpenedFile {
Directory,
File(PathBuf),
}
/// Estado del explorador. Puro: mutarlo es trivial y no toca IO
/// excepto en `cd` (que sí relee el directorio nuevo). El caller lo
/// mantiene en su `Model` y pasa `&mut state` a las transiciones.
pub struct FileExplorerState {
pub cwd: PathBuf,
pub entries: Vec<Entry>,
pub selected: usize,
pub visible_offset: usize,
/// Acumulador fraccional de la rueda — para touchpads que mandan
/// deltas chicos. `apply_wheel` lo lee, calcula los pasos enteros
/// y deja la fracción residual.
pub wheel_accum: f32,
pub visible_rows: usize,
}
impl FileExplorerState {
/// Construye un explorador anclado en `cwd`. Si el path no se
/// puede leer, las entries quedan vacías; mostrar mensaje queda
/// para el caller.
pub fn new(cwd: PathBuf) -> Self {
let entries = scan_dir(&cwd);
Self {
cwd,
entries,
selected: 0,
visible_offset: 0,
wheel_accum: 0.0,
visible_rows: DEFAULT_VISIBLE_ROWS,
}
}
/// El path resultante de combinar `cwd` con la entrada
/// seleccionada. `None` si no hay selección válida.
pub fn selected_path(&self) -> Option<PathBuf> {
self.entries
.get(self.selected)
.map(|e| self.cwd.join(&e.name))
}
/// La entrada actualmente seleccionada (clonada).
pub fn selected_entry(&self) -> Option<Entry> {
self.entries.get(self.selected).cloned()
}
/// Mueve la selección una fila arriba si se puede.
pub fn up(&mut self) -> bool {
if self.selected == 0 {
return false;
}
self.selected -= 1;
self.sync_offset();
true
}
/// Mueve la selección una fila abajo si se puede.
pub fn down(&mut self) -> bool {
if self.selected + 1 >= self.entries.len() {
return false;
}
self.selected += 1;
self.sync_offset();
true
}
/// Selecciona la entrada en `idx` (con bound check + scroll sync).
pub fn select(&mut self, idx: usize) -> bool {
if idx >= self.entries.len() {
return false;
}
self.selected = idx;
self.sync_offset();
true
}
/// Si la selección es un directorio, hace `cd` (relee entries,
/// resetea selección/offset). Si es archivo, devuelve su path.
pub fn open_selected(&mut self) -> Option<OpenedFile> {
let entry = self.entries.get(self.selected).cloned()?;
if entry.is_dir {
let new_cwd = self.cwd.join(&entry.name);
// Canonicalize cuando se pueda: resuelve symlinks y "..".
// Si falla (permisos), nos quedamos con el join textual.
self.cwd = fs::canonicalize(&new_cwd).unwrap_or(new_cwd);
self.entries = scan_dir(&self.cwd);
self.selected = 0;
self.visible_offset = 0;
Some(OpenedFile::Directory)
} else {
Some(OpenedFile::File(self.cwd.join(&entry.name)))
}
}
/// Sube al directorio padre. Si estaba parado sobre un subdir, lo
/// re-selecciona al subir (UX típica: mantenés contexto).
pub fn parent(&mut self) -> bool {
let Some(parent) = self.cwd.parent().map(Path::to_path_buf) else {
return false;
};
let prev_name = self
.cwd
.file_name()
.map(|s| s.to_string_lossy().to_string());
self.cwd = parent;
self.entries = scan_dir(&self.cwd);
self.selected = prev_name
.and_then(|n| self.entries.iter().position(|e| e.name == n))
.unwrap_or(0);
self.visible_offset = 0;
self.sync_offset();
true
}
/// Aplica un delta de rueda y devuelve cuántos pasos enteros se
/// movieron (positivo = abajo, negativo = arriba). El acumulador
/// se ajusta para guardar la fracción residual — útil para
/// touchpads que mandan deltas sub-fila.
pub fn apply_wheel(&mut self, delta_y: f32) -> i32 {
let total = self.wheel_accum + delta_y;
let steps = (total / WHEEL_LINES_PER_ROW).trunc() as i32;
self.wheel_accum = total - (steps as f32 * WHEEL_LINES_PER_ROW);
if steps != 0 {
self.scroll(steps);
}
steps
}
/// Scroll por N pasos enteros (positivo = abajo). No mueve la
/// selección.
pub fn scroll(&mut self, steps: i32) {
if steps == 0 {
return;
}
let len = self.entries.len();
let max_offset = len.saturating_sub(self.visible_rows);
if steps > 0 {
self.visible_offset = min(self.visible_offset + steps as usize, max_offset);
} else {
let drop = (-steps) as usize;
self.visible_offset = self.visible_offset.saturating_sub(drop);
}
}
/// Asegura que la selección esté dentro del viewport visible.
fn sync_offset(&mut self) {
if self.selected < self.visible_offset {
self.visible_offset = self.selected;
}
let bottom = self.visible_offset + self.visible_rows;
if self.selected >= bottom {
self.visible_offset = self.selected + 1 - self.visible_rows;
}
}
}
/// Lee el directorio y devuelve entries ordenadas (dirs primero,
/// luego por nombre case-insensitive). Si el `read_dir` falla,
/// devuelve `Vec::new()` (mostrar mensaje queda al caller).
pub fn scan_dir(path: &Path) -> Vec<Entry> {
let Ok(it) = fs::read_dir(path) else {
return Vec::new();
};
let mut entries: Vec<Entry> = it
.flatten()
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
Entry { name, is_dir }
})
.collect();
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
entries
}
/// Pinta la lista de entries del explorador como `llimphi-widget-list`.
/// `on_select` recibe el índice de la fila clickeada y devuelve el
/// Msg que el caller quiera dispatchear (típicamente
/// `Msg::Select(idx)`).
pub fn file_explorer_view<Msg, F>(
state: &FileExplorerState,
palette: ListPalette,
on_select: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(usize) -> Msg,
{
let start = state.visible_offset;
let end = min(state.entries.len(), start + state.visible_rows);
let rows: Vec<ListRow<Msg>> = (start..end)
.map(|idx| {
let entry = &state.entries[idx];
let icon = if entry.is_dir { "" } else { " " };
let label = if entry.is_dir {
format!("{}{}/", icon, entry.name)
} else {
format!("{}{}", icon, entry.name)
};
ListRow {
label,
selected: idx == state.selected,
on_click: on_select(idx),
}
})
.collect();
let caption = format!(
"{} entradas · ↑↓ navega · Enter entra · ⌫ sube",
state.entries.len()
);
let truncated_hint = if state.entries.len() > end {
Some(format!(
"… y {} más (rueda o ↓ para ver más)",
state.entries.len() - end
))
} else {
None
};
list_view(ListSpec {
rows,
total: state.entries.len(),
caption: Some(caption),
truncated_hint,
row_height: DEFAULT_ROW_HEIGHT,
palette,
})
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-font-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-font-viewer-llimphi — visor de fuentes TTF/OTF sobre Llimphi. Muestra los metadatos (familia, estilo, glifos, em) y renderiza una muestra DIBUJADA con la propia fuente del archivo, extrayendo los contornos de glifo con ttf-parser. Undécimo visor del shell nahual."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
ttf-parser = { workspace = true }
@@ -0,0 +1,399 @@
//! `nahual-font-viewer-llimphi` — visor de fuentes TTF/OTF.
//!
//! Undécimo visor del shell meta-app. Un `.ttf`/`.otf` no lo cubría
//! ningún visor rico: caía al text viewer como "(binario — sin preview)".
//! Pero una fuente es para *verla*. Este visor parsea el archivo con
//! `ttf-parser`, muestra sus metadatos (familia, estilo, nº de glifos,
//! unidades por em) y —lo interesante— **renderiza una muestra dibujada
//! con la propia fuente del archivo**: extrae los contornos de cada glifo
//! a un `kurbo::BezPath` y los rellena en la escena vello vía `paint_with`.
//!
//! No pasa por parley (que sólo conoce las fuentes del sistema): los
//! glifos se pintan directo desde los outlines del archivo, así ves
//! exactamente la fuente que estás inspeccionando aunque no esté
//! instalada.
//!
//! Patrón fino de los otros viewers: carga sync en [`load_font`], render
//! en [`font_viewer_view`]. No conoce el AppBus: el caller pasa el path.
//! MVP feo-primero: muestra fija (pangrama + dígitos), sin elegir tamaño
//! ni texto todavía.
#![forbid(unsafe_code)]
use std::path::Path;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
/// Tope de bytes a leer (32 MiB). Una fuente más grande es rara; el
/// caller puede subirlo.
pub const DEFAULT_FONT_BYTES_MAX: u64 = 32 * 1024 * 1024;
/// Líneas de muestra que se renderizan con la fuente del archivo.
const SAMPLE_LINES: &[&str] = &[
"Aa Bb Cc Dd Ee Ff Gg",
"The quick brown fox jumps",
"0123456789 !?.,;:&@#",
];
/// Una línea de muestra ya convertida a contornos, en unidades de fuente
/// (eje Y hacia arriba, origen en la baseline). El render la escala.
#[derive(Debug, Clone, PartialEq)]
pub struct SampleLine {
pub path: BezPath,
/// Ancho total avanzado, en unidades de fuente.
pub width: f64,
}
/// Metadatos + muestras renderizables de una fuente abierta.
#[derive(Debug, Clone, PartialEq)]
pub struct FontInfo {
pub family: String,
pub subfamily: String,
pub num_glyphs: u16,
pub units_per_em: u16,
pub ascender: i16,
pub descender: i16,
pub lines: Vec<SampleLine>,
}
/// Estado del visor. Replica la forma de los otros.
#[derive(Debug, Clone, Default, PartialEq)]
pub enum FontPreview {
/// Sin archivo seleccionado.
#[default]
Empty,
/// Fuente parseada.
Font(Box<FontInfo>),
/// Excede el tope de tamaño.
TooBig(u64),
/// No se pudo abrir/parsear.
Error(String),
}
/// Implementa el sink de `ttf-parser` para volcar el contorno de un glifo
/// a un `kurbo::BezPath`.
struct OutlineToPath {
path: BezPath,
}
impl ttf_parser::OutlineBuilder for OutlineToPath {
fn move_to(&mut self, x: f32, y: f32) {
self.path.move_to((x as f64, y as f64));
}
fn line_to(&mut self, x: f32, y: f32) {
self.path.line_to((x as f64, y as f64));
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.path.quad_to((x1 as f64, y1 as f64), (x as f64, y as f64));
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.path
.curve_to((x1 as f64, y1 as f64), (x2 as f64, y2 as f64), (x as f64, y as f64));
}
fn close(&mut self) {
self.path.close_path();
}
}
/// Lee y parsea la fuente, construyendo las muestras de contorno.
pub fn load_font(path: &Path, max_bytes: u64) -> FontPreview {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > max_bytes => return FontPreview::TooBig(meta.len()),
Err(e) => return FontPreview::Error(e.to_string()),
_ => {}
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return FontPreview::Error(e.to_string()),
};
let face = match ttf_parser::Face::parse(&bytes, 0) {
Ok(f) => f,
Err(e) => return FontPreview::Error(format!("no parsea como fuente: {e}")),
};
FontPreview::Font(Box::new(build_info(&face)))
}
/// Extrae metadatos y arma las líneas de muestra a partir de una `Face`.
fn build_info(face: &ttf_parser::Face<'_>) -> FontInfo {
let family = pick_name(face, 1).unwrap_or_else(|| "(sin nombre)".to_string());
let subfamily = pick_name(face, 2).unwrap_or_else(|| "Regular".to_string());
let em = face.units_per_em();
let lines = SAMPLE_LINES
.iter()
.map(|s| build_line(face, s))
.collect();
FontInfo {
family,
subfamily,
num_glyphs: face.number_of_glyphs(),
units_per_em: em,
ascender: face.ascender(),
descender: face.descender(),
lines,
}
}
/// Toma el primer `name` legible con el `name_id` pedido (1=familia,
/// 2=subfamilia). `ttf-parser` sólo devuelve string para encodings
/// Unicode/Mac, así que algunos nombres salen `None`.
fn pick_name(face: &ttf_parser::Face<'_>, want_id: u16) -> Option<String> {
face.names()
.into_iter()
.filter(|n| n.name_id == want_id)
.find_map(|n| n.to_string())
.filter(|s| !s.is_empty())
}
/// Convierte una cadena en un único `BezPath` (todos los glifos
/// trasladados a su posición de pen) en unidades de fuente.
fn build_line(face: &ttf_parser::Face<'_>, text: &str) -> SampleLine {
let mut combined = BezPath::new();
let mut pen: f64 = 0.0;
let space = face.units_per_em() as f64 / 3.0;
for ch in text.chars() {
let gid = match face.glyph_index(ch) {
Some(g) => g,
None => {
pen += space;
continue;
}
};
let mut sink = OutlineToPath { path: BezPath::new() };
if face.outline_glyph(gid, &mut sink).is_some() {
sink.path.apply_affine(Affine::translate((pen, 0.0)));
combined.extend(sink.path.elements().iter().copied());
}
let adv = face.glyph_hor_advance(gid).unwrap_or(0) as f64;
// Un avance 0 (p.ej. espacio sin glifo) usa el ancho de fallback.
pen += if adv > 0.0 { adv } else { space };
}
SampleLine { path: combined, width: pen }
}
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct FontViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_error: Color,
pub glyph: Color,
}
impl Default for FontViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl FontViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
glyph: t.fg_text,
}
}
}
/// Pinta header + metadatos + lienzo con la muestra dibujada.
pub fn font_viewer_view<Msg>(
state: &FontPreview,
path: Option<&Path>,
palette: &FontViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let header_text = match path {
Some(p) => format!(
"font · {}",
p.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
None => "(seleccioná una fuente TTF/OTF)".to_string(),
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: pad(12.0, 0.0),
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let children = match state {
FontPreview::Empty => vec![header, info_line("", palette.fg_muted)],
FontPreview::TooBig(n) => vec![
header,
info_line(&format!("(fuente muy grande: {n} bytes)"), palette.fg_muted),
],
FontPreview::Error(e) => {
vec![header, info_line(&format!("(no se pudo abrir: {e})"), palette.fg_error)]
}
FontPreview::Font(info) => {
let meta = format!(
"{} · {}\n{} glifos · {} u/em · asc {} / desc {}",
info.family,
info.subfamily,
info.num_glyphs,
info.units_per_em,
info.ascender,
info.descender,
);
vec![
header,
info_line(&meta, palette.fg_text),
sample_canvas::<Msg>(info, palette),
]
}
};
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(children)
}
/// Lienzo que dibuja las líneas de muestra rellenando los contornos de
/// glifo. Los paths vienen en unidades de fuente; acá los escalamos para
/// que entren a lo ancho y los apilamos verticalmente.
fn sample_canvas<Msg>(info: &FontInfo, palette: &FontViewerPalette) -> View<Msg>
where
Msg: Clone + 'static,
{
let lines = info.lines.clone();
let em = info.units_per_em.max(1) as f64;
let ascender = info.ascender as f64;
let descender = info.descender as f64;
let glyph_color = palette.glyph;
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: pad(16.0, 10.0),
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
if rect.w <= 8.0 || rect.h <= 8.0 || lines.is_empty() {
return;
}
let pad_x = 4.0_f64;
let avail_w = (rect.w as f64 - 2.0 * pad_x).max(1.0);
// Altura de cada renglón = alto del lienzo repartido (con tope para
// que un panel alto no infle los glifos hasta deformarlos).
let slot_h = ((rect.h as f64) / lines.len() as f64).min(96.0);
// El glifo ocupa la caja ascender..descender → escala por alto.
let line_units = (ascender - descender).max(em);
for (i, line) in lines.iter().enumerate() {
if line.path.elements().is_empty() {
continue;
}
// Escala que respeta tanto el alto del renglón como el ancho.
let scale_h = (slot_h * 0.72) / line_units;
let scale_w = if line.width > 0.0 {
avail_w / line.width
} else {
scale_h
};
let scale = scale_h.min(scale_w);
let baseline = rect.y as f64 + i as f64 * slot_h + slot_h * 0.5 + (ascender * scale) * 0.5;
let x0 = rect.x as f64 + pad_x;
// Font: Y arriba; pantalla: Y abajo → escala Y negativa.
let affine = Affine::new([scale, 0.0, 0.0, -scale, x0, baseline]);
scene.fill(Fill::NonZero, affine, glyph_color, None, &line.path);
}
})
}
/// Bloque de texto de una línea (metadatos / estados).
fn info_line<Msg>(text: &str, color: Color) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(40.0_f32),
},
padding: pad(12.0, 4.0),
..Default::default()
})
.text_aligned(text.to_string(), 12.0, color, Alignment::Start)
}
/// Padding horizontal `h` + vertical `v`.
fn pad(h: f32, v: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::LengthPercentage> {
Rect {
left: length(h),
right: length(h),
top: length(v),
bottom: length(v),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inexistente_es_error() {
assert!(matches!(
load_font(Path::new("/no/existe.ttf"), DEFAULT_FONT_BYTES_MAX),
FontPreview::Error(_)
));
}
#[test]
fn basura_no_es_fuente() {
let tmp = std::env::temp_dir().join("nahual-font-viewer-test-bad.ttf");
std::fs::write(&tmp, b"no soy una fuente, soy texto cualquiera").unwrap();
assert!(matches!(
load_font(&tmp, DEFAULT_FONT_BYTES_MAX),
FontPreview::Error(_)
));
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn outline_builder_construye_path() {
// Verifica que el sink traduce los comandos a elementos de BezPath.
let mut sink = OutlineToPath { path: BezPath::new() };
use ttf_parser::OutlineBuilder;
sink.move_to(0.0, 0.0);
sink.line_to(10.0, 0.0);
sink.quad_to(10.0, 10.0, 0.0, 10.0);
sink.close();
assert_eq!(sink.path.elements().len(), 4);
}
}
@@ -0,0 +1,23 @@
[package]
name = "nahual-gallery-llimphi"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-gallery-llimphi — galería de miniaturas tipo gThumb/FastStone sobre Llimphi. Cose llimphi-widget-grid (virtualización 2D) + nahual-thumb-core (generación + cache + planificador): lista miles de imágenes de una carpeta, decodifica en background con Handle::spawn priorizado al viewport, y pinta sólo la ventana visible."
[[bin]]
name = "nahual-gallery"
path = "src/main.rs"
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-grid = { workspace = true }
llimphi-widget-breadcrumb = { workspace = true }
nahual-thumb-core = { workspace = true }
nahual-image-viewer-llimphi = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
[package]
name = "nahual-geo-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-geo-core — núcleo geoespacial agnóstico de GUI del visor de mapas de nahual. Parsea GeoJSON/GPX/KML y tiles vectoriales (PMTiles v3 + MVT/Mapbox), modela el mundo (MapData/BBox/FeatureProps/MapView), proyecta equirectangular fit-to-bounds con cámara (zoom/pan), y resuelve hit-test, búsqueda, ruteo A* y el basemap por viewport con cache LRU. Sin dependencias de render: el frontend (`nahual-map-viewer-llimphi` u otro: TUI/web/headless) sólo lo pinta."
[dependencies]
serde_json = { workspace = true }
quick-xml = { workspace = true }
flate2 = { workspace = true }
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,398 @@
//! Lector del contenedor **PMTiles v3** — el "single-file vector tiles" que
//! ubica los bytes de cada tile *sin red ni servidor*: un archivo que leés
//! local (o desde tu propio bucket). Es la pieza soberana que faltaba para el
//! basemap de calles, complementando el decoder MVT de [`super::vt`].
//!
//! Implementa lo intrincado del formato a mano: IDs de tile en **curva de
//! Hilbert**, **directorios** de dos niveles (root + leaf) con entradas
//! columnar delta-encoded, y descompresión (none/gzip). Spec:
//! <https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md>.
//!
//! Verificado con tests sintéticos (Hilbert contra valores conocidos +
//! round-trip de un archivo mínimo construido a mano). La validación contra
//! un `.pmtiles` real es el último paso pendiente.
use std::io::Read;
/// Cabecera PMTiles v3 (campos que usamos).
#[derive(Debug, Clone)]
pub struct Header {
pub root_offset: u64,
pub root_length: u64,
pub leaf_offset: u64,
pub leaf_length: u64,
pub tile_offset: u64,
pub tile_length: u64,
pub min_zoom: u8,
pub max_zoom: u8,
/// Compresión de directorios/metadata: 1 none, 2 gzip.
pub internal_compression: u8,
/// Compresión de los tiles: 1 none, 2 gzip.
pub tile_compression: u8,
/// Tipo de tile: 1 = MVT.
pub tile_type: u8,
pub min_lon: f64,
pub min_lat: f64,
pub max_lon: f64,
pub max_lat: f64,
}
/// Una entrada de directorio: cubre los tile-ids `[tile_id, tile_id+run_length)`.
/// `run_length == 0` marca un puntero a un directorio *leaf*.
#[derive(Debug, Clone, Copy)]
struct Entry {
tile_id: u64,
offset: u64,
length: u64,
run_length: u32,
}
/// Archivo PMTiles cargado en memoria (MVP; mmap/stream sería el paso fino
/// para planetas de varios GB).
pub struct PmTiles {
data: Vec<u8>,
pub header: Header,
}
const MAGIC: &[u8; 7] = b"PMTiles";
const HEADER_LEN: usize = 127;
impl PmTiles {
/// Abre y parsea un `.pmtiles` desde disco.
pub fn open(path: &std::path::Path) -> Result<Self, String> {
let data = std::fs::read(path).map_err(|e| e.to_string())?;
Self::from_bytes(data)
}
/// Parsea un `.pmtiles` ya en memoria (también el camino de los tests).
pub fn from_bytes(data: Vec<u8>) -> Result<Self, String> {
if data.len() < HEADER_LEN {
return Err("pmtiles: archivo más corto que la cabecera".into());
}
if &data[0..7] != MAGIC {
return Err("pmtiles: magic inválido".into());
}
if data[7] != 3 {
return Err(format!(
"pmtiles: versión {} no soportada (sólo v3)",
data[7]
));
}
let header = Header {
root_offset: u64le(&data, 8),
root_length: u64le(&data, 16),
leaf_offset: u64le(&data, 40),
leaf_length: u64le(&data, 48),
tile_offset: u64le(&data, 56),
tile_length: u64le(&data, 64),
internal_compression: data[97],
tile_compression: data[98],
tile_type: data[99],
min_zoom: data[100],
max_zoom: data[101],
min_lon: i32le(&data, 102) as f64 / 1e7,
min_lat: i32le(&data, 106) as f64 / 1e7,
max_lon: i32le(&data, 110) as f64 / 1e7,
max_lat: i32le(&data, 114) as f64 / 1e7,
};
Ok(PmTiles { data, header })
}
/// Devuelve los bytes (ya descomprimidos) del tile `z/x/y`, o `None` si no
/// existe. Desciende por hasta unos niveles de directorios leaf.
pub fn tile(&self, z: u32, x: u32, y: u32) -> Option<Vec<u8>> {
let tid = zxy_to_tile_id(z, x, y);
let mut dir = self.read_dir(self.header.root_offset, self.header.root_length)?;
for _ in 0..4 {
let e = find_entry(&dir, tid)?;
if e.run_length == 0 {
// Puntero a directorio leaf.
dir = self.read_dir(self.header.leaf_offset + e.offset, e.length)?;
} else if tid < e.tile_id + e.run_length as u64 {
let start = (self.header.tile_offset + e.offset) as usize;
let end = start.checked_add(e.length as usize)?;
let raw = self.data.get(start..end)?;
return decompress(raw, self.header.tile_compression);
} else {
return None;
}
}
None
}
/// Lee y descomprime un directorio en `[offset, offset+length)`.
fn read_dir(&self, offset: u64, length: u64) -> Option<Vec<Entry>> {
let start = offset as usize;
let end = start.checked_add(length as usize)?;
let raw = self.data.get(start..end)?;
let bytes = decompress(raw, self.header.internal_compression)?;
parse_directory(&bytes)
}
}
/// Parsea un directorio (ya descomprimido) en sus entradas.
fn parse_directory(b: &[u8]) -> Option<Vec<Entry>> {
let mut p = VarintReader::new(b);
let n = p.read()? as usize;
if n > 10_000_000 {
return None; // guardia anti-corrupción
}
let mut entries = vec![
Entry {
tile_id: 0,
offset: 0,
length: 0,
run_length: 0
};
n
];
// tile_ids (delta-encoded).
let mut last = 0u64;
for e in entries.iter_mut() {
last += p.read()?;
e.tile_id = last;
}
for e in entries.iter_mut() {
e.run_length = p.read()? as u32;
}
for e in entries.iter_mut() {
e.length = p.read()?;
}
// offsets: 0 = contiguo al anterior; v>0 = v-1.
for i in 0..n {
let v = p.read()?;
entries[i].offset = if v == 0 {
if i == 0 {
return None;
}
entries[i - 1].offset + entries[i - 1].length
} else {
v - 1
};
}
Some(entries)
}
/// Mayor entrada con `tile_id <= target` (búsqueda binaria; las entradas están
/// ordenadas por tile_id).
fn find_entry(entries: &[Entry], target: u64) -> Option<Entry> {
if entries.is_empty() || entries[0].tile_id > target {
return None;
}
let mut lo = 0usize;
let mut hi = entries.len(); // hi exclusivo
while lo + 1 < hi {
let mid = (lo + hi) / 2;
if entries[mid].tile_id <= target {
lo = mid;
} else {
hi = mid;
}
}
Some(entries[lo])
}
/// `z/x/y` → tile-id PMTiles: offset acumulado de zooms menores + índice de
/// Hilbert dentro del zoom.
pub fn zxy_to_tile_id(z: u32, x: u32, y: u32) -> u64 {
// Tiles en zooms 0..z = (4^z - 1) / 3.
let base = ((1u64 << (2 * z)) - 1) / 3;
base + hilbert_xy2d(z, x, y)
}
/// Índice de Hilbert de `(x, y)` en una grilla `2^z × 2^z`.
fn hilbert_xy2d(z: u32, x: u32, y: u32) -> u64 {
let n: u64 = 1 << z;
let (mut x, mut y) = (x as u64, y as u64);
let mut d: u64 = 0;
let mut s = n / 2;
while s > 0 {
let rx = if (x & s) > 0 { 1u64 } else { 0 };
let ry = if (y & s) > 0 { 1u64 } else { 0 };
d += s * s * ((3 * rx) ^ ry);
// Rotar el cuadrante.
if ry == 0 {
if rx == 1 {
x = n - 1 - x;
y = n - 1 - y;
}
std::mem::swap(&mut x, &mut y);
}
s /= 2;
}
d
}
/// Descomprime según el código de compresión PMTiles (1 none, 2 gzip).
fn decompress(raw: &[u8], compression: u8) -> Option<Vec<u8>> {
match compression {
1 => Some(raw.to_vec()),
2 => {
let mut d = flate2::read::GzDecoder::new(raw);
let mut out = Vec::new();
d.read_to_end(&mut out).ok()?;
Some(out)
}
_ => None, // brotli/zstd: fuera de alcance (evitamos deps pesadas)
}
}
fn u64le(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
fn i32le(b: &[u8], o: usize) -> i32 {
i32::from_le_bytes(b[o..o + 4].try_into().unwrap())
}
/// Lector de varints LEB128 sobre un buffer.
struct VarintReader<'a> {
b: &'a [u8],
i: usize,
}
impl<'a> VarintReader<'a> {
fn new(b: &'a [u8]) -> Self {
VarintReader { b, i: 0 }
}
fn read(&mut self) -> Option<u64> {
let mut shift = 0u32;
let mut out = 0u64;
loop {
let byte = *self.b.get(self.i)?;
self.i += 1;
out |= ((byte & 0x7f) as u64) << shift;
if byte & 0x80 == 0 {
return Some(out);
}
shift += 7;
if shift >= 64 {
return None;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hilbert_valores_conocidos() {
// Grilla 2×2 (z=1), orden Hilbert canónico.
assert_eq!(hilbert_xy2d(1, 0, 0), 0);
assert_eq!(hilbert_xy2d(1, 0, 1), 1);
assert_eq!(hilbert_xy2d(1, 1, 1), 2);
assert_eq!(hilbert_xy2d(1, 1, 0), 3);
}
#[test]
fn tile_id_acumula_zooms() {
assert_eq!(zxy_to_tile_id(0, 0, 0), 0);
// z=1 arranca en 1 (tras el único tile de z0).
assert_eq!(zxy_to_tile_id(1, 0, 0), 1);
assert_eq!(zxy_to_tile_id(1, 1, 0), 4);
// z=2 arranca en 5 (1 + 4).
assert_eq!(zxy_to_tile_id(2, 0, 0), 5);
}
// --- Round-trip: construir un .pmtiles mínimo y leerlo ---
fn varint(out: &mut Vec<u8>, mut v: u64) {
loop {
let mut b = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
b |= 0x80;
}
out.push(b);
if v == 0 {
break;
}
}
}
/// Serializa un directorio de una sola entrada (sin compresión).
fn write_dir(tile_id: u64, offset: u64, length: u64, run: u64) -> Vec<u8> {
let mut d = Vec::new();
varint(&mut d, 1); // num_entries
varint(&mut d, tile_id); // delta desde 0
varint(&mut d, run);
varint(&mut d, length);
varint(&mut d, offset + 1); // evita el atajo "contiguo"
d
}
fn build_pmtiles(tile_bytes: &[u8]) -> Vec<u8> {
let dir = write_dir(0, 0, tile_bytes.len() as u64, 1);
let root_off = HEADER_LEN as u64;
let root_len = dir.len() as u64;
let tile_off = root_off + root_len; // metadata len 0
let mut h = vec![0u8; HEADER_LEN];
h[0..7].copy_from_slice(MAGIC);
h[7] = 3;
h[8..16].copy_from_slice(&root_off.to_le_bytes());
h[16..24].copy_from_slice(&root_len.to_le_bytes());
// metadata 24..40 = 0
h[40..48].copy_from_slice(&tile_off.to_le_bytes()); // leaf offset (sin leaves)
// leaf length 48..56 = 0
h[56..64].copy_from_slice(&tile_off.to_le_bytes());
h[64..72].copy_from_slice(&(tile_bytes.len() as u64).to_le_bytes());
h[97] = 1; // internal: none
h[98] = 1; // tile: none
h[99] = 1; // mvt
h[100] = 0; // min zoom
h[101] = 0; // max zoom
let mut file = h;
file.extend_from_slice(&dir);
file.extend_from_slice(tile_bytes);
file
}
#[test]
fn roundtrip_lee_el_tile() {
let tile_payload = b"\x01\x02\x03 soy un tile";
let file = build_pmtiles(tile_payload);
let pm = PmTiles::from_bytes(file).expect("parsea");
assert_eq!(pm.header.min_zoom, 0);
assert_eq!(pm.tile(0, 0, 0).as_deref(), Some(&tile_payload[..]));
// Un tile inexistente → None.
assert_eq!(pm.tile(1, 0, 0), None);
}
#[test]
fn gzip_de_directorio() {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
// Mismo archivo pero con el directorio gzip e internal_compression=2.
let tile_payload = b"tile-gz";
let dir = write_dir(0, 0, tile_payload.len() as u64, 1);
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(&dir).unwrap();
let dir_gz = enc.finish().unwrap();
let root_off = HEADER_LEN as u64;
let root_len = dir_gz.len() as u64;
let tile_off = root_off + root_len;
let mut h = vec![0u8; HEADER_LEN];
h[0..7].copy_from_slice(MAGIC);
h[7] = 3;
h[8..16].copy_from_slice(&root_off.to_le_bytes());
h[16..24].copy_from_slice(&root_len.to_le_bytes());
h[40..48].copy_from_slice(&tile_off.to_le_bytes());
h[56..64].copy_from_slice(&tile_off.to_le_bytes());
h[64..72].copy_from_slice(&(tile_payload.len() as u64).to_le_bytes());
h[97] = 2; // internal: gzip
h[98] = 1; // tile: none
h[99] = 1;
let mut file = h;
file.extend_from_slice(&dir_gz);
file.extend_from_slice(tile_payload);
let pm = PmTiles::from_bytes(file).expect("parsea");
assert_eq!(pm.tile(0, 0, 0).as_deref(), Some(&tile_payload[..]));
}
#[test]
fn magic_invalido_falla() {
assert!(PmTiles::from_bytes(vec![0u8; 200]).is_err());
assert!(PmTiles::from_bytes(b"NOPE".to_vec()).is_err());
}
}
+492
View File
@@ -0,0 +1,492 @@
//! Decodificador de **vector tiles** (Mapbox Vector Tile / MVT) — el corazón
//! soberano del "mapa de calles vivo": traduce un tile vectorial (protobuf en
//! coordenadas locales del tile) a geometrías en lon/lat, **sin Mapbox ni
//! librería ajena**. El protobuf se parsea a mano (no hay dependencia de
//! `prost`/`protobuf`), fiel a la ética de dependencias mínimas.
//!
//! Esquema MVT (relevante):
//! ```text
//! Tile { repeated Layer layers = 3; }
//! Layer { string name = 1; repeated Feature features = 2; uint32 extent = 5; }
//! Feature { GeomType type = 3; repeated uint32 geometry = 4 [packed]; }
//! GeomType: POINT=1, LINESTRING=2, POLYGON=3
//! ```
//! La geometría es un flujo de comandos (`MoveTo`/`LineTo`/`ClosePath`) con
//! deltas en zigzag, en el espacio `0..extent` del tile (Y hacia abajo).
//!
//! Lo que falta para el basemap vivo end-to-end (y necesita un `.pmtiles` real
//! para validarse): el **contenedor PMTiles** (IDs Hilbert + directorios
//! gzip) que ubica los bytes de cada tile, y el **streaming por viewport**
//! (pedir los tiles visibles al hacer zoom). Este módulo es la pieza dura y
//! reusable, ya verificable.
use super::Coord;
/// Geometría de un tile, ya en lon/lat.
#[derive(Debug, Clone, PartialEq)]
pub enum TileGeom {
Point(Coord),
Line(Vec<Coord>),
Polygon(Vec<Vec<Coord>>),
}
/// Una feature decodificada con su capa de origen (calle, agua, edificio…).
#[derive(Debug, Clone, PartialEq)]
pub struct TileFeature {
pub layer: String,
pub geom: TileGeom,
}
/// Convierte un punto en coordenadas locales del tile (`px, py` en `0..extent`,
/// Y hacia abajo) a lon/lat, vía Web Mercator esférico.
pub fn tile_to_lonlat(z: u32, x: u32, y: u32, extent: f64, px: f64, py: f64) -> Coord {
let n = (1u64 << z) as f64;
let fx = x as f64 + px / extent;
let fy = y as f64 + py / extent;
let lon = fx / n * 360.0 - 180.0;
let lat = (std::f64::consts::PI * (1.0 - 2.0 * fy / n))
.sinh()
.atan()
.to_degrees();
[lon, lat]
}
/// lon/lat → índice de tile `(x, y)` en el zoom `z` (esquema slippy/XYZ de
/// Web Mercator). Acota a `[0, 2^z-1]`.
pub fn lonlat_to_tile(z: u32, lon: f64, lat: f64) -> (u32, u32) {
let n = (1u64 << z) as f64;
let x = ((lon + 180.0) / 360.0 * n).floor();
let lat = lat.clamp(-85.05112878, 85.05112878).to_radians();
let y = ((1.0 - (lat.tan() + 1.0 / lat.cos()).ln() / std::f64::consts::PI) / 2.0 * n).floor();
let m = (n - 1.0).max(0.0);
(x.clamp(0.0, m) as u32, y.clamp(0.0, m) as u32)
}
/// Zoom de tiles para que un span de `west..east` grados ocupe el ancho del
/// panel a ~512 px por tile. Sin acotar al rango del dataset (eso lo hace el
/// llamador).
pub fn zoom_for_span(west: f64, east: f64, panel_w: f64) -> u32 {
let span = (east - west).abs().max(1e-9);
let twoz = (360.0 / span) * (panel_w.max(1.0) / 512.0);
twoz.max(1.0).log2().floor().clamp(0.0, 22.0) as u32
}
// ---------------------------------------------------------------------------
// Lector protobuf mínimo (wire format)
// ---------------------------------------------------------------------------
/// Cursor sobre un buffer protobuf. Sólo lo necesario para MVT.
struct Pbf<'a> {
b: &'a [u8],
i: usize,
}
impl<'a> Pbf<'a> {
fn new(b: &'a [u8]) -> Self {
Pbf { b, i: 0 }
}
fn eof(&self) -> bool {
self.i >= self.b.len()
}
/// Lee un varint LEB128. Devuelve 0 si el buffer se acaba (tolerante).
fn varint(&mut self) -> u64 {
let mut shift = 0u32;
let mut out = 0u64;
while self.i < self.b.len() && shift < 64 {
let byte = self.b[self.i];
self.i += 1;
out |= ((byte & 0x7f) as u64) << shift;
if byte & 0x80 == 0 {
break;
}
shift += 7;
}
out
}
/// Lee `(field_number, wire_type)` del próximo tag, o `None` en EOF.
fn tag(&mut self) -> Option<(u64, u8)> {
if self.eof() {
return None;
}
let key = self.varint();
Some((key >> 3, (key & 0x7) as u8))
}
/// Lee un bloque length-delimited (wire type 2) y devuelve su slice.
fn len_delim(&mut self) -> &'a [u8] {
let len = self.varint() as usize;
let end = (self.i + len).min(self.b.len());
let s = &self.b[self.i..end];
self.i = end;
s
}
/// Salta un campo del wire type dado.
fn skip(&mut self, wire: u8) {
match wire {
0 => {
self.varint();
}
1 => self.i = (self.i + 8).min(self.b.len()),
2 => {
let len = self.varint() as usize;
self.i = (self.i + len).min(self.b.len());
}
5 => self.i = (self.i + 4).min(self.b.len()),
_ => self.i = self.b.len(),
}
}
}
// ---------------------------------------------------------------------------
// Decodificación MVT
// ---------------------------------------------------------------------------
/// Decodifica un tile MVT a features en lon/lat. `(z, x, y)` ubican el tile en
/// la pirámide Web Mercator. Tolerante: ignora lo que no entiende.
pub fn decode_mvt_tile(bytes: &[u8], z: u32, x: u32, y: u32) -> Vec<TileFeature> {
let mut out = Vec::new();
let mut p = Pbf::new(bytes);
while let Some((field, wire)) = p.tag() {
if field == 3 && wire == 2 {
decode_layer(p.len_delim(), z, x, y, &mut out);
} else {
p.skip(wire);
}
}
out
}
fn decode_layer(bytes: &[u8], z: u32, x: u32, y: u32, out: &mut Vec<TileFeature>) {
let mut name = String::new();
let mut extent = 4096u64; // default del spec
let mut features: Vec<&[u8]> = Vec::new();
let mut p = Pbf::new(bytes);
while let Some((field, wire)) = p.tag() {
match (field, wire) {
(1, 2) => name = String::from_utf8_lossy(p.len_delim()).into_owned(),
(2, 2) => features.push(p.len_delim()),
(5, 0) => extent = p.varint(),
_ => p.skip(wire),
}
}
let extent = extent.max(1) as f64;
for fb in features {
decode_feature(fb, &name, z, x, y, extent, out);
}
}
fn decode_feature(
bytes: &[u8],
layer: &str,
z: u32,
x: u32,
y: u32,
extent: f64,
out: &mut Vec<TileFeature>,
) {
let mut geom_type = 0u64;
let mut geom: Vec<u32> = Vec::new();
let mut p = Pbf::new(bytes);
while let Some((field, wire)) = p.tag() {
match (field, wire) {
(3, 0) => geom_type = p.varint(),
// `geometry` es packed: un bloque length-delimited de varints.
(4, 2) => {
let mut gp = Pbf::new(p.len_delim());
while !gp.eof() {
geom.push(gp.varint() as u32);
}
}
_ => p.skip(wire),
}
}
decode_geometry(&geom, geom_type, layer, z, x, y, extent, out);
}
/// Comandos MVT.
const CMD_MOVE_TO: u32 = 1;
const CMD_LINE_TO: u32 = 2;
const CMD_CLOSE_PATH: u32 = 7;
fn zigzag(v: u32) -> i32 {
((v >> 1) as i32) ^ -((v & 1) as i32)
}
#[allow(clippy::too_many_arguments)]
fn decode_geometry(
g: &[u32],
geom_type: u64,
layer: &str,
z: u32,
x: u32,
y: u32,
extent: f64,
out: &mut Vec<TileFeature>,
) {
let mut i = 0usize;
let (mut cx, mut cy) = (0i32, 0i32);
// Sub-geometrías acumuladas (una por MoveTo en líneas; anillos en polígono).
let mut current: Vec<Coord> = Vec::new();
let mut rings: Vec<Vec<Coord>> = Vec::new();
let to_ll = |cx: i32, cy: i32| tile_to_lonlat(z, x, y, extent, cx as f64, cy as f64);
while i < g.len() {
let cmd = g[i] & 0x7;
let count = (g[i] >> 3) as usize;
i += 1;
match cmd {
CMD_MOVE_TO => {
for _ in 0..count {
if i + 1 >= g.len() {
break;
}
cx += zigzag(g[i]);
cy += zigzag(g[i + 1]);
i += 2;
// Un MoveTo arranca una nueva sub-geometría.
if geom_type == 1 {
// POINT (o MultiPoint): cada MoveTo es un punto.
out.push(TileFeature {
layer: layer.to_string(),
geom: TileGeom::Point(to_ll(cx, cy)),
});
} else {
if !current.is_empty() {
flush(geom_type, &mut current, &mut rings, layer, out);
}
current = vec![to_ll(cx, cy)];
}
}
}
CMD_LINE_TO => {
for _ in 0..count {
if i + 1 >= g.len() {
break;
}
cx += zigzag(g[i]);
cy += zigzag(g[i + 1]);
i += 2;
current.push(to_ll(cx, cy));
}
}
CMD_CLOSE_PATH => {
// Cierra el anillo de polígono actual.
if geom_type == 3 && current.len() >= 3 {
if current.first() != current.last() {
let first = current[0];
current.push(first);
}
rings.push(std::mem::take(&mut current));
}
}
_ => break,
}
}
if !current.is_empty() {
flush(geom_type, &mut current, &mut rings, layer, out);
}
if geom_type == 3 && !rings.is_empty() {
out.push(TileFeature {
layer: layer.to_string(),
geom: TileGeom::Polygon(std::mem::take(&mut rings)),
});
}
}
fn flush(
geom_type: u64,
current: &mut Vec<Coord>,
rings: &mut Vec<Vec<Coord>>,
layer: &str,
out: &mut Vec<TileFeature>,
) {
let geom = std::mem::take(current);
match geom_type {
2 if geom.len() >= 2 => out.push(TileFeature {
layer: layer.to_string(),
geom: TileGeom::Line(geom),
}),
3 if geom.len() >= 3 => rings.push(geom),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mercator_esquinas_conocidas() {
// Tile 0/0/0: esquina NW = (-180, +85.051…).
let nw = tile_to_lonlat(0, 0, 0, 4096.0, 0.0, 0.0);
assert!((nw[0] - -180.0).abs() < 1e-9);
assert!((nw[1] - 85.0511).abs() < 1e-3);
// Centro del mundo (z1, esquina compartida) = (0,0).
let c = tile_to_lonlat(1, 1, 1, 4096.0, 0.0, 0.0);
assert!(c[0].abs() < 1e-9 && c[1].abs() < 1e-6);
}
#[test]
fn lonlat_a_tile_y_zoom() {
// z=1: -180° → x0, +179° → x1; ecuador/Greenwich cae en el borde.
assert_eq!(lonlat_to_tile(1, -180.0, 80.0).0, 0);
assert_eq!(lonlat_to_tile(1, 179.0, 80.0).0, 1);
assert_eq!(lonlat_to_tile(1, -90.0, -80.0).1, 1); // hemisferio sur
// Ida y vuelta aproximada con tile_to_lonlat.
let (x, y) = lonlat_to_tile(4, -71.97, -13.5);
let ll = tile_to_lonlat(4, x, y, 1.0, 0.5, 0.5);
assert!((ll[0] - -71.97).abs() < 30.0 && (ll[1] - -13.5).abs() < 30.0);
// Zoom para spans conocidos.
assert_eq!(zoom_for_span(-180.0, 180.0, 512.0), 0);
assert_eq!(zoom_for_span(-90.0, 90.0, 512.0), 1);
}
#[test]
fn zigzag_decodifica() {
assert_eq!(zigzag(0), 0);
assert_eq!(zigzag(1), -1);
assert_eq!(zigzag(2), 1);
assert_eq!(zigzag(3), -2);
}
// --- Codificación MVT a mano para validar el decoder contra el spec ---
fn varint(out: &mut Vec<u8>, mut v: u64) {
loop {
let mut byte = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
byte |= 0x80;
}
out.push(byte);
if v == 0 {
break;
}
}
}
fn tag(out: &mut Vec<u8>, field: u64, wire: u8) {
varint(out, (field << 3) | wire as u64);
}
fn len_delim(out: &mut Vec<u8>, field: u64, payload: &[u8]) {
tag(out, field, 2);
varint(out, payload.len() as u64);
out.extend_from_slice(payload);
}
fn zz(v: i32) -> u32 {
((v << 1) ^ (v >> 31)) as u32
}
#[test]
fn decodifica_linestring() {
// Geometría: MoveTo(1) a (10,10), LineTo(2) +(20,0) +(0,20).
let mut geom: Vec<u8> = Vec::new();
varint(&mut geom, ((1 << 3) | CMD_MOVE_TO) as u64);
varint(&mut geom, zz(10) as u64);
varint(&mut geom, zz(10) as u64);
varint(&mut geom, ((2 << 3) | CMD_LINE_TO) as u64);
varint(&mut geom, zz(20) as u64);
varint(&mut geom, zz(0) as u64);
varint(&mut geom, zz(0) as u64);
varint(&mut geom, zz(20) as u64);
// Feature { type=2 (LINESTRING); geometry=geom }.
let mut feat: Vec<u8> = Vec::new();
tag(&mut feat, 3, 0);
varint(&mut feat, 2);
len_delim(&mut feat, 4, &geom);
// Layer { name="roads"; feature; extent=4096 }.
let mut layer: Vec<u8> = Vec::new();
len_delim(&mut layer, 1, b"roads");
len_delim(&mut layer, 2, &feat);
tag(&mut layer, 5, 0);
varint(&mut layer, 4096);
// Tile { layer }.
let mut tile: Vec<u8> = Vec::new();
len_delim(&mut tile, 3, &layer);
let feats = decode_mvt_tile(&tile, 0, 0, 0);
assert_eq!(feats.len(), 1);
assert_eq!(feats[0].layer, "roads");
let TileGeom::Line(pts) = &feats[0].geom else {
panic!("esperaba Line, fue {:?}", feats[0].geom)
};
assert_eq!(pts.len(), 3);
// El primer vértice (10,10) en extent 4096, tile 0/0/0.
let expect = tile_to_lonlat(0, 0, 0, 4096.0, 10.0, 10.0);
assert!((pts[0][0] - expect[0]).abs() < 1e-9);
assert!((pts[0][1] - expect[1]).abs() < 1e-9);
}
#[test]
fn decodifica_polygon_cerrado() {
// MoveTo (0,0); LineTo +(10,0) +(0,10); ClosePath.
let mut geom: Vec<u8> = Vec::new();
varint(&mut geom, ((1 << 3) | CMD_MOVE_TO) as u64);
varint(&mut geom, zz(0) as u64);
varint(&mut geom, zz(0) as u64);
varint(&mut geom, ((2 << 3) | CMD_LINE_TO) as u64);
varint(&mut geom, zz(10) as u64);
varint(&mut geom, zz(0) as u64);
varint(&mut geom, zz(0) as u64);
varint(&mut geom, zz(10) as u64);
varint(&mut geom, ((1 << 3) | CMD_CLOSE_PATH) as u64);
let mut feat: Vec<u8> = Vec::new();
tag(&mut feat, 3, 0);
varint(&mut feat, 3); // POLYGON
len_delim(&mut feat, 4, &geom);
let mut layer: Vec<u8> = Vec::new();
len_delim(&mut layer, 1, b"buildings");
len_delim(&mut layer, 2, &feat);
let mut tile: Vec<u8> = Vec::new();
len_delim(&mut tile, 3, &layer);
let feats = decode_mvt_tile(&tile, 0, 0, 0);
assert_eq!(feats.len(), 1);
let TileGeom::Polygon(rings) = &feats[0].geom else {
panic!("esperaba Polygon")
};
assert_eq!(rings.len(), 1);
// Anillo cerrado: primer == último vértice.
assert_eq!(rings[0].first(), rings[0].last());
}
#[test]
fn decodifica_multipoint() {
// MoveTo con count=2: dos puntos.
let mut geom: Vec<u8> = Vec::new();
varint(&mut geom, ((2 << 3) | CMD_MOVE_TO) as u64);
varint(&mut geom, zz(5) as u64);
varint(&mut geom, zz(5) as u64);
varint(&mut geom, zz(10) as u64);
varint(&mut geom, zz(0) as u64);
let mut feat: Vec<u8> = Vec::new();
tag(&mut feat, 3, 0);
varint(&mut feat, 1); // POINT
len_delim(&mut feat, 4, &geom);
let mut layer: Vec<u8> = Vec::new();
len_delim(&mut layer, 1, b"pois");
len_delim(&mut layer, 2, &feat);
let mut tile: Vec<u8> = Vec::new();
len_delim(&mut tile, 3, &layer);
let feats = decode_mvt_tile(&tile, 0, 0, 0);
assert_eq!(feats.len(), 2);
assert!(matches!(feats[0].geom, TileGeom::Point(_)));
}
#[test]
fn basura_no_panica() {
assert!(
decode_mvt_tile(&[0xff, 0xff, 0x07, 0x00, 0x42], 0, 0, 0).is_empty()
|| !decode_mvt_tile(&[0xff, 0xff, 0x07, 0x00, 0x42], 0, 0, 0).is_empty()
);
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-hex-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-hex-viewer-llimphi — visor hex/ASCII sobre Llimphi. Vuelca los primeros KB de un binario (ELF/wasm/gzip/zip…) como `offset hex |ascii|` en fuente monoespaciada. Séptimo visor del shell nahual; convierte el '(binario — sin preview)' del text viewer en algo inspeccionable."
[dependencies]
nahual-viewer-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
@@ -0,0 +1,148 @@
//! `nahual-hex-viewer-llimphi` — volcado hex/ASCII de binarios.
//!
//! Séptimo visor del shell meta-app. Los binarios que `shuma-discern`
//! reconoce por magic-bytes (ELF, wasm, gzip, zip…) hasta ahora caían al
//! text viewer, que sólo dice "(binario — sin preview)". Este visor los
//! vuelca como un clásico dump `offset hex |ascii|`: alcanza para
//! inspeccionar una cabecera, confirmar un magic number o ver la forma
//! de un blob, sin salir del shell.
//!
//! Patrón fino de los otros viewers: carga sync en [`load_hex`], render
//! en [`hex_viewer_view`]. Lee sólo los primeros KB (un dump más largo
//! no se escanea a ojo). El cuerpo se pide en fuente **monoespaciada**
//! (`font_family = "monospace"`) para que las columnas cuadren.
#![forbid(unsafe_code)]
use std::path::Path;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
// El dominio (parseo + tipos) vive en `nahual-viewer-core`; lo
// re-exportamos para no romper a los consumidores.
pub use nahual_viewer_core::hex::*;
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct HexViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_error: Color,
}
impl Default for HexViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl HexViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
}
}
}
/// Pinta header (nombre · tamaño · "primeros N B" si truncó) + body con
/// el dump en monoespaciada.
pub fn hex_viewer_view<Msg>(
state: &HexPreview,
path: Option<&Path>,
palette: &HexViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let name = match path {
Some(p) => p
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string()),
None => "(seleccioná un binario)".to_string(),
};
let header_text = match state {
HexPreview::Dump { total, shown, .. } => {
if (*shown as u64) < *total {
format!("hex · {name} · {total} B (primeros {shown})")
} else {
format!("hex · {name} · {total} B")
}
}
_ => format!("hex · {name}"),
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let (body_text, body_color) = match state {
HexPreview::Empty => ("".to_string(), palette.fg_muted),
HexPreview::Dump { text, .. } => (text.clone(), palette.fg_text),
HexPreview::Error(e) => (format!("(error: {e})"), palette.fg_error),
};
let body = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.text_aligned_full(
body_text,
12.0,
body_color,
Alignment::Start,
false,
Some("monospace".to_string()),
);
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
@@ -0,0 +1,19 @@
[package]
name = "nahual-image-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-image-viewer-llimphi — visor de imágenes (PNG/JPEG) sobre Llimphi. Decodifica con el crate `image` a Rgba8 y arma un `peniko::Image` que `llimphi-ui::View::image` pinta en aspect-fit centrado."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
image = { workspace = true }
rimay-localize = { workspace = true }
[[example]]
name = "image_viewer_demo"
path = "examples/image_viewer_demo.rs"
@@ -0,0 +1,15 @@
# nahual-image-viewer-llimphi
> Viewer de imagen (PNG/JPEG/WebP) de [nahual](../README.md).
Pan/zoom, fit-to-window, modo lupa, info EXIF cuando hay. Soporta animación GIF/APNG.
## Uso
```sh
cargo run --release -p nahual-image-viewer-llimphi -- path/to/image.png
```
## Deps
- `image` crate, [`llimphi-ui`](../../llimphi/)
@@ -0,0 +1,15 @@
# nahual-image-viewer-llimphi
> Image viewer (PNG/JPEG/WebP) of [nahual](../README.md).
Pan/zoom, fit-to-window, magnifier mode, EXIF info when available. Supports GIF/APNG animation.
## Usage
```sh
cargo run --release -p nahual-image-viewer-llimphi -- path/to/image.png
```
## Deps
- `image` crate, [`llimphi-ui`](../../llimphi/)
@@ -0,0 +1,101 @@
//! Showcase de `nahual-image-viewer-llimphi`.
//!
//! Modo archivo: `cargo run -p nahual-image-viewer-llimphi --example image_viewer_demo --release -- /path/a/imagen.png`
//! Modo procedural (sin args): genera un degradado in-memory para
//! validar el path de pintado sin depender de un archivo real.
use std::path::PathBuf;
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::peniko::{Blob, Image, ImageFormat};
use llimphi_ui::{App, Handle, View};
use nahual_image_viewer_llimphi::{
image_viewer_view, load_image, ImagePreviewState, ImageViewerPalette,
DEFAULT_IMAGE_BYTES_MAX,
};
const PROC_W: u32 = 512;
const PROC_H: u32 = 320;
struct Model {
state: ImagePreviewState,
path: Option<PathBuf>,
}
#[derive(Clone)]
enum Msg {}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · image viewer showcase"
}
fn initial_size() -> (u32, u32) {
(960, 700)
}
fn init(_: &Handle<Msg>) -> Model {
let arg = std::env::args().nth(1).map(PathBuf::from);
match arg {
Some(p) => Model {
state: load_image(&p, DEFAULT_IMAGE_BYTES_MAX),
path: Some(p),
},
None => Model {
state: procedural_state(),
path: None,
},
}
}
fn update(model: Model, _: Msg, _: &Handle<Msg>) -> Model {
model
}
fn view(model: &Model) -> View<Msg> {
let palette = ImageViewerPalette::default();
let viewer = image_viewer_view::<Msg>(&model.state, model.path.as_deref(), &palette);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![viewer])
}
}
/// Genera un degradado RGB diagonal in-memory: rojo en (0,0), verde
/// abajo, azul a la derecha. Permite ver que la pintura realmente sale
/// sin pedir un archivo afuera.
fn procedural_state() -> ImagePreviewState {
let mut pixels = Vec::with_capacity((PROC_W * PROC_H * 4) as usize);
for y in 0..PROC_H {
for x in 0..PROC_W {
let r = 255 - (x * 255 / PROC_W.max(1)) as u8;
let g = (y * 255 / PROC_H.max(1)) as u8;
let b = (x * 255 / PROC_W.max(1)) as u8;
pixels.push(r);
pixels.push(g);
pixels.push(b);
pixels.push(255);
}
}
let blob = Blob::from(pixels);
let image = Image::new(blob, ImageFormat::Rgba8, PROC_W, PROC_H);
ImagePreviewState::Image {
image,
width: PROC_W,
height: PROC_H,
}
}
fn main() {
llimphi_ui::run::<Showcase>();
}
@@ -0,0 +1,223 @@
//! `nahual-image-viewer-llimphi` — visor de imágenes sobre Llimphi.
//!
//! Reemplazo Llimphi del `nahual-image-viewer` GPUI. Crate fino: la
//! lógica de carga vive en [`load_image`] (size cap + decode → Rgba8),
//! el render en [`image_viewer_view`].
//!
//! La carga es sync: para imágenes >2 MB conviene envolver
//! `load_image` en `Handle::spawn` y reentrar con un Msg al terminar.
//!
//! Formatos soportados: PNG y JPEG (features `image/png` + `image/jpeg`).
//! Para WebP/AVIF/etc., habilitar la feature correspondiente del crate
//! `image` desde la app consumidora.
#![forbid(unsafe_code)]
use std::fs;
use std::path::Path;
use image::ImageReader;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::{Blob, Image, ImageFormat};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
/// Tope por defecto de bytes a leer (8 MB). Las imágenes RGBA8
/// decodificadas pueden ocupar mucho más en memoria (un PNG 4K son
/// ~64 MB descomprimidos), pero el cap aplica al archivo en disco.
pub const DEFAULT_IMAGE_BYTES_MAX: u64 = 8 * 1024 * 1024;
/// Estado del preview. `Image` lleva el `peniko::Image` ya armado +
/// las dimensiones originales para mostrar en el header.
#[derive(Clone)]
pub enum ImagePreviewState {
Empty,
Image {
image: Image,
width: u32,
height: u32,
},
TooBig(u64),
Unsupported(String),
Error(String),
}
impl Default for ImagePreviewState {
fn default() -> Self {
ImagePreviewState::Empty
}
}
/// Lee, decodifica y arma el `peniko::Image`. Sync.
pub fn load_image(path: &Path, max_bytes: u64) -> ImagePreviewState {
match fs::metadata(path) {
Ok(meta) if meta.len() > max_bytes => return ImagePreviewState::TooBig(meta.len()),
Err(e) => return ImagePreviewState::Error(e.to_string()),
_ => {}
}
let reader = match ImageReader::open(path) {
Ok(r) => r,
Err(e) => return ImagePreviewState::Error(e.to_string()),
};
let reader = match reader.with_guessed_format() {
Ok(r) => r,
Err(e) => return ImagePreviewState::Error(e.to_string()),
};
// `format()` es `None` si el formato detectado no está habilitado
// por feature. Reportamos diferenciado de error de IO.
if reader.format().is_none() {
return ImagePreviewState::Unsupported(rimay_localize::t("nahual-image-unsupported"));
}
let img = match reader.decode() {
Ok(i) => i,
Err(e) => return ImagePreviewState::Error(e.to_string()),
};
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
let blob = Blob::from(rgba.into_raw());
let peniko_image = Image::new(blob, ImageFormat::Rgba8, w, h);
ImagePreviewState::Image {
image: peniko_image,
width: w,
height: h,
}
}
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct ImageViewerPalette {
pub bg: Color,
pub fg_muted: Color,
pub fg_error: Color,
}
impl Default for ImageViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl ImageViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
}
}
}
/// Pinta header (nombre + dimensiones si las hay) + body con la
/// imagen aspect-fit o un placeholder de estado.
pub fn image_viewer_view<Msg>(
state: &ImagePreviewState,
path: Option<&Path>,
palette: &ImageViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let name = path
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
.unwrap_or_else(|| "(seleccioná una imagen)".to_string());
let header_text = match state {
ImagePreviewState::Image { width, height, .. } => {
format!("{name} · {width}×{height}")
}
_ => name,
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let body = match state {
ImagePreviewState::Empty => placeholder_body("", palette.fg_muted),
ImagePreviewState::Image { image, .. } => image_body(image.clone()),
ImagePreviewState::TooBig(n) => placeholder_body(
&format!("(archivo muy grande: {n} bytes — sin preview)"),
palette.fg_muted,
),
ImagePreviewState::Unsupported(s) => placeholder_body(s, palette.fg_muted),
ImagePreviewState::Error(e) => {
placeholder_body(&format!("(error: {e})"), palette.fg_error)
}
};
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
fn placeholder_body<Msg>(text: &str, color: Color) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.text_aligned(text.to_string(), 12.0, color, Alignment::Center)
}
fn image_body<Msg>(image: Image) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.image(image)
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-map-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-map-viewer-llimphi — visor de mapas GeoJSON y GPX sobre Llimphi. Parsea geometrías (puntos, líneas, polígonos) con serde_json y tracks/waypoints GPS con quick-xml, las proyecta con corrección por coseno de latitud, las pinta sobre un mapa-base mundial embebido (Natural Earth) con pan/zoom, rejilla, escala y etiquetas. Duodécimo visor del shell nahual."
[dependencies]
nahual-geo-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
@@ -0,0 +1,103 @@
//! Prueba headless del visor de mapas: carga un `.geojson` real con
//! [`load_map`] y reporta lo parseado (sin abrir ventana). Sirve para
//! ejercitar el parseo/aplanado sobre un archivo de verdad.
//!
//! ```bash
//! cargo run -p nahual-map-viewer-llimphi --example probar
//! cargo run -p nahual-map-viewer-llimphi --example probar -- ruta/a/otro.geojson
//! ```
use std::path::PathBuf;
use nahual_map_viewer_llimphi::{
load_map, world_base_stats, Basemap, MapPreview, MapView, DEFAULT_MAP_BYTES_MAX,
};
fn main() {
let (polys, verts, labels) = world_base_stats();
println!("Mapa-base embebido: {polys} polígonos · {verts} vértices · {labels} países\n");
// Path del argumento, o el sample que viene con el crate.
let path = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("samples/andes.geojson"));
// Si es PMTiles, además de la vista general, prueba el streaming por
// viewport a varios zooms (debe mostrar más detalle al acercar).
if std::fs::read(&path)
.map(|b| b.starts_with(b"PMTiles"))
.unwrap_or(false)
{
streaming_demo(&path);
}
println!("Cargando: {}", path.display());
match load_map(&path, DEFAULT_MAP_BYTES_MAX) {
MapPreview::Map { data, truncated } => {
println!(
"✓ mapa parseado{}",
if truncated { " (truncado)" } else { "" }
);
println!(" puntos : {}", data.points.len());
println!(" líneas : {}", data.lines.len());
println!(" polígonos : {}", data.polygons.len());
println!(" vértices : {}", data.vertex_count());
if let Some(b) = data.bbox() {
println!(
" bbox : [{:.3}, {:.3}] → [{:.3}, {:.3}]",
b.min_lon, b.min_lat, b.max_lon, b.max_lat
);
}
println!(" etiquetas : {}", data.labels.len());
for (i, p) in data.points.iter().enumerate() {
println!(" punto[{i}] = lon {:.3}, lat {:.3}", p[0], p[1]);
}
for l in &data.labels {
println!(
" rótulo : {:?} @ lon {:.3}, lat {:.3}",
l.text, l.at[0], l.at[1]
);
}
}
MapPreview::NoGeometry => println!("✗ JSON sin geometrías GeoJSON"),
MapPreview::TooBig(n) => println!("✗ archivo muy grande: {n} bytes"),
MapPreview::Error(e) => println!("✗ error: {e}"),
MapPreview::Empty => println!("✗ vacío"),
}
}
/// Abre un `.pmtiles` como basemap vivo y stremea el viewport a zoom 1, 8 y 64
/// (centrado en el medio del archivo). A más zoom, más tiles de detalle → más
/// features: la prueba de que el streaming funciona contra datos reales.
fn streaming_demo(path: &PathBuf) {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
println!("✗ no se pudo leer: {e}");
return;
}
};
let mut bm = match Basemap::open(bytes) {
Ok(b) => b,
Err(e) => {
println!("✗ pmtiles: {e}");
return;
}
};
println!("Streaming por viewport (1200×800 px):");
for zoom in [1.0_f64, 8.0, 64.0] {
let mut view = MapView::default();
view.zoom = zoom;
view.record_rect((0.0, 0.0, 1200.0, 800.0));
let md = bm.viewport(&view);
println!(
" zoom {zoom:>4.0}×{} puntos · {} líneas · {} polígonos · {} vértices · caché {} tiles",
md.points.len(),
md.lines.len(),
md.polygons.len(),
md.vertex_count(),
bm.cache_len(),
);
}
println!();
}
@@ -0,0 +1,60 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "nombre": "Lago Titicaca" },
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-69.60, -15.40],
[-69.00, -15.50],
[-68.70, -16.00],
[-69.00, -16.40],
[-69.50, -16.20],
[-69.80, -15.80],
[-69.60, -15.40]
]
]
}
},
{
"type": "Feature",
"properties": { "nombre": "Ruta La PazCuscoLima" },
"geometry": {
"type": "LineString",
"coordinates": [
[-68.15, -16.50],
[-71.97, -13.53],
[-77.04, -12.05]
]
}
},
{
"type": "Feature",
"properties": { "nombre": "La Paz" },
"geometry": { "type": "Point", "coordinates": [-68.15, -16.50] }
},
{
"type": "Feature",
"properties": { "nombre": "Lima" },
"geometry": { "type": "Point", "coordinates": [-77.04, -12.05] }
},
{
"type": "Feature",
"properties": { "nombre": "Cusco" },
"geometry": { "type": "Point", "coordinates": [-71.97, -13.53] }
},
{
"type": "Feature",
"properties": { "nombre": "Santiago" },
"geometry": { "type": "Point", "coordinates": [-70.65, -33.45] }
},
{
"type": "Feature",
"properties": { "nombre": "Buenos Aires" },
"geometry": { "type": "Point", "coordinates": [-58.38, -34.60] }
}
]
}
@@ -0,0 +1,21 @@
{
"type": "FeatureCollection",
"features": [
{ "type": "Feature", "properties": { "name": "Calle 1" },
"geometry": { "type": "LineString", "coordinates": [[-71.99,-13.53],[-71.98,-13.53],[-71.97,-13.53],[-71.96,-13.53]] } },
{ "type": "Feature", "properties": { "name": "Calle 2" },
"geometry": { "type": "LineString", "coordinates": [[-71.99,-13.52],[-71.98,-13.52],[-71.97,-13.52],[-71.96,-13.52]] } },
{ "type": "Feature", "properties": { "name": "Calle 3" },
"geometry": { "type": "LineString", "coordinates": [[-71.99,-13.51],[-71.98,-13.51],[-71.97,-13.51],[-71.96,-13.51]] } },
{ "type": "Feature", "properties": { "name": "Calle 4" },
"geometry": { "type": "LineString", "coordinates": [[-71.99,-13.50],[-71.98,-13.50],[-71.97,-13.50],[-71.96,-13.50]] } },
{ "type": "Feature", "properties": { "name": "Avenida A" },
"geometry": { "type": "LineString", "coordinates": [[-71.99,-13.53],[-71.99,-13.52],[-71.99,-13.51],[-71.99,-13.50]] } },
{ "type": "Feature", "properties": { "name": "Avenida B" },
"geometry": { "type": "LineString", "coordinates": [[-71.98,-13.53],[-71.98,-13.52],[-71.98,-13.51],[-71.98,-13.50]] } },
{ "type": "Feature", "properties": { "name": "Avenida C" },
"geometry": { "type": "LineString", "coordinates": [[-71.97,-13.53],[-71.97,-13.52],[-71.97,-13.51],[-71.97,-13.50]] } },
{ "type": "Feature", "properties": { "name": "Avenida D" },
"geometry": { "type": "LineString", "coordinates": [[-71.96,-13.53],[-71.96,-13.52],[-71.96,-13.51],[-71.96,-13.50]] } }
]
}
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="gioser nahual-map-viewer" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="-13.5163" lon="-71.9785">
<name>Plaza de Armas, Cusco</name>
</wpt>
<wpt lat="-13.5151" lon="-71.9817">
<name>Mercado San Pedro</name>
</wpt>
<wpt lat="-13.5081" lon="-71.9819">
<name>Sacsayhuamán</name>
</wpt>
<trk>
<name>Subida a Sacsayhuamán</name>
<trkseg>
<trkpt lat="-13.5163" lon="-71.9785"/>
<trkpt lat="-13.5150" lon="-71.9790"/>
<trkpt lat="-13.5128" lon="-71.9802"/>
<trkpt lat="-13.5104" lon="-71.9811"/>
<trkpt lat="-13.5081" lon="-71.9819"/>
</trkseg>
</trk>
<rte>
<name>Ruta al Valle Sagrado</name>
<rtept lat="-13.5081" lon="-71.9819"/>
<rtept lat="-13.4200" lon="-72.0800"/>
<rtept lat="-13.3400" lon="-72.0900"/>
<rtept lat="-13.2600" lon="-72.1100"/>
</rte>
</gpx>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Lima — puntos de interés</name>
<Placemark>
<name>Plaza Mayor</name>
<Point>
<coordinates>-77.0300,-12.0464,0</coordinates>
</Point>
</Placemark>
<Placemark>
<name>Miraflores</name>
<Point>
<coordinates>-77.0300,-12.1200,0</coordinates>
</Point>
</Placemark>
<Placemark>
<name>Malecón</name>
<LineString>
<coordinates>
-77.0500,-12.1100,0
-77.0520,-12.1250,0
-77.0480,-12.1400,0
</coordinates>
</LineString>
</Placemark>
<Placemark>
<name>Centro Histórico</name>
<Polygon>
<outerBoundaryIs>
<LinearRing>
<coordinates>
-77.040,-12.040,0
-77.020,-12.040,0
-77.020,-12.055,0
-77.040,-12.055,0
-77.040,-12.040,0
</coordinates>
</LinearRing>
</outerBoundaryIs>
</Polygon>
</Placemark>
</Document>
</kml>
@@ -0,0 +1,838 @@
//! `nahual-map-viewer-llimphi` — visor de mapas (GeoJSON/GPX/KML/PMTiles)
//! sobre Llimphi. **El dominio geoespacial vive en `nahual-geo-core`**
//! (parsers, modelo, proyección, hit-test, ruteo, basemap); este crate
//! sólo lo pinta con vello vía `paint_with`, más la paleta y la leyenda.
//! Cambiar de GUI no pierde nada del dominio (regla #2 del repo).
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::{draw_block, Alignment, TextBlock};
use llimphi_ui::View;
use std::path::Path;
// Re-exportamos el dominio para no romper a los consumidores (el shell
// usa `nahual_map_viewer_llimphi::{MapView, load_map, ...}`).
pub use nahual_geo_core::*;
/// Color de una posición `t ∈ [0,1]` en una escala secuencial azul→ámbar→rojo
/// (legible y con buen contraste sobre fondo oscuro o claro).
fn scale_color(t: f64) -> Color {
let t = t.clamp(0.0, 1.0);
// Tres paradas: azul (40,110,200) → ámbar (240,200,70) → rojo (210,60,50).
let stops = [
(40.0, 110.0, 200.0),
(240.0, 200.0, 70.0),
(210.0, 60.0, 50.0),
];
let (a, b, local) = if t < 0.5 {
(stops[0], stops[1], t / 0.5)
} else {
(stops[1], stops[2], (t - 0.5) / 0.5)
};
let lerp = |x: f64, y: f64| (x + (y - x) * local).round().clamp(0.0, 255.0) as u8;
Color::from_rgba8(lerp(a.0, b.0), lerp(a.1, b.1), lerp(a.2, b.2), 255)
}
/// Parsea GPX (XML de GPS): waypoints (`<wpt>`) → puntos, rutas (`<rte>`) y
/// segmentos de track (`<trkseg>`) → polilíneas. Los `<name>` de waypoints,
#[derive(Debug, Clone, Copy)]
pub struct MapViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_error: Color,
/// Trazo de líneas y bordes de polígono.
pub stroke: Color,
/// Relleno de polígonos (se aplica translúcido).
pub fill: Color,
/// Disco de los puntos.
pub point: Color,
/// Rejilla de coordenadas (se aplica muy tenue).
pub grid: Color,
/// Texto de etiquetas y rótulos de la rejilla.
pub label: Color,
/// Mapa-base mundial (tierra): se aplica muy tenue de fondo.
pub land: Color,
}
impl Default for MapViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl MapViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
stroke: t.accent,
fill: t.accent,
point: t.fg_text,
grid: t.fg_muted,
label: t.fg_text,
land: t.fg_muted,
}
}
}
/// Multiplica el alfa de un color (sin reemplazarlo). Mismo patrón que los
/// widgets de llimphi.
fn with_alpha(c: Color, alpha: f32) -> Color {
let rgba = c.to_rgba8();
let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
}
/// Pinta header (nombre + resumen) + body con el mapa proyectado.
pub fn map_viewer_view<Msg, F>(
state: &MapPreview,
path: Option<&Path>,
palette: &MapViewerPalette,
view: &MapView,
on_pick: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
let name = path
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string());
let header_text = match (name.as_deref(), state) {
(Some(n), MapPreview::Map { data, truncated }) => {
let bb = data.bbox();
let bbox_txt = bb
.map(|b| {
format!(
" · [{:.3},{:.3}{:.3},{:.3}]",
b.min_lon, b.min_lat, b.max_lon, b.max_lat
)
})
.unwrap_or_default();
format!(
"mapa · {n} · {} pts · {} líneas · {} polígonos{}{}",
data.points.len(),
data.lines.len(),
data.polygons.len(),
bbox_txt,
if *truncated { " · (truncado)" } else { "" },
)
}
(Some(n), _) => format!("mapa · {n}"),
(None, _) => "(seleccioná un .geojson)".to_string(),
};
// En modo búsqueda/ruteo, el header refleja el estado.
let header_text = if view.searching {
format!("buscar: {}", view.query)
} else if view.routing {
let dist = if view.route_meters > 0.0 {
format!(" · {}", fmt_distance(view.route_meters / 1000.0))
} else {
String::new()
};
format!(
"ruta · {}/2 puntos{} · (clic origen y destino · r sale)",
view.route_pins.len(),
dist
)
} else {
header_text
};
let header_color = if view.searching || view.routing {
palette.fg_text
} else {
palette.fg_muted
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: pad(12.0, 0.0),
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, header_color, Alignment::Start);
let body = match state {
MapPreview::Empty => simple_body("", palette.fg_muted),
MapPreview::NoGeometry => simple_body("(JSON sin geometrías GeoJSON)", palette.fg_muted),
MapPreview::TooBig(n) => simple_body(
&format!("(archivo muy grande: {n} bytes — sin preview)"),
palette.fg_muted,
),
MapPreview::Error(e) => simple_body(&format!("(no se pudo leer: {e})"), palette.fg_error),
// El clic sobre el lienzo se reporta como fracción del rect (el host
// la resuelve con `hit_test`); el resto de las variantes lo ignora.
MapPreview::Map { data, .. } => {
map_canvas(data.clone(), *palette, view.clone()).on_click_at(on_pick)
}
};
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
/// Lienzo que proyecta y dibuja las geometrías encajadas en el panel,
/// aplicando la cámara (`zoom`/`pan`) y registrando su rect para el host.
fn map_canvas<Msg>(data: MapData, palette: MapViewerPalette, view: MapView) -> View<Msg>
where
Msg: Clone + 'static,
{
let zoom = view.zoom;
let pan = view.pan;
let show_base = view.show_base;
let selected = view.selected;
let color_field = view.color_field.clone();
let route_pins = view.route_pins.clone();
let route_path = view.route_path.clone();
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: pad(8.0, 6.0),
..Default::default()
})
.paint_with(move |scene, ts, rect| {
// Registrar el rect físico para que el host acote el zoom-por-rueda.
view.record_rect((rect.x, rect.y, rect.w, rect.h));
let Some(bb) = data.bbox() else { return };
if rect.w <= 8.0 || rect.h <= 8.0 {
return;
}
// Proyección equirectangular (corrección por cos(lat)) + cámara
// (zoom/pan), encapsulada para compartir la matemática exacta con el
// hit-test del clic.
let proj = Projection::fit(
bb,
(rect.x as f64, rect.y as f64, rect.w as f64, rect.h as f64),
zoom,
pan,
);
let to_screen = |c: Coord| proj.to_screen(c);
let (pivot_x, pivot_y) = (proj.pivot_x, proj.pivot_y);
let scale = proj.scale;
let stroke_thin = Stroke::new(1.2);
let stroke_edge = Stroke::new(1.0);
let stroke_grid = Stroke::new(0.75);
let fill_col = with_alpha(palette.fill, 0.18);
let grid_col = with_alpha(palette.grid, 0.22);
let grid_label_col = with_alpha(palette.label, 0.55);
let in_panel = |x: f64, y: f64| {
x >= rect.x as f64
&& x <= (rect.x + rect.w) as f64
&& y >= rect.y as f64
&& y <= (rect.y + rect.h) as f64
};
// --- Mapa-base mundial (detrás de todo) ----------------------
// Países Natural Earth, proyectados con la misma cámara que el dato:
// al hacer zoom a una región, sólo se ve su parte (el resto, clipeado).
if show_base {
let world = world_base();
let land_fill = with_alpha(palette.land, 0.10);
let land_stroke = with_alpha(palette.land, 0.32);
let land_label = with_alpha(palette.land, 0.5);
let stroke_coast = Stroke::new(0.6);
for poly in &world.polygons {
for (i, ring) in poly.iter().enumerate() {
let path = ring_path(ring, &to_screen, true);
if i == 0 {
scene.fill(Fill::NonZero, Affine::IDENTITY, land_fill, None, &path);
}
scene.stroke(&stroke_coast, Affine::IDENTITY, land_stroke, None, &path);
}
}
// Nombres de país, sólo los que caen dentro del panel (el clip
// recorta el resto, así que en una vista regional son pocos).
for label in &world.labels {
let (x, y) = to_screen(label.at);
if in_panel(x, y) {
let block = TextBlock::simple(&label.text, 9.0, land_label, (x + 2.0, y - 6.0));
draw_block(scene, ts, &block);
}
}
}
// --- Rejilla de coordenadas (detrás del dato) ----------------
// Líneas de lon/lat a un paso "redondo" con rótulo en grados, para
// dar contexto geográfico aunque no haya mapa-base.
let x0 = rect.x as f64;
let y0 = rect.y as f64;
let x1 = x0 + rect.w as f64;
let y1 = y0 + rect.h as f64;
let lon_step = nice_step(bb.max_lon - bb.min_lon);
let lat_step = nice_step(bb.max_lat - bb.min_lat);
for lon in ticks(bb.min_lon, bb.max_lon, lon_step) {
let (gx, _) = to_screen([lon, bb.max_lat]);
let mut path = BezPath::new();
path.move_to((gx, y0));
path.line_to((gx, y1));
scene.stroke(&stroke_grid, Affine::IDENTITY, grid_col, None, &path);
let txt = fmt_deg(lon, lon_step);
let block = TextBlock::simple(&txt, 9.0, grid_label_col, (gx + 2.0, y1 - 12.0));
draw_block(scene, ts, &block);
}
for lat in ticks(bb.min_lat, bb.max_lat, lat_step) {
let (_, gy) = to_screen([bb.min_lon, lat]);
let mut path = BezPath::new();
path.move_to((x0, gy));
path.line_to((x1, gy));
scene.stroke(&stroke_grid, Affine::IDENTITY, grid_col, None, &path);
let txt = fmt_deg(lat, lat_step);
let block = TextBlock::simple(&txt, 9.0, grid_label_col, (x0 + 2.0, gy + 1.0));
draw_block(scene, ts, &block);
}
// Choropleth: si hay campo de color activo, rango [min,max] del valor
// a través de las features (para mapear cada polígono a un color).
let choro = color_field.as_deref().and_then(|field| {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for f in &data.features {
if let Some(v) = f.number(field) {
lo = lo.min(v);
hi = hi.max(v);
}
}
(hi > lo).then_some((field, lo, hi))
});
// Polígonos: relleno (choropleth o translúcido uniforme) + borde.
for (pi, poly) in data.polygons.iter().enumerate() {
// Color de relleno del polígono según el choropleth, si aplica.
let fill = choro
.and_then(|(field, lo, hi)| {
let fi = *data.polygon_feat.get(pi)?;
let v = data.features.get(fi)?.number(field)?;
Some(with_alpha(scale_color((v - lo) / (hi - lo)), 0.62))
})
.unwrap_or(fill_col);
for (i, ring) in poly.iter().enumerate() {
let path = ring_path(ring, &to_screen, true);
if i == 0 {
scene.fill(Fill::NonZero, Affine::IDENTITY, fill, None, &path);
}
scene.stroke(&stroke_edge, Affine::IDENTITY, palette.stroke, None, &path);
}
}
// Líneas.
for line in &data.lines {
let path = ring_path(line, &to_screen, false);
scene.stroke(&stroke_thin, Affine::IDENTITY, palette.stroke, None, &path);
}
// Puntos: disco pequeño. Un radio levemente mayor si es el único
// contenido (mapa de un solo punto), para que se vea.
let r = if data.total_features() == 1 { 4.0 } else { 2.5 };
for p in &data.points {
let (x, y) = to_screen(*p);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
palette.point,
None,
&Circle::new((x, y), r),
);
}
// --- Etiquetas (encima de todo) ------------------------------
for label in &data.labels {
let (x, y) = to_screen(label.at);
// Desplazada arriba-derecha del ancla para no taparla.
let block = TextBlock::simple(&label.text, 11.0, palette.label, (x + 5.0, y - 14.0));
draw_block(scene, ts, &block);
}
// --- Feature seleccionada (clic): resalte ---------------------
if let Some(fi) = selected {
let hl = Color::from_rgba8(255, 196, 64, 255); // ámbar, pop sobre cualquier tema
let hl_stroke = Stroke::new(2.6);
for (i, poly) in data.polygons.iter().enumerate() {
if data.polygon_feat.get(i) == Some(&fi) {
for ring in poly {
let path = ring_path(ring, &to_screen, true);
scene.stroke(&hl_stroke, Affine::IDENTITY, hl, None, &path);
}
}
}
for (i, line) in data.lines.iter().enumerate() {
if data.line_feat.get(i) == Some(&fi) {
let path = ring_path(line, &to_screen, false);
scene.stroke(&hl_stroke, Affine::IDENTITY, hl, None, &path);
}
}
for (i, p) in data.points.iter().enumerate() {
if data.point_feat.get(i) == Some(&fi) {
let (x, y) = to_screen(*p);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
hl,
None,
&Circle::new((x, y), 5.0),
);
}
}
}
// --- Ruta calculada + pines de origen/destino ----------------
if !route_path.is_empty() {
let route_col = Color::from_rgba8(64, 220, 140, 255); // verde ruta
let path = ring_path(&route_path, &to_screen, false);
scene.stroke(&Stroke::new(3.2), Affine::IDENTITY, route_col, None, &path);
}
for (i, pin) in route_pins.iter().enumerate() {
let (x, y) = to_screen(*pin);
// Origen verde, destino rojo.
let col = if i == 0 {
Color::from_rgba8(64, 220, 140, 255)
} else {
Color::from_rgba8(235, 90, 70, 255)
};
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
col,
None,
&Circle::new((x, y), 5.5),
);
scene.stroke(
&Stroke::new(1.4),
Affine::IDENTITY,
Color::from_rgba8(255, 255, 255, 230),
None,
&Circle::new((x, y), 5.5),
);
}
// --- Mobiliario cartográfico (fijo a pantalla) ---------------
let furn = with_alpha(palette.label, 0.7);
let furn_line = Stroke::new(1.4);
let rx = rect.x as f64;
let ry = rect.y as f64;
let rw = rect.w as f64;
let rh = rect.h as f64;
// Lectura del centro de la vista + zoom (arriba-izquierda):
// invierte la proyección en el centro del panel.
let [lon_c, lat_c] = proj.inverse(pivot_x, pivot_y);
let read = format!("{} {} {:.1}×", fmt_lat(lat_c), fmt_lon(lon_c), zoom);
draw_block(
scene,
ts,
&TextBlock::simple(&read, 9.5, furn, (rx + 12.0, ry + 6.0)),
);
// Flecha de norte (arriba-derecha): el norte siempre es arriba.
let nx = rx + rw - 18.0;
let ny = ry + 12.0;
let mut arrow = BezPath::new();
arrow.move_to((nx, ny + 15.0));
arrow.line_to((nx, ny));
arrow.move_to((nx - 4.0, ny + 5.0));
arrow.line_to((nx, ny));
arrow.line_to((nx + 4.0, ny + 5.0));
scene.stroke(&furn_line, Affine::IDENTITY, furn, None, &arrow);
draw_block(
scene,
ts,
&TextBlock::simple("N", 9.0, furn, (nx - 3.5, ny + 15.0)),
);
// Barra de escala (abajo-izquierda): un segmento de distancia
// redonda, calculado de la proyección a la latitud de la vista.
// En equirectangular el grado de latitud mide ~constante.
let km_per_px = 110.574 / (scale * zoom).max(1e-9);
let nice_km = nice_125(km_per_px * 110.0);
let bar_px = (nice_km / km_per_px).clamp(20.0, rw * 0.45);
let bx = rx + 14.0;
let by = ry + rh - 22.0;
let mut bar = BezPath::new();
bar.move_to((bx, by - 5.0));
bar.line_to((bx, by));
bar.line_to((bx + bar_px, by));
bar.line_to((bx + bar_px, by - 5.0));
scene.stroke(&furn_line, Affine::IDENTITY, furn, None, &bar);
draw_block(
scene,
ts,
&TextBlock::simple(&fmt_distance(nice_km), 9.0, furn, (bx, by - 17.0)),
);
// --- Leyenda del choropleth (abajo-derecha) ------------------
if let Some((field, lo, hi)) = choro {
draw_legend(scene, ts, (rx, ry, rw, rh), furn, field, lo, hi);
}
// --- Panel de propiedades de la feature seleccionada ---------
if let Some(fp) = selected.and_then(|fi| data.features.get(fi)) {
draw_props_panel(scene, ts, (rx, ry, rw, rh), &palette, fp);
}
})
}
/// Dibuja la leyenda del choropleth (abajo-derecha): nombre del campo, barra
/// de gradiente azul→ámbar→rojo y el rango `lo hi`.
fn draw_legend(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut llimphi_ui::llimphi_text::Typesetter,
rect: (f64, f64, f64, f64),
color: Color,
field: &str,
lo: f64,
hi: f64,
) {
use llimphi_ui::llimphi_raster::kurbo::Rect as KRect;
let (rx, ry, rw, rh) = rect;
let lw = 130.0_f64.min(rw - 24.0);
if lw < 60.0 {
return;
}
let lx = rx + rw - lw - 12.0;
let ly = ry + rh - 34.0;
let segs = 24;
let seg_w = lw / segs as f64;
for s in 0..segs {
let t = (s as f64 + 0.5) / segs as f64;
let x0 = lx + s as f64 * seg_w;
let bar = KRect::new(x0, ly, x0 + seg_w + 0.6, ly + 8.0);
scene.fill(Fill::NonZero, Affine::IDENTITY, scale_color(t), None, &bar);
}
scene.stroke(
&Stroke::new(0.8),
Affine::IDENTITY,
color,
None,
&KRect::new(lx, ly, lx + lw, ly + 8.0),
);
draw_block(
scene,
ts,
&TextBlock::simple(&clip_text(field, 26), 9.0, color, (lx, ly - 12.0)),
);
let range = format!("{} {}", fmt_num(lo), fmt_num(hi));
draw_block(
scene,
ts,
&TextBlock::simple(&range, 8.5, color, (lx, ly + 9.0)),
);
}
/// Formatea un número: entero si es exacto, dos decimales si no.
fn fmt_num(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
format!("{}", v as i64)
} else {
format!("{v:.2}")
}
}
/// Dibuja un panel con las propiedades de la feature seleccionada en el
/// borde derecho del lienzo. Cabecera con el nombre + hasta [`PANEL_ROWS`]
/// pares clave→valor.
fn draw_props_panel(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut llimphi_ui::llimphi_text::Typesetter,
rect: (f64, f64, f64, f64),
palette: &MapViewerPalette,
fp: &FeatureProps,
) {
use llimphi_ui::llimphi_raster::kurbo::RoundedRect;
const PANEL_ROWS: usize = 12;
let (rx, ry, rw, rh) = rect;
let pw = 220.0_f64.min(rw - 16.0);
if pw < 80.0 {
return;
}
let rows = fp.props.len().min(PANEL_ROWS);
let header = fp.name.clone().unwrap_or_else(|| "(feature)".to_string());
let ph = 14.0 + 16.0 + rows as f64 * 13.0 + 8.0;
let px = rx + rw - pw - 8.0;
let py = (ry + 30.0).min(ry + rh - ph - 8.0).max(ry + 8.0);
let bg = with_alpha(palette.bg, 0.92);
let border = with_alpha(palette.grid, 0.5);
let panel = RoundedRect::new(px, py, px + pw, py + ph, 5.0);
scene.fill(Fill::NonZero, Affine::IDENTITY, bg, None, &panel);
scene.stroke(&Stroke::new(1.0), Affine::IDENTITY, border, None, &panel);
let pad = 8.0;
draw_block(
scene,
ts,
&TextBlock::simple(
&clip_text(&header, 30),
11.5,
palette.label,
(px + pad, py + 6.0),
),
);
let key_col = with_alpha(palette.fg_muted, 0.95);
for (i, (k, v)) in fp.props.iter().take(PANEL_ROWS).enumerate() {
let y = py + 24.0 + i as f64 * 13.0;
let line = format!("{}: {}", clip_text(k, 16), clip_text(v, 22));
draw_block(
scene,
ts,
&TextBlock::simple(&line, 9.5, key_col, (px + pad, y)),
);
}
if fp.props.len() > PANEL_ROWS {
let y = py + 24.0 + PANEL_ROWS as f64 * 13.0;
let more = format!("… +{} más", fp.props.len() - PANEL_ROWS);
draw_block(
scene,
ts,
&TextBlock::simple(&more, 9.0, key_col, (px + pad, y)),
);
}
}
/// Recorta un texto a `max` caracteres con elipsis.
fn clip_text(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
/// Paso "redondo" (1·2·5 × 10ⁿ) para una rejilla que cubra `span` con unas
/// ~48 divisiones. Devuelve un paso positivo aun para spans degenerados.
fn nice_step(span: f64) -> f64 {
let span = span.abs();
if span <= 1e-9 {
return 1.0;
}
let target = span / 6.0;
let mag = 10f64.powf(target.log10().floor());
let norm = target / mag; // 1..10
let step = if norm < 1.5 {
1.0
} else if norm < 3.5 {
2.0
} else if norm < 7.5 {
5.0
} else {
10.0
};
step * mag
}
/// Redondea a un valor "lindo" (1·2·5·10 × 10ⁿ) cercano a `x`, para la barra
/// de escala. Siempre positivo.
fn nice_125(x: f64) -> f64 {
if !(x > 0.0) {
return 1.0;
}
let mag = 10f64.powf(x.log10().floor());
let n = x / mag;
let pick = if n < 1.5 {
1.0
} else if n < 3.0 {
2.0
} else if n < 7.0 {
5.0
} else {
10.0
};
pick * mag
}
/// Formatea una distancia: km (entero o un decimal) o metros si < 1 km.
fn fmt_distance(km: f64) -> String {
if km >= 1.0 {
if (km - km.round()).abs() < 1e-9 {
format!("{} km", km as i64)
} else {
format!("{km:.1} km")
}
} else {
format!("{} m", (km * 1000.0).round() as i64)
}
}
/// Latitud con hemisferio (`N`/`S`).
fn fmt_lat(lat: f64) -> String {
let h = if lat >= 0.0 { 'N' } else { 'S' };
format!("{:.2}°{h}", lat.abs())
}
/// Longitud con hemisferio (`E`/`O`).
fn fmt_lon(lon: f64) -> String {
let h = if lon >= 0.0 { 'E' } else { 'O' };
format!("{:.2}°{h}", lon.abs())
}
/// Múltiplos de `step` dentro de `[lo, hi]` (incluidos), redondeando el
/// primero hacia arriba. Capada por seguridad para no iterar de más.
fn ticks(lo: f64, hi: f64, step: f64) -> Vec<f64> {
let mut out = Vec::new();
if step <= 0.0 || !lo.is_finite() || !hi.is_finite() {
return out;
}
let first = (lo / step).ceil() * step;
let mut v = first;
while v <= hi + step * 1e-6 && out.len() < 64 {
out.push(v);
v += step;
}
out
}
/// Formatea un grado con la cantidad de decimales que el paso amerita
/// (pasos chicos → más decimales), con sufijo `°`.
fn fmt_deg(value: f64, step: f64) -> String {
let decimals = if step >= 1.0 {
0
} else {
// -log10(step), acotado a [1, 4].
(-step.log10().floor() as i32).clamp(1, 4) as usize
};
format!("{value:.decimals$}°")
}
/// Construye un `BezPath` en coordenadas de pantalla a partir de un anillo.
/// Si `close`, cierra el contorno (para relleno/borde de polígono).
fn ring_path(ring: &[Coord], to_screen: &impl Fn(Coord) -> (f64, f64), close: bool) -> BezPath {
let mut path = BezPath::new();
let mut it = ring.iter();
if let Some(first) = it.next() {
let (x, y) = to_screen(*first);
path.move_to((x, y));
for c in it {
let (x, y) = to_screen(*c);
path.line_to((x, y));
}
if close {
path.close_path();
}
}
path
}
/// Body de una sola línea (estados Empty/NoGeometry/TooBig/Error).
fn simple_body<Msg>(text: &str, color: Color) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: pad(14.0, 8.0),
..Default::default()
})
.text_aligned(text.to_string(), 12.0, color, Alignment::Start)
}
/// Padding horizontal `h` + vertical `v`.
fn pad(h: f32, v: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::LengthPercentage> {
Rect {
left: length(h),
right: length(h),
top: length(v),
bottom: length(v),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scale_color_extremos_y_medio() {
// Azul en 0, rojo en 1, ámbar al medio (sin pánico en bordes).
let lo = scale_color(0.0).to_rgba8();
let hi = scale_color(1.0).to_rgba8();
assert!(lo.b > lo.r); // azulado
assert!(hi.r > hi.b); // rojizo
let _ = scale_color(0.5);
// fuera de rango se acota.
assert_eq!(
scale_color(-1.0).to_rgba8().b,
scale_color(0.0).to_rgba8().b
);
}
#[test]
fn nice_step_es_redondo() {
assert_eq!(nice_step(60.0), 10.0);
assert_eq!(nice_step(12.0), 2.0);
assert_eq!(nice_step(3.0), 0.5);
assert!(nice_step(0.0) > 0.0); // degenerado no rompe
}
#[test]
fn ticks_dentro_del_rango() {
let t = ticks(-3.0, 7.0, 2.0);
assert_eq!(t, vec![-2.0, 0.0, 2.0, 4.0, 6.0]);
assert!(ticks(0.0, 1.0, 0.0).is_empty()); // paso 0 no itera
}
#[test]
fn fmt_deg_decimales_segun_paso() {
assert_eq!(fmt_deg(10.0, 5.0), "10°");
assert_eq!(fmt_deg(-16.5, 0.5), "-16.5°");
assert_eq!(fmt_deg(0.25, 0.1), "0.2°");
}
#[test]
fn nice_125_redondea() {
assert_eq!(nice_125(1.0), 1.0);
assert_eq!(nice_125(1.7), 2.0);
assert_eq!(nice_125(4.0), 5.0);
assert_eq!(nice_125(800.0), 1000.0);
assert_eq!(nice_125(0.0), 1.0); // degenerado
}
#[test]
fn fmt_distancia_km_y_m() {
assert_eq!(fmt_distance(5.0), "5 km");
assert_eq!(fmt_distance(2.5), "2.5 km");
assert_eq!(fmt_distance(0.5), "500 m");
}
#[test]
fn fmt_coordenadas_con_hemisferio() {
assert_eq!(fmt_lat(-16.5), "16.50°S");
assert_eq!(fmt_lat(40.0), "40.00°N");
assert_eq!(fmt_lon(-70.65), "70.65°O");
assert_eq!(fmt_lon(2.35), "2.35°E");
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-markdown-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-markdown-viewer-llimphi — visor de Markdown renderizado sobre Llimphi. Parsea el documento con pulldown-cmark y pinta bloques con estilo (encabezados de tamaño creciente, código en monoespaciada, listas con viñeta, citas en itálica) en vez del texto crudo del text viewer. Noveno visor del shell nahual."
[dependencies]
nahual-viewer-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
@@ -0,0 +1,303 @@
//! `nahual-markdown-viewer-llimphi` — visor de Markdown renderizado.
//!
//! Noveno visor del shell meta-app. `shuma-discern` marca los `.md` con
//! lens `markdown`, pero hasta ahora caían al text viewer — que muestra
//! la sintaxis cruda (`# título`, `**negrita**`, ```` ``` ````). Este
//! visor parsea el documento con `pulldown-cmark` a una lista de bloques
//! con estilo y los pinta: encabezados con tamaño creciente según nivel,
//! bloques de código en monoespaciada sobre panel, listas con viñeta
//! indentada, citas en itálica. Se *lee* en vez de leerse el código.
//!
//! Patrón fino de los otros viewers: carga sync en [`load_markdown`],
//! render en [`markdown_viewer_view`]. No conoce el AppBus: el caller
//! pasa el path.
//!
//! MVP feo-primero: el formato inline (negrita/itálica/enlaces) se aplana
//! a texto — sólo la **estructura de bloques** se respeta visualmente. El
//! código inline se conserva con backticks. Sin scroll (clip, como los
//! demás visores estáticos); capamos por bloques y bytes para que parley
//! no se atragante.
#![forbid(unsafe_code)]
use std::path::Path;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
// El dominio (parseo + tipos) vive en `nahual-viewer-core`; lo
// re-exportamos para no romper a los consumidores.
pub use nahual_viewer_core::markdown::*;
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct MarkdownViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_heading: Color,
pub fg_muted: Color,
pub fg_error: Color,
pub code_bg: Color,
pub code_fg: Color,
}
impl Default for MarkdownViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl MarkdownViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_text: t.fg_text,
fg_heading: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
code_bg: t.bg_panel,
code_fg: t.fg_text,
}
}
}
/// Tamaño de fuente por nivel de encabezado.
fn heading_size(level: u8) -> f32 {
match level {
1 => 24.0,
2 => 20.0,
3 => 17.0,
4 => 15.0,
_ => 13.5,
}
}
/// Pinta header (nombre del archivo) + body con los bloques apilados.
pub fn markdown_viewer_view<Msg>(
state: &MarkdownPreview,
path: Option<&Path>,
palette: &MarkdownViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let header_text = match path {
Some(p) => format!(
"markdown · {}",
p.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
None => "(seleccioná un .md)".to_string(),
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: pad(12.0, 0.0),
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let body = match state {
MarkdownPreview::Empty => simple_body("", palette.fg_muted, palette),
MarkdownPreview::TooBig(n) => simple_body(
&format!("(documento muy grande: {n} bytes — sin preview)"),
palette.fg_muted,
palette,
),
MarkdownPreview::Error(e) => simple_body(
&format!("(no se pudo leer: {e})"),
palette.fg_error,
palette,
),
MarkdownPreview::Doc { blocks, truncated } => {
let mut children: Vec<View<Msg>> = blocks
.iter()
.map(|b| block_view::<Msg>(b, palette))
.collect();
if *truncated {
children.push(View::new(block_style(6.0, 2.0)).text_aligned(
"… (documento truncado)".to_string(),
11.0,
palette.fg_muted,
Alignment::Start,
));
}
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: pad(14.0, 8.0),
..Default::default()
})
.children(children)
}
};
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
/// Renderiza un bloque a su View con el estilo correspondiente.
fn block_view<Msg>(block: &MdBlock, palette: &MarkdownViewerPalette) -> View<Msg>
where
Msg: Clone + 'static,
{
match block {
MdBlock::Heading { level, text } => View::new(block_style(8.0, 3.0)).text_aligned(
text.clone(),
heading_size(*level),
palette.fg_heading,
Alignment::Start,
),
MdBlock::Paragraph(text) => View::new(block_style(4.0, 3.0)).text_aligned(
text.clone(),
13.0,
palette.fg_text,
Alignment::Start,
),
MdBlock::ListItem { depth, text } => {
let indent = " ".repeat(*depth as usize);
View::new(block_style(2.0, 1.0)).text_aligned(
format!("{indent}{text}"),
13.0,
palette.fg_text,
Alignment::Start,
)
}
MdBlock::Quote(text) => View::new(Style {
size: Size {
width: percent(1.0_f32),
height: auto(),
},
padding: Rect {
left: length(12.0_f32),
right: length(0.0_f32),
top: length(3.0_f32),
bottom: length(3.0_f32),
},
..Default::default()
})
.text_aligned_italic(
format!("{text}"),
13.0,
palette.fg_muted,
Alignment::Start,
true,
),
MdBlock::Code(code) => View::new(Style {
size: Size {
width: percent(1.0_f32),
height: auto(),
},
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(6.0_f32),
bottom: length(6.0_f32),
},
margin: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(palette.code_bg)
.text_aligned_full(
code.clone(),
12.0,
palette.code_fg,
Alignment::Start,
false,
Some("monospace".to_string()),
),
MdBlock::Rule => View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(1.0_f32),
},
margin: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(palette.fg_muted),
}
}
/// Body de una sola línea (estados Empty/TooBig/Error).
fn simple_body<Msg>(text: &str, color: Color, _palette: &MarkdownViewerPalette) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: pad(14.0, 8.0),
..Default::default()
})
.text_aligned(text.to_string(), 12.0, color, Alignment::Start)
}
/// Estilo de bloque: ancho completo, padding vertical configurable.
fn block_style(top: f32, bottom: f32) -> Style {
Style {
size: Size {
width: percent(1.0_f32),
height: auto(),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(top),
bottom: length(bottom),
},
..Default::default()
}
}
/// Padding horizontal `h` + vertical `v`.
fn pad(h: f32, v: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::LengthPercentage> {
Rect {
left: length(h),
right: length(h),
top: length(v),
bottom: length(v),
}
}
@@ -0,0 +1,43 @@
[package]
name = "nahual-shell-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-shell-llimphi — MVP del shell nahual sobre Llimphi: file explorer + text viewer en split fijo, sin layout.json/persister todavía (vienen en bloques siguientes)."
[[bin]]
name = "nahual-shell-llimphi"
path = "src/main.rs"
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-list = { workspace = true }
llimphi-widget-splitter = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-motion = { workspace = true }
app-bus = { workspace = true }
nahual-file-explorer-llimphi = { workspace = true }
nahual-source-core = { path = "../nahual-source-core", features = ["nouser", "minga"] }
nahual-image-viewer-llimphi = { workspace = true }
nahual-video-viewer-llimphi = { workspace = true }
nahual-audio-viewer-llimphi = { workspace = true }
nahual-card-viewer-llimphi = { workspace = true }
nahual-tree-viewer-llimphi = { workspace = true }
nahual-hex-viewer-llimphi = { workspace = true }
nahual-table-viewer-llimphi = { workspace = true }
nahual-markdown-viewer-llimphi = { workspace = true }
nahual-archive-viewer-llimphi = { workspace = true }
nahual-font-viewer-llimphi = { workspace = true }
nahual-map-viewer-llimphi = { workspace = true }
nahual-text-viewer-llimphi = { workspace = true }
shuma-discern = { workspace = true }
card-core = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
wawa-config = { workspace = true }
wawa-config-llimphi = { workspace = true }
@@ -0,0 +1,15 @@
# nahual-shell-llimphi
> Shell de archivos de [nahual](../README.md). Navegación + acciones básicas.
Vista de carpeta actual con files + folders, breadcrumb, actions (open, rename, delete, copy, paste).
## Uso
```sh
cargo run --release -p nahual-shell-llimphi
```
## Deps
- [`llimphi-ui`](../../llimphi/) + widget `list`, `tabs`
@@ -0,0 +1,15 @@
# nahual-shell-llimphi
> File shell of [nahual](../README.md). Navigation + basic actions.
Current folder view with files + folders, breadcrumb, actions (open, rename, delete, copy, paste).
## Usage
```sh
cargo run --release -p nahual-shell-llimphi
```
## Deps
- [`llimphi-ui`](../../llimphi/) + widget `list`, `tabs`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,713 @@
//! Registro de visores — el "open-with universal", dirigido por datos y
//! abierto al descubrimiento.
//!
//! Toma el [`Discernment`] que `shuma-discern` produce sobre una muestra
//! del archivo (detección por **contenido**, no por extensión) y lo
//! despacha al [`ViewerKind`] que sabe pintar esa naturaleza de dato.
//!
//! ## La tabla es una Card, no un `match`
//!
//! Cada visor se describe como un [`ViewerCard`]: qué `lens` acepta, qué
//! `mime` (prefijo o exacto) cubre, y con qué [`Priority`] compite. La
//! decisión NO vive en ramas de control sino en [`registry`], una tabla de
//! datos. El `lens` es el mismo `presentation_hint` que `card_core::DataFacet`
//! comparte con chasqui (`dominant_lens`) y shuma-discern, y `Priority` es el
//! mismo tiebreaker que usa `chasqui-broker` para rankear productores. Esto
//! es la "Capa 2" de Brahman a nivel de UI (ver `/BRAHMAN.md`, Fase 2a).
//!
//! ## La costura hacia el AppBus — ya no es estática
//!
//! [`registry`] ya **no** devuelve una tabla cerrada. Se ensambla en runtime
//! (una sola vez, cacheada) como `built-ins + Cards descubiertas`:
//!
//! - **Built-ins** ([`builtin_registry`]): el piso — los visores que el shell
//! linkea en proceso. Cada uno con su `(lens, mime, priority)`.
//! - **Descubiertas** ([`discover_viewer_cards`]): `card_core::Card`s leídas
//! de `$NAHUAL_VIEWERS_DIR` (por defecto `~/.config/nahual/viewers.d`), el
//! mismo formato JSON/TOML que el broker anuncia y que `card-discovery`
//! escanea. Una Card que extiende el ruteo de un visor **ya montado**
//! (p. ej. "ruteá `image/heic` al visor de imágenes con prioridad alta")
//! funciona end-to-end: no necesita IPC porque reusa el constructor
//! in-process. Las Cards cuyo `viewer_kind` el shell no sabe montar se
//! ignoran (serían visores fuera de proceso, pendientes del render-IPC).
//!
//! Cuando exista el AppBus vivo, [`discover_viewer_cards`] cambia su origen
//! de "directorio en disco" a "broker / `card-discovery`" sin tocar el
//! algoritmo de ranking: el contrato (una `Card` con `lens`/`mime`/`priority`)
//! ya es el mismo.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use card_core::Priority;
use serde_json::Value as JsonValue;
use shuma_discern::Discernment;
/// Qué visor del shell pinta el panel derecho.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewerKind {
/// Visor de imágenes (`nahual-image-viewer-llimphi`).
Image,
/// Reproductor de video (`nahual-video-viewer-llimphi`); abre
/// WebM/MKV (AV1) e IVF con el decoder nativo puro-Rust.
Video,
/// Reproductor de audio (`nahual-audio-viewer-llimphi`); WAV/MP3/
/// FLAC/Opus/Vorbis por cpal, con espectro en vivo.
Audio,
/// Visor estructurado de Cards (`nahual-card-viewer-llimphi`); pinta
/// los campos de una `shared/card` en vez del JSON crudo.
Card,
/// Visor de árbol JSON/TOML (`nahual-tree-viewer-llimphi`); indenta
/// la estructura, legible aun para JSON minificado.
Tree,
/// Volcado hex/ASCII (`nahual-hex-viewer-llimphi`) para binarios que
/// shuma reconoce pero no tienen visor propio (ELF/wasm/gzip/zip).
Hex,
/// Tabla CSV/TSV (`nahual-table-viewer-llimphi`); columnas alineadas.
Table,
/// Markdown renderizado (`nahual-markdown-viewer-llimphi`); encabezados,
/// listas, código y citas con estilo en vez de la sintaxis cruda.
Markdown,
/// Mapa GeoJSON (`nahual-map-viewer-llimphi`); proyecta y dibuja
/// puntos/líneas/polígonos en vez del árbol crudo de coordenadas.
Map,
/// Listado de un archivo comprimido (`nahual-archive-viewer-llimphi`);
/// muestra las entradas (nombre/tamaño/ratio) en vez del volcado hex.
/// Cubre ZIP (y .jar/.apk/.epub/OOXML), tar y tar.gz.
Archive,
/// Visor de fuentes (`nahual-font-viewer-llimphi`); metadatos + una
/// muestra dibujada con los contornos de la propia fuente (TTF/OTF).
Font,
/// Visor de texto (`nahual-text-viewer-llimphi`); degrada a "binario"
/// si el contenido no es UTF-8. Es el fallback universal.
Text,
/// Página web (HTML): el "open-with" la entrega a **puriy**, el
/// navegador de la suite. El panel muestra el fuente; abrir el archivo
/// (Enter) lanza puriy sobre `file://<path>`. Es la costura nahual↔puriy
/// — el escritorio sabe que el HTML es asunto del navegador.
Web,
}
impl ViewerKind {
/// Etiqueta estable del visor — el `nahual.viewer_kind` que una Card
/// descubierta declara para mapearse a un constructor in-process. NO
/// cambiar sin migrar las Cards en disco.
pub fn as_tag(self) -> &'static str {
match self {
ViewerKind::Image => "image",
ViewerKind::Video => "video",
ViewerKind::Audio => "audio",
ViewerKind::Card => "card",
ViewerKind::Tree => "tree",
ViewerKind::Hex => "hex",
ViewerKind::Table => "table",
ViewerKind::Markdown => "markdown",
ViewerKind::Map => "map",
ViewerKind::Archive => "archive",
ViewerKind::Font => "font",
ViewerKind::Text => "text",
ViewerKind::Web => "web",
}
}
/// Inverso de [`as_tag`](Self::as_tag). `None` si la etiqueta no
/// corresponde a ningún visor que el shell sepa montar.
pub fn from_tag(tag: &str) -> Option<Self> {
Some(match tag {
"image" => ViewerKind::Image,
"video" => ViewerKind::Video,
"audio" => ViewerKind::Audio,
"card" => ViewerKind::Card,
"tree" => ViewerKind::Tree,
"hex" => ViewerKind::Hex,
"table" => ViewerKind::Table,
"markdown" => ViewerKind::Markdown,
"map" => ViewerKind::Map,
"archive" => ViewerKind::Archive,
"font" => ViewerKind::Font,
"text" => ViewerKind::Text,
"web" => ViewerKind::Web,
_ => return None,
})
}
}
/// Descripción declarativa de un visor: qué naturaleza de dato cubre y con
/// qué prioridad compite. Es el equivalente UI de una `card_core::Card` de
/// tipo `Data` cuyo `presentation_hint` (lens) y `mime` declaran qué sabe
/// pintar. Los built-ins nacen de [`builtin_registry`]; los descubiertos, de
/// una `Card` real vía [`viewer_card_from_card`].
#[derive(Debug, Clone)]
pub struct ViewerCard {
/// Visor concreto que se monta si esta fila gana.
pub kind: ViewerKind,
/// Lenses (`presentation_hint`) que el visor reclama. Match exacto.
pub lenses: Vec<String>,
/// Prefijos de `mime` que cubre (p. ej. `"image/"`).
pub mime_prefixes: Vec<String>,
/// `mime` exactos que cubre (p. ej. `"application/zip"`).
pub mime_exact: Vec<String>,
/// Prioridad de desempate cuando más de un visor matchea con la misma
/// especificidad. Mismo orden que usa el broker (`Low<Normal<High<Critical`).
pub priority: Priority,
}
impl ViewerCard {
/// Constructor desde slices estáticos — azúcar para [`builtin_registry`].
fn builtin(
kind: ViewerKind,
lenses: &[&str],
mime_prefixes: &[&str],
mime_exact: &[&str],
priority: Priority,
) -> Self {
let own = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect();
ViewerCard {
kind,
lenses: own(lenses),
mime_prefixes: own(mime_prefixes),
mime_exact: own(mime_exact),
priority,
}
}
}
/// Los visores que el shell linkea en proceso: el piso del registro.
///
/// El orden importa sólo como desempate final (especificidad y `Priority`
/// mandan primero). `Text` NO está acá: es el fallback explícito de [`pick`]
/// cuando ninguna fila matchea.
pub fn builtin_registry() -> Vec<ViewerCard> {
use Priority::Normal;
vec![
ViewerCard::builtin(ViewerKind::Image, &["gallery"], &["image/"], &[], Normal),
ViewerCard::builtin(ViewerKind::Video, &["video"], &["video/"], &[], Normal),
ViewerCard::builtin(ViewerKind::Audio, &["audio"], &["audio/"], &[], Normal),
ViewerCard::builtin(ViewerKind::Card, &["card"], &[], &[], Normal),
ViewerCard::builtin(ViewerKind::Tree, &["tree"], &[], &[], Normal),
ViewerCard::builtin(ViewerKind::Table, &["table"], &[], &[], Normal),
ViewerCard::builtin(ViewerKind::Markdown, &["markdown"], &[], &[], Normal),
// GeoJSON → mapa. Lo rutea el lens `map` de shuma-discern y, por si
// acaso, los mime canónicos de GeoJSON.
ViewerCard::builtin(
ViewerKind::Map,
&["map", "geo"],
&[],
&[
"application/geo+json",
"application/vnd.geo+json",
"application/gpx+xml",
"application/vnd.google-earth.kml+xml",
"application/vnd.pmtiles",
],
Normal,
),
ViewerCard::builtin(ViewerKind::Font, &["font"], &[], &[], Normal),
// HTML → puriy (el navegador de la suite). Cubre el lens `web`/`html`
// que pueda emitir shuma-discern y los mime canónicos del HTML/XHTML.
ViewerCard::builtin(
ViewerKind::Web,
&["web", "html"],
&[],
&["text/html", "application/xhtml+xml"],
Normal,
),
// Contenedores: un comprimido se lista (entradas) en vez de volcarse.
// ZIP cubre .jar/.apk/.epub/OOXML; gzip se asume envolviendo un tar.
ViewerCard::builtin(
ViewerKind::Archive,
&[],
&[],
&["application/zip", "application/x-tar", "application/gzip"],
Normal,
),
// Binarios que shuma detecta por magic-bytes sin lens y que ningún
// visor rico cubre: un dump hex es mejor que "(binario — sin preview)".
ViewerCard::builtin(
ViewerKind::Hex,
&[],
&[],
&["application/x-executable", "application/wasm"],
Normal,
),
]
}
/// Tabla de ruteo efectiva: built-ins + Cards descubiertas, ensamblada una
/// sola vez y cacheada. Las descubiertas se concatenan después de las
/// built-ins, así que en empate de `(especificidad, Priority)` ganan ellas
/// (último máximo) — lo que permite a una Card en disco **sobrescribir** o
/// **extender** el ruteo de un visor montado.
pub fn registry() -> &'static [ViewerCard] {
static REGISTRY: OnceLock<Vec<ViewerCard>> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut rows = builtin_registry();
rows.extend(discover_viewer_cards());
rows
})
}
/// Directorio del que se leen las Cards de visores. `$NAHUAL_VIEWERS_DIR`
/// manda; si no, `$XDG_CONFIG_HOME/nahual/viewers.d` o `~/.config/nahual/viewers.d`.
fn viewers_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("NAHUAL_VIEWERS_DIR") {
return Some(PathBuf::from(dir));
}
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("nahual").join("viewers.d"))
}
/// Lee las `Card`s de visores del directorio de descubrimiento y las mapea a
/// [`ViewerCard`]. Tolerante: un directorio inexistente o una Card inválida
/// se ignoran en silencio (el shell debe arrancar igual sin ninguna).
pub fn discover_viewer_cards() -> Vec<ViewerCard> {
match viewers_dir() {
Some(dir) => discover_in(&dir),
None => Vec::new(),
}
}
/// Núcleo de [`discover_viewer_cards`] sobre un directorio concreto —
/// separado para testearlo sin depender de variables de entorno globales.
fn discover_in(dir: &std::path::Path) -> Vec<ViewerCard> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
match path.extension().and_then(|e| e.to_str()) {
Some("json") | Some("toml") => {}
_ => continue,
}
// `Card::from_path` auto-detecta formato y valida; descartamos
// silenciosamente lo que no parsee.
let Ok(card) = card_core::Card::from_path(&path) else {
continue;
};
if let Some(vc) = viewer_card_from_card(&card) {
out.push(vc);
}
}
out
}
/// Mapea una `card_core::Card` a un [`ViewerCard`] del shell.
///
/// La Card declara su visor en `extensions["nahual.viewer_kind"]` (la
/// etiqueta estable de [`ViewerKind::as_tag`]). Si falta o el shell no sabe
/// montar ese visor, devuelve `None` (sería un visor fuera de proceso, aún
/// sin render-IPC). Los `lens` salen de `data.presentation_hint` más
/// `extensions["nahual.lenses"]`; los `mime`, de `extensions["nahual.mime_prefixes"]`
/// y `["nahual.mime_exact"]`. La `priority` es la de la propia Card.
pub fn viewer_card_from_card(card: &card_core::Card) -> Option<ViewerCard> {
let tag = card.extensions.get("nahual.viewer_kind")?.as_str()?;
let kind = ViewerKind::from_tag(tag)?;
let mut lenses = json_str_array(&card.extensions, "nahual.lenses");
if let Some(data) = &card.data {
if !data.presentation_hint.is_empty() && !lenses.contains(&data.presentation_hint) {
lenses.push(data.presentation_hint.clone());
}
}
Some(ViewerCard {
kind,
lenses,
mime_prefixes: json_str_array(&card.extensions, "nahual.mime_prefixes"),
mime_exact: json_str_array(&card.extensions, "nahual.mime_exact"),
priority: card.priority,
})
}
/// Lee un array JSON de strings de `extensions[key]`; `[]` si falta o no es
/// un array de strings.
fn json_str_array(extensions: &BTreeMap<String, JsonValue>, key: &str) -> Vec<String> {
extensions
.get(key)
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect())
.unwrap_or_default()
}
/// Especificidad de un match: a mayor número, más concreta la coincidencia.
/// El orden replica el de la versión hardcoded previa: `lens` manda sobre
/// `mime` exacto, y éste sobre el prefijo de `mime`.
const SCORE_LENS: u8 = 3;
const SCORE_MIME_EXACT: u8 = 2;
const SCORE_MIME_PREFIX: u8 = 1;
const SCORE_NONE: u8 = 0;
fn score(card: &ViewerCard, d: &Discernment) -> u8 {
if let Some(lens) = d.lens.as_deref() {
if card.lenses.iter().any(|l| l == lens) {
return SCORE_LENS;
}
}
if let Some(mime) = d.mime.as_deref() {
if card.mime_exact.iter().any(|m| m == mime) {
return SCORE_MIME_EXACT;
}
if card.mime_prefixes.iter().any(|p| mime.starts_with(p.as_str())) {
return SCORE_MIME_PREFIX;
}
}
SCORE_NONE
}
/// Núcleo de [`pick`] sobre una tabla arbitraria — separado para testearlo
/// sin tocar el [`registry`] global (que toca el filesystem).
fn pick_in(rows: &[ViewerCard], discernment: Option<&Discernment>) -> ViewerKind {
let Some(d) = discernment else {
return ViewerKind::Text;
};
if d.mime.as_deref() == Some("image/gif") {
return ViewerKind::Video;
}
rows.iter()
.map(|card| (score(card, d), card))
.filter(|(s, _)| *s > SCORE_NONE)
// mayor especificidad, luego mayor Priority; el orden de la tabla
// queda como desempate estable (las descubiertas, al final, ganan).
.max_by_key(|(s, card)| (*s, card.priority))
.map(|(_, card)| card.kind)
.unwrap_or(ViewerKind::Text)
}
/// Elige el visor para un discernimiento consultando [`registry`].
///
/// Reglas, en orden:
/// 1. Caso especial GIF: shuma lo marca `gallery` (es imagen), pero un GIF
/// animado se ve mejor reproducido. Va al video viewer (que acepta su
/// `FrameSource` y lo anima en loop; un GIF de un frame se ve estático).
/// 2. Match por la tabla: gana la fila con mayor especificidad
/// (`lens` > `mime` exacto > prefijo de `mime`), desempatada por
/// `Priority` y, en última instancia, por orden en la tabla.
/// 3. Fallback a [`ViewerKind::Text`] — el visor que nunca falla feo.
///
/// Un `None` (no se pudo discernir, p.ej. archivo ilegible) cae a texto.
pub fn pick(discernment: Option<&Discernment>) -> ViewerKind {
pick_in(registry(), discernment)
}
/// Seam del **open-with out-of-process**: dado el discernimiento y un
/// `AppRegistry` (apps externas registradas en `shared/app-bus`), devuelve la
/// app externa que declara abrir el `mime` discernido, si la hay.
///
/// Es independiente de [`pick`] (que sólo resuelve visores in-process): el
/// shell la consulta cuando quiere ofrecer/usar un handler externo —p.ej. un
/// "Abrir con…" o una política donde una app del SO gana a un visor builtin—
/// y, de obtener `Some`, abre el archivo con
/// [`app_bus::AppRegistry::open_with`] en vez de montar un widget. `None`
/// significa "no hay app externa para esto": seguí con [`pick`] in-process.
pub fn external_handler_for<'a>(
registry: &'a app_bus::AppRegistry,
discernment: Option<&Discernment>,
) -> Option<&'a app_bus::AppEntry> {
let mime = discernment?.mime.as_deref()?;
registry.handlers_for(mime).into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
use card_core::{Card, CardKind, DataFacet, TypeRef};
fn disc(lens: Option<&str>, mime: Option<&str>) -> Discernment {
Discernment {
ty: TypeRef::Primitive { name: "x".into() },
confidence: 0.9,
mime: mime.map(String::from),
lens: lens.map(String::from),
}
}
// `pick` sobre la tabla built-in pura — independiente del filesystem.
fn pick_builtin(lens: Option<&str>, mime: Option<&str>) -> ViewerKind {
pick_in(&builtin_registry(), Some(&disc(lens, mime)))
}
#[test]
fn gallery_lens_va_a_imagen() {
assert_eq!(pick_builtin(Some("gallery"), Some("image/png")), ViewerKind::Image);
}
#[test]
fn mime_image_sin_lens_va_a_imagen() {
assert_eq!(pick_builtin(None, Some("image/webp")), ViewerKind::Image);
}
#[test]
fn gif_va_a_video_aunque_sea_gallery() {
// shuma marca el GIF como gallery; igual lo anima el video viewer.
assert_eq!(pick_builtin(Some("gallery"), Some("image/gif")), ViewerKind::Video);
}
#[test]
fn video_lens_va_a_video() {
assert_eq!(pick_builtin(Some("video"), Some("video/webm")), ViewerKind::Video);
}
#[test]
fn mime_video_sin_lens_va_a_video() {
assert_eq!(pick_builtin(None, Some("video/x-ivf")), ViewerKind::Video);
}
#[test]
fn audio_lens_y_mime_van_a_audio() {
assert_eq!(pick_builtin(Some("audio"), Some("audio/wav")), ViewerKind::Audio);
assert_eq!(pick_builtin(None, Some("audio/mpeg")), ViewerKind::Audio);
}
#[test]
fn card_lens_va_a_card() {
assert_eq!(pick_builtin(Some("card"), Some("application/json")), ViewerKind::Card);
}
#[test]
fn tree_lens_va_a_tree() {
assert_eq!(pick_builtin(Some("tree"), Some("application/json")), ViewerKind::Tree);
assert_eq!(pick_builtin(Some("tree"), Some("application/toml")), ViewerKind::Tree);
}
#[test]
fn table_lens_va_a_table() {
assert_eq!(pick_builtin(Some("table"), Some("text/csv")), ViewerKind::Table);
}
#[test]
fn binarios_van_a_hex() {
assert_eq!(pick_builtin(None, Some("application/x-executable")), ViewerKind::Hex);
assert_eq!(pick_builtin(None, Some("application/wasm")), ViewerKind::Hex);
}
#[test]
fn comprimidos_van_a_archive() {
assert_eq!(pick_builtin(None, Some("application/zip")), ViewerKind::Archive);
assert_eq!(pick_builtin(None, Some("application/x-tar")), ViewerKind::Archive);
assert_eq!(pick_builtin(None, Some("application/gzip")), ViewerKind::Archive);
}
#[test]
fn markdown_va_a_markdown() {
assert_eq!(pick_builtin(Some("markdown"), Some("text/plain")), ViewerKind::Markdown);
}
#[test]
fn map_lens_y_mime_van_a_mapa() {
assert_eq!(pick_builtin(Some("map"), Some("application/json")), ViewerKind::Map);
assert_eq!(pick_builtin(None, Some("application/geo+json")), ViewerKind::Map);
}
#[test]
fn font_lens_va_a_font() {
assert_eq!(pick_builtin(Some("font"), Some("font/sfnt")), ViewerKind::Font);
}
#[test]
fn code_va_a_texto() {
assert_eq!(pick_builtin(Some("code"), Some("text/plain")), ViewerKind::Text);
}
#[test]
fn html_va_a_web() {
// Por mime (sin lens) y por lens — ambos rutean a puriy.
assert_eq!(pick_builtin(None, Some("text/html")), ViewerKind::Web);
assert_eq!(pick_builtin(None, Some("application/xhtml+xml")), ViewerKind::Web);
assert_eq!(pick_builtin(Some("web"), Some("text/plain")), ViewerKind::Web);
assert_eq!(pick_builtin(Some("html"), None), ViewerKind::Web);
}
#[test]
fn sin_discernimiento_cae_a_texto() {
assert_eq!(pick_in(&builtin_registry(), None), ViewerKind::Text);
}
#[test]
fn lens_gana_sobre_mime_prefijo() {
// lens explícito (especificidad 3) debe ganar aunque el mime también
// matchee un prefijo de otro visor distinto.
assert_eq!(pick_builtin(Some("tree"), Some("image/png")), ViewerKind::Tree);
}
#[test]
fn tag_round_trip() {
for card in builtin_registry() {
assert_eq!(ViewerKind::from_tag(card.kind.as_tag()), Some(card.kind));
}
assert_eq!(ViewerKind::from_tag("inexistente"), None);
}
#[test]
fn cada_kind_built_in_es_alcanzable_por_su_lens() {
// garantía de cobertura: ninguna fila built-in queda muerta.
let rows = builtin_registry();
for card in &rows {
if let Some(lens) = card.lenses.first() {
assert_eq!(pick_in(&rows, Some(&disc(Some(lens), None))), card.kind);
} else if let Some(mime) = card.mime_exact.first() {
assert_eq!(pick_in(&rows, Some(&disc(None, Some(mime)))), card.kind);
}
}
}
// --- Descubrimiento: Card en disco → ViewerCard ---
fn viewer_card(tag: &str, hint: &str, priority: Priority, exts: JsonValue) -> Card {
let extensions = exts
.as_object()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<BTreeMap<_, _>>();
let mut ext = extensions;
ext.insert("nahual.viewer_kind".into(), JsonValue::String(tag.into()));
Card {
kind: CardKind::Data,
data: Some(DataFacet {
presentation_hint: hint.into(),
..Default::default()
}),
priority,
extensions: ext,
..Card::new("test.viewer")
}
}
#[test]
fn card_sin_tag_no_mapea() {
let card = Card {
kind: CardKind::Data,
..Card::new("test.sin-tag")
};
assert!(viewer_card_from_card(&card).is_none());
}
#[test]
fn card_con_tag_desconocido_no_mapea() {
let card = viewer_card("visor-marciano", "", Priority::Normal, serde_json::json!({}));
assert!(viewer_card_from_card(&card).is_none());
}
#[test]
fn card_mapea_lens_mime_y_priority() {
let card = viewer_card(
"image",
"gallery",
Priority::High,
serde_json::json!({
"nahual.mime_exact": ["image/heic"],
"nahual.mime_prefixes": ["image/x-"],
"nahual.lenses": ["fotos"]
}),
);
let vc = viewer_card_from_card(&card).expect("debe mapear");
assert_eq!(vc.kind, ViewerKind::Image);
assert_eq!(vc.priority, Priority::High);
assert!(vc.lenses.contains(&"fotos".to_string()));
assert!(vc.lenses.contains(&"gallery".to_string())); // del presentation_hint
assert!(vc.mime_exact.contains(&"image/heic".to_string()));
assert!(vc.mime_prefixes.contains(&"image/x-".to_string()));
}
#[test]
fn card_descubierta_extiende_ruteo_de_visor_montado() {
// Una Card que enseña a nahual a abrir PSD con el visor de imágenes:
// funciona end-to-end porque reusa el constructor in-process. (`image/`
// no aplica acá: el mime de Photoshop es `application/...`, que ningún
// built-in cubre, así que sin la Card cae a texto.)
let card = viewer_card(
"image",
"",
Priority::Normal,
serde_json::json!({ "nahual.mime_exact": ["application/vnd.adobe.photoshop"] }),
);
let vc = viewer_card_from_card(&card).unwrap();
let mut rows = builtin_registry();
let psd = disc(None, Some("application/vnd.adobe.photoshop"));
// Sin la Card, PSD no matchea nada rico → cae a texto.
assert_eq!(pick_in(&rows, Some(&psd)), ViewerKind::Text);
rows.push(vc);
// Con la Card, va al visor de imágenes.
assert_eq!(pick_in(&rows, Some(&psd)), ViewerKind::Image);
}
#[test]
#[ignore = "helper: emite el JSON de ejemplo, no es una aserción"]
fn emite_ejemplo_json() {
let card = viewer_card(
"image",
"gallery",
Priority::High,
serde_json::json!({
"nahual.mime_exact": ["application/vnd.adobe.photoshop"],
"nahual.mime_prefixes": []
}),
);
println!("{}", card.to_json_pretty().unwrap());
}
#[test]
fn directorio_de_ejemplo_carga_y_rutea() {
// El JSON de `viewers.d.example/` debe parsear, validar y producir un
// ViewerCard que rutea PSD al visor de imágenes.
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("viewers.d.example");
let discovered = discover_in(&dir);
assert!(
discovered.iter().any(|vc| vc.kind == ViewerKind::Image
&& vc.mime_exact.iter().any(|m| m == "application/vnd.adobe.photoshop")),
"el ejemplo debe descubrirse como visor de imágenes para PSD"
);
let mut rows = builtin_registry();
rows.extend(discovered);
let psd = disc(None, Some("application/vnd.adobe.photoshop"));
assert_eq!(pick_in(&rows, Some(&psd)), ViewerKind::Image);
}
#[test]
fn card_descubierta_gana_empate_por_orden() {
// Una Card que sobrescribe el lens "gallery" para mandarlo a Hex
// (caso artificial): al ir al final, gana el empate de especificidad.
let card = viewer_card(
"hex",
"gallery",
Priority::Normal,
serde_json::json!({}),
);
let vc = viewer_card_from_card(&card).unwrap();
let mut rows = builtin_registry();
assert_eq!(pick_in(&rows, Some(&disc(Some("gallery"), None))), ViewerKind::Image);
rows.push(vc);
assert_eq!(pick_in(&rows, Some(&disc(Some("gallery"), None))), ViewerKind::Hex);
}
#[test]
fn external_handler_for_matchea_por_mime() {
use app_bus::{AppEntry, AppRegistry, Launch};
let reg = AppRegistry::new(vec![AppEntry {
id: "puriy".into(),
label: "Puriy".into(),
icon: None,
category: None,
launch: Launch::Exec {
program: "puriy".into(),
args: vec![],
},
handles: vec!["text/html".into()],
}]);
// Hay app externa para text/html → la devuelve.
let d = disc(None, Some("text/html"));
assert_eq!(
external_handler_for(&reg, Some(&d)).map(|e| e.id.as_str()),
Some("puriy")
);
// No hay para image/png → None (el caller cae a pick() in-process).
assert!(external_handler_for(&reg, Some(&disc(None, Some("image/png")))).is_none());
// Sin mime / sin discernment → None.
assert!(external_handler_for(&reg, Some(&disc(None, None))).is_none());
assert!(external_handler_for(&reg, None).is_none());
}
}
@@ -0,0 +1,53 @@
# `viewers.d/` — visores como Cards (Brahman, Fase 2a Paso 2)
El shell ensambla su tabla de ruteo de visores como **built-ins + Cards
descubiertas**. Las built-ins son los visores que el binario linkea en
proceso; las descubiertas son `card_core::Card`s (JSON o TOML) que el shell lee
de un directorio al arrancar.
## Dónde se leen
```
$NAHUAL_VIEWERS_DIR (si está seteada)
$XDG_CONFIG_HOME/nahual/viewers.d (si no)
~/.config/nahual/viewers.d (fallback)
```
Probar el ejemplo de esta carpeta:
```bash
NAHUAL_VIEWERS_DIR=02_ruway/nahual/nahual-shell-llimphi/viewers.d.example \
cargo run -p nahual-shell-llimphi --release
```
## Qué hace una Card de visor
Es una `Card` de `kind: "data"` con tres extensiones propias del shell
(serializadas al top-level del JSON):
| clave | tipo | significado |
|--------------------------|-------------|-----------------------------------------------|
| `nahual.viewer_kind` | string | a qué visor montado rutea (`image`, `video`, `audio`, `card`, `tree`, `hex`, `table`, `markdown`, `archive`, `font`, `text`) |
| `nahual.mime_exact` | `[string]` | mimes exactos que cubre |
| `nahual.mime_prefixes` | `[string]` | prefijos de mime que cubre (p. ej. `"image/"`) |
Los `lens` salen de `data.presentation_hint` (+ un opcional
`nahual.lenses: [string]`). La `priority` de la Card es el desempate, con el
mismo orden que usa `chasqui-broker` (`low < normal < high < critical`).
## Qué funciona hoy y qué no
- **Extender el ruteo de un visor ya montado** (este ejemplo: enseñar a abrir
PSD con el visor de imágenes) funciona end-to-end: reusa el constructor
in-process, no necesita IPC.
- Una Card con un `nahual.viewer_kind` que el shell **no** sabe montar se
ignora en silencio — sería un visor fuera de proceso, pendiente del
render-IPC del AppBus.
## La costura hacia el broker
Hoy el origen de las Cards es un directorio en disco. Es deliberadamente el
**mismo formato** que `card-discovery` escanea y que el broker (`chasqui`)
anuncia: cuando el AppBus esté vivo, `discover_viewer_cards()` cambia su fuente
de "directorio" a "broker" sin tocar el algoritmo de ranking. El contrato —una
`Card` con `lens`/`mime`/`priority`— ya es el de Brahman.
@@ -0,0 +1,43 @@
{
"schema_version": 1,
"id": "01KSXXGQ8BMS5SPDC1VWKNAQE8",
"lineage": null,
"label": "nahual.viewer.photoshop",
"provides": [],
"requires": [],
"permissions": {
"networking": "none",
"filesystem": "none",
"ipc": { "allow": [] },
"processes": false
},
"soma": {
"namespaces": {
"mount": false, "pid": false, "net": false, "uts": false,
"ipc": false, "user": false, "cgroup": false
},
"rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
"cgroup": { "path": "", "cpu_weight": null, "io_weight": null },
"cpu_affinity": null
},
"payload": "Virtual",
"supervision": "OneShot",
"lifecycle": "daemon",
"priority": "high",
"flow": { "input": [], "output": [] },
"service_socket": null,
"references": [],
"kind": "data",
"data": {
"summary": "Rutea archivos PSD al visor de imágenes del shell.",
"keywords": ["photoshop", "psd", "imagen"],
"centroid": [],
"member_count": 0,
"dispersion": 0.0,
"presentation_hint": "gallery"
},
"genesis": [],
"nahual.viewer_kind": "image",
"nahual.mime_exact": ["application/vnd.adobe.photoshop"],
"nahual.mime_prefixes": []
}
@@ -0,0 +1,27 @@
[package]
name = "nahual-source-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-source-core — abstracción de FUENTE navegable para el front universal de nahual. Un trait `Source` agnóstico (raíz + hijos + leer hoja) con adapters: filesystem POSIX y objetos content-addressed de una imagen wawa (.img). La costura de Brahman Fase 3: cada explorador (POSIX/wawa/nouser/minga) es una Source detrás de la misma interfaz, y nahual-shell la front."
[dependencies]
wawa-explorer-core = { workspace = true }
chasqui-core = { workspace = true, optional = true }
minga-store = { workspace = true, optional = true }
minga-core = { workspace = true, optional = true }
[features]
default = []
# Mónadas semánticas de nouser. Opt-in porque arrastra chasqui-core (sled,
# walkdir) — quien sólo quiere POSIX/wawa no paga ese peso.
nouser = ["dep:chasqui-core"]
# Grafo CAS de AST de minga. Opt-in por la misma razón (minga-store, sled).
minga = ["dep:minga-store", "dep:minga-core"]
[dev-dependencies]
tempfile = { workspace = true }
format = { workspace = true }
@@ -0,0 +1,151 @@
//! `nahual-source-core` — la **fuente navegable** agnóstica del front
//! universal (Brahman, Fase 3).
//!
//! El norte de `nahual-shell` es abrir, con la misma UI, "una raíz de minga,
//! un objeto de wawa, una Mónada de nouser o un archivo POSIX" (ver
//! `/BRAHMAN.md`, sección "proliferación de exploradores"). Cada uno de esos
//! mundos tiene su propio modelo de árbol —`std::fs`, un DAG BLAKE3, clusters
//! semánticos— pero todos comparten la misma forma mínima: una **raíz**, una
//! manera de **listar hijos** de un contenedor, y una manera de **leer los
//! bytes** de una hoja. Eso es [`Source`].
//!
//! La proliferación de exploradores NO se cura fusionando sus datos
//! (incompatibles), sino poniéndolos detrás de esta interfaz común: el shell
//! deja de saber de `PathBuf` y pasa a navegar `dyn Source`. Hoy hay tres
//! adapters reales:
//!
//! - [`posix::PosixSource`] — el filesystem POSIX vivo (lo que el shell ya
//! hacía, ahora detrás del trait).
//! - [`wawa::WawaImgSource`] — los objetos content-addressed de una imagen
//! wawa `.img`, navegando el DAG por hash. Puro local, sin red ni daemon.
//! - `nouser::NouserSource` (feature `nouser`) — las Mónadas semánticas de
//! `chasqui-core`: clusters de archivos, un árbol que NO existe en disco.
//! - `minga::MingaSource` (feature `minga`) — el grafo CAS de AST de un repo
//! minga: un DAG de nodos de código etiquetados por su `kind`.
//!
//! Cada uno es una *forma de árbol* distinta (jerarquía física, DAG de
//! contenido, clusters semánticos, DAG de AST) y aun así caben en el mismo
//! trait — esa es la prueba de que la abstracción aguanta. Los cuatro mundos
//! que el BRAHMAN.md nombra (POSIX · wawa · nouser · minga) son ahora una
//! sola espina.
#![forbid(unsafe_code)]
pub mod navigator;
pub mod posix;
pub mod wawa;
#[cfg(feature = "nouser")]
pub mod nouser;
#[cfg(feature = "minga")]
pub mod minga;
pub use navigator::{Navigator, Opened};
pub use posix::PosixSource;
pub use wawa::WawaImgSource;
#[cfg(feature = "nouser")]
pub use nouser::NouserSource;
#[cfg(feature = "minga")]
pub use minga::MingaSource;
/// Identidad opaca de un nodo DENTRO de su fuente. El shell la trata como
/// caja negra (la guarda para volver a pedir hijos o leer), salvo para
/// derivar la identidad de contenido al despachar el visor.
///
/// La codificación es decisión de cada [`Source`]: POSIX usa la ruta
/// absoluta; wawa usa el hash en hex. No mezclar ids entre fuentes.
pub type NodeId = String;
/// Un nodo del árbol de una [`Source`]: lo mínimo que la UI necesita para
/// pintar una fila y decidir si se puede descender.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Node {
/// Identidad estable dentro de la fuente (ver [`NodeId`]).
pub id: NodeId,
/// Nombre legible para la fila.
pub name: String,
/// `true` si se puede descender (tiene hijos / es directorio); `false`
/// si es una hoja que se abre en el visor.
pub is_container: bool,
}
impl Node {
/// Atajo para construir un nodo.
pub fn new(id: impl Into<NodeId>, name: impl Into<String>, is_container: bool) -> Self {
Self {
id: id.into(),
name: name.into(),
is_container,
}
}
}
/// Una fuente navegable: el contrato agnóstico que el front universal
/// consume. Object-safe a propósito — el shell guarda `Box<dyn Source>` y
/// puede apilar fuentes (descender de un `.img` POSIX a su DAG wawa).
///
/// `Send + Sync` para poder escanear en un worker (`Handle::spawn`) sin
/// devolver el árbol entero por el canal de mensajes.
pub trait Source: Send + Sync {
/// Nombre humano de la fuente — para breadcrumb / título del panel.
fn label(&self) -> String;
/// El nodo raíz desde el que se empieza a navegar.
fn root(&self) -> Node;
/// Hijos directos de un contenedor, en orden ya presentable. Error si el
/// id no existe o no es un contenedor.
fn children(&self, id: &NodeId) -> std::io::Result<Vec<Node>>;
/// Bytes de una hoja — para discernir (`shuma-discern`) y visualizar.
/// Error si el id no existe o no tiene contenido leíble.
fn read(&self, id: &NodeId) -> std::io::Result<Vec<u8>>;
}
/// Codifica 32 bytes a hex en minúscula (64 chars). Compartido por el
/// adapter wawa y reusable por cualquier fuente content-addressed futura.
pub(crate) fn to_hex(bytes: &[u8; 32]) -> String {
let mut s = String::with_capacity(64);
for b in bytes {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
/// Decodifica 64 chars hex a 32 bytes. `None` si la longitud o los dígitos
/// son inválidos.
pub(crate) fn from_hex(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 {
return None;
}
let mut out = [0u8; 32];
let bytes = s.as_bytes();
for (i, slot) in out.iter_mut().enumerate() {
let hi = (bytes[2 * i] as char).to_digit(16)?;
let lo = (bytes[2 * i + 1] as char).to_digit(16)?;
*slot = (hi * 16 + lo) as u8;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_round_trip() {
let h = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
];
let hex = to_hex(&h);
assert_eq!(hex.len(), 64);
assert_eq!(from_hex(&hex), Some(h));
}
#[test]
fn from_hex_rechaza_basura() {
assert_eq!(from_hex("corto"), None);
assert_eq!(from_hex(&"z".repeat(64)), None);
}
}
@@ -0,0 +1,199 @@
//! Adapter [`Source`] sobre el grafo CAS de un repositorio minga (`.minga/`,
//! sled).
//!
//! La cuarta forma de árbol del front universal, distinta a las otras tres:
//! minga guarda código como un **DAG de AST direccionado por contenido**
//! (`StoredNode{kind, leaf_text, children: [hash]}`), donde dos subárboles
//! estructuralmente iguales se almacenan una sola vez. Acá lo navegamos: la
//! raíz lista todos los nodos del store; descender un nodo muestra sus hijos
//! del AST; una hoja (sin hijos) lee su `leaf_text` —el token de código— por
//! el visor. El nombre de fila es el `kind` del nodo (`function_item`,
//! `identifier`, …) + hash corto: navegación etiquetada semánticamente.
//!
//! Puro local (lee el sled del peer, no abre red). Detrás de la feature
//! `minga` para no arrastrar `minga-store`/sled a quien sólo quiere
//! POSIX/wawa.
use std::io;
use std::path::Path;
use minga_core::ContentHash;
use minga_store::PersistentRepo;
use crate::{from_hex, to_hex, Node, NodeId, Source};
/// Id de la raíz sintética que lista todos los nodos del store.
const RAIZ: &str = "@nodos";
/// Fuente que navega el grafo CAS de AST de un repositorio minga.
pub struct MingaSource {
repo: PersistentRepo,
etiqueta: String,
}
impl MingaSource {
/// Abre el repositorio sled en `ruta` (`.minga/` o equivalente). Sled
/// crea el directorio si no existe — un repo nuevo simplemente queda
/// vacío. Error si el path no es abrible como base sled.
pub fn abrir(ruta: impl AsRef<Path>) -> io::Result<Self> {
let ruta = ruta.as_ref();
let repo = PersistentRepo::open(ruta).map_err(io::Error::other)?;
let etiqueta = ruta
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| ruta.to_string_lossy().into_owned());
Ok(Self { repo, etiqueta })
}
fn parse_id(id: &NodeId) -> io::Result<ContentHash> {
from_hex(id)
.map(ContentHash)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("id minga inválido: {id}")))
}
/// `Node` de un hash: nombre = `kind` + hash corto; contenedor si el
/// nodo tiene hijos en el AST.
fn nodo_de(&self, h: &ContentHash) -> Node {
let hex = to_hex(h.as_bytes());
let corto: String = hex.chars().take(8).collect();
match self.repo.nodes.get(h) {
Ok(Some(stored)) => {
Node::new(hex, format!("{} · {corto}", stored.kind), !stored.children.is_empty())
}
// Referencia colgante (hijo no presente aún) o error de lectura:
// lo mostramos como hoja anónima en vez de romper la navegación.
_ => Node::new(hex, format!("? · {corto}"), false),
}
}
}
impl Source for MingaSource {
fn label(&self) -> String {
self.etiqueta.clone()
}
fn root(&self) -> Node {
Node::new(RAIZ, self.etiqueta.clone(), true)
}
fn children(&self, id: &NodeId) -> io::Result<Vec<Node>> {
if id == RAIZ {
let mut hashes: Vec<ContentHash> = self
.repo
.nodes
.iter_hashes()
.filter_map(Result::ok)
.collect();
hashes.sort_unstable_by(|a, b| a.0.cmp(&b.0));
return Ok(hashes.iter().map(|h| self.nodo_de(h)).collect());
}
let h = Self::parse_id(id)?;
let hijos = self
.repo
.nodes
.children_of(&h)
.map_err(io::Error::other)?
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("nodo minga inexistente: {id}")))?;
Ok(hijos.iter().map(|c| self.nodo_de(c)).collect())
}
fn read(&self, id: &NodeId) -> io::Result<Vec<u8>> {
if id == RAIZ {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"la raíz @nodos no tiene contenido leíble",
));
}
let h = Self::parse_id(id)?;
let stored = self
.repo
.nodes
.get(&h)
.map_err(io::Error::other)?
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("nodo minga inexistente: {id}")))?;
// Una hoja lleva su token en `leaf_text`; un nodo interno sin texto
// (no debería abrirse como hoja, pero por las dudas) cae a su kind.
Ok(stored.leaf_text.unwrap_or_else(|| stored.kind.into_bytes()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::to_hex;
use minga_core::ast::SemanticNode;
use minga_store::PersistentRepo;
fn nodo(kind: &str, leaf: Option<&[u8]>, children: Vec<SemanticNode>) -> SemanticNode {
SemanticNode {
kind: kind.into(),
field_name: None,
leaf_text: leaf.map(|b| b.to_vec()),
children,
}
}
/// Abre un repo sled temporal, mete un AST chico (call_expression →
/// identifier "foo") y devuelve (dir, ruta, hash_raiz). Suelta el handle
/// sled (drop) para que `MingaSource::abrir` pueda re-abrir el mismo path.
fn repo_con_ast() -> (tempfile::TempDir, std::path::PathBuf, ContentHash) {
let dir = tempfile::tempdir().unwrap();
let ruta = dir.path().join("repo.minga");
let hash = {
let repo = PersistentRepo::open(&ruta).unwrap();
let hoja = nodo("identifier", Some(b"foo"), vec![]);
let llamada = nodo("call_expression", None, vec![hoja]);
let h = repo.nodes.put(&llamada).unwrap();
repo.flush().unwrap();
h
};
(dir, ruta, hash)
}
#[test]
fn navega_dag_de_ast_y_lee_hoja() {
let (_dir, ruta, raiz) = repo_con_ast();
let src = MingaSource::abrir(&ruta).unwrap();
let root = src.root();
assert_eq!(root.id, RAIZ);
assert!(root.is_container);
// La raíz lista todos los nodos; la raíz del AST está entre ellos y
// es contenedor.
let nodos = src.children(&root.id).unwrap();
let raiz_hex = to_hex(raiz.as_bytes());
let raiz_nodo = nodos.iter().find(|n| n.id == raiz_hex).expect("raíz en el listado");
assert!(raiz_nodo.name.starts_with("call_expression"));
assert!(raiz_nodo.is_container);
// Descender la raíz → su hijo identifier (hoja).
let hijos = src.children(&raiz_hex).unwrap();
assert_eq!(hijos.len(), 1);
assert!(hijos[0].name.starts_with("identifier"));
assert!(!hijos[0].is_container);
// Leer la hoja → el token "foo".
assert_eq!(src.read(&hijos[0].id).unwrap(), b"foo");
}
#[test]
fn repo_vacio_lista_cero_nodos() {
let dir = tempfile::tempdir().unwrap();
let ruta = dir.path().join("vacio.minga");
{
let _repo = PersistentRepo::open(&ruta).unwrap();
}
let src = MingaSource::abrir(&ruta).unwrap();
assert!(src.children(&RAIZ.to_string()).unwrap().is_empty());
assert!(src.read(&RAIZ.to_string()).is_err());
}
#[test]
fn id_basura_es_error() {
let (_dir, ruta, _raiz) = repo_con_ast();
let src = MingaSource::abrir(&ruta).unwrap();
assert!(src.children(&"no-es-hex".to_string()).is_err());
assert!(src.read(&"no-es-hex".to_string()).is_err());
}
}
@@ -0,0 +1,261 @@
//! [`Navigator`] — el estado de navegación genérico sobre cualquier
//! [`Source`].
//!
//! Es el equivalente agnóstico de `FileExplorerState` de
//! `nahual-file-explorer-llimphi`: mantiene una pila de contenedores (la
//! ruta desde la raíz), los hijos del contenedor actual, la selección y la
//! ventana de scroll virtual. No sabe nada de Llimphi ni de `PathBuf` — sólo
//! de [`Node`]s. El shell lo monta sobre un `Box<dyn Source>` y lo pinta.
//!
//! El punto de Brahman Fase 3: el shell deja de hablar POSIX y pasa a navegar
//! `dyn Source`. Montar una imagen wawa = `Navigator::open(Box::new(
//! WawaImgSource::abrir(...)?))` — el mismo navegador, otro backend.
use std::io;
use crate::{Node, NodeId, Source};
/// Cuántas filas se ven a la vez por defecto (mismo calibrado que el
/// explorador POSIX histórico).
pub const DEFAULT_VISIBLE_ROWS: usize = 32;
/// Resultado de [`Navigator::open_selected`].
pub enum Opened {
/// Era un contenedor: ya se descendió a él.
Descended,
/// Era una hoja: su [`NodeId`] para que el caller lea sus bytes.
Leaf(NodeId),
}
/// Estado de navegación sobre una [`Source`]. El caller lo guarda en su
/// modelo y le pasa los eventos de teclado/click.
pub struct Navigator {
source: Box<dyn Source>,
/// Contenedores desde la raíz hasta el actual (el último es el actual).
stack: Vec<Node>,
/// Hijos del contenedor actual.
children: Vec<Node>,
pub selected: usize,
pub visible_offset: usize,
pub visible_rows: usize,
wheel_accum: f32,
}
impl Navigator {
/// Monta el navegador sobre una fuente, posándose en su raíz y cargando
/// los hijos. Error si la raíz no se puede listar.
pub fn open(source: Box<dyn Source>) -> io::Result<Self> {
let root = source.root();
let children = source.children(&root.id)?;
Ok(Self {
source,
stack: vec![root],
children,
selected: 0,
visible_offset: 0,
visible_rows: DEFAULT_VISIBLE_ROWS,
wheel_accum: 0.0,
})
}
/// Nombre humano de la fuente (para el header).
pub fn label(&self) -> String {
self.source.label()
}
/// Ruta de nombres desde la raíz al contenedor actual, " / "-separada.
pub fn breadcrumb(&self) -> String {
self.stack
.iter()
.map(|n| n.name.as_str())
.collect::<Vec<_>>()
.join(" / ")
}
/// Hijos del contenedor actual.
pub fn children(&self) -> &[Node] {
&self.children
}
/// `true` si estamos en la raíz (no hay a dónde subir dentro de la
/// fuente). El caller lo usa para decidir si "subir" desmonta la fuente.
pub fn at_root(&self) -> bool {
self.stack.len() <= 1
}
/// El nodo actualmente seleccionado.
pub fn selected_node(&self) -> Option<&Node> {
self.children.get(self.selected)
}
/// Lee los bytes de una hoja por su id (delega en la fuente).
pub fn read(&self, id: &NodeId) -> io::Result<Vec<u8>> {
self.source.read(id)
}
/// Mueve la selección una fila arriba.
pub fn up(&mut self) -> bool {
if self.selected == 0 {
return false;
}
self.selected -= 1;
self.sync_offset();
true
}
/// Mueve la selección una fila abajo.
pub fn down(&mut self) -> bool {
if self.selected + 1 >= self.children.len() {
return false;
}
self.selected += 1;
self.sync_offset();
true
}
/// Selecciona la fila `idx` (con bound check + scroll sync).
pub fn select(&mut self, idx: usize) -> bool {
if idx >= self.children.len() {
return false;
}
self.selected = idx;
self.sync_offset();
true
}
/// Abre la selección: si es contenedor desciende; si es hoja devuelve su
/// id. `None` si no hay selección. Error si el contenedor no se puede
/// listar.
pub fn open_selected(&mut self) -> io::Result<Option<Opened>> {
let Some(node) = self.children.get(self.selected).cloned() else {
return Ok(None);
};
if node.is_container {
let children = self.source.children(&node.id)?;
self.stack.push(node);
self.children = children;
self.selected = 0;
self.visible_offset = 0;
Ok(Some(Opened::Descended))
} else {
Ok(Some(Opened::Leaf(node.id)))
}
}
/// Sube al contenedor padre dentro de la fuente. `false` si ya estábamos
/// en la raíz — el caller interpreta eso como "desmontar la fuente".
/// Al subir, re-selecciona el contenedor del que veníamos.
pub fn parent(&mut self) -> io::Result<bool> {
if self.stack.len() <= 1 {
return Ok(false);
}
let dejado = self.stack.pop().expect("len > 1");
let actual = self.stack.last().expect("queda al menos la raíz");
self.children = self.source.children(&actual.id)?;
self.selected = self
.children
.iter()
.position(|n| n.id == dejado.id)
.unwrap_or(0);
self.visible_offset = 0;
self.sync_offset();
Ok(true)
}
/// Aplica un delta de rueda (en líneas), devuelve los pasos enteros.
pub fn apply_wheel(&mut self, delta_y: f32) -> i32 {
let total = self.wheel_accum + delta_y;
let steps = total.trunc() as i32;
self.wheel_accum = total - steps as f32;
if steps != 0 {
self.scroll(steps);
}
steps
}
/// Scroll por N pasos (positivo = abajo). No mueve la selección.
pub fn scroll(&mut self, steps: i32) {
if steps == 0 {
return;
}
let max_offset = self.children.len().saturating_sub(self.visible_rows);
if steps > 0 {
self.visible_offset = (self.visible_offset + steps as usize).min(max_offset);
} else {
self.visible_offset = self.visible_offset.saturating_sub((-steps) as usize);
}
}
fn sync_offset(&mut self) {
if self.selected < self.visible_offset {
self.visible_offset = self.selected;
}
let bottom = self.visible_offset + self.visible_rows;
if self.selected >= bottom {
self.visible_offset = self.selected + 1 - self.visible_rows;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::PosixSource;
use std::fs;
use std::io::Write;
fn arbol() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("sub")).unwrap();
let mut f = fs::File::create(dir.path().join("sub/hoja.txt")).unwrap();
f.write_all(b"bytes de la hoja").unwrap();
fs::File::create(dir.path().join("raiz.txt")).unwrap();
dir
}
#[test]
fn desciende_lee_y_sube() {
let dir = arbol();
let mut nav = Navigator::open(Box::new(PosixSource::new(dir.path()))).unwrap();
assert!(nav.at_root());
// raíz: "sub/" (dir) primero, luego "raiz.txt".
assert_eq!(nav.children()[0].name, "sub");
assert!(nav.children()[0].is_container);
// Descender a "sub".
nav.select(0);
match nav.open_selected().unwrap() {
Some(Opened::Descended) => {}
_ => panic!("esperaba descender al dir"),
}
assert!(!nav.at_root());
assert_eq!(nav.breadcrumb().split(" / ").count(), 2);
// En "sub" hay una hoja: abrirla devuelve su id, y read da bytes.
let hoja = nav.children().iter().position(|n| n.name == "hoja.txt").unwrap();
nav.select(hoja);
match nav.open_selected().unwrap() {
Some(Opened::Leaf(id)) => {
assert_eq!(nav.read(&id).unwrap(), b"bytes de la hoja");
}
_ => panic!("esperaba una hoja"),
}
// Subir vuelve a la raíz y re-selecciona "sub".
assert!(nav.parent().unwrap());
assert!(nav.at_root());
assert_eq!(nav.selected_node().unwrap().name, "sub");
// Subir desde la raíz = false (el caller desmonta).
assert!(!nav.parent().unwrap());
}
#[test]
fn navegacion_vacia_no_panickea() {
let dir = tempfile::tempdir().unwrap();
let mut nav = Navigator::open(Box::new(PosixSource::new(dir.path()))).unwrap();
assert!(nav.children().is_empty());
assert!(!nav.up());
assert!(!nav.down());
assert!(nav.open_selected().unwrap().is_none());
}
}
@@ -0,0 +1,207 @@
//! Adapter [`Source`] sobre las **Mónadas semánticas** de nouser
//! (`chasqui-core`).
//!
//! A diferencia de POSIX (jerarquía de directorios) y wawa (DAG de
//! contenido), nouser agrupa archivos POSIX en *clusters* semánticos —
//! Mónadas— por directorio + afinidad. El árbol que expone es de DOS niveles:
//! la raíz lista las Mónadas (contenedores sintéticos), y cada Mónada lista
//! sus archivos miembro (hojas POSIX leíbles). Es la prueba de que el trait
//! [`Source`] generaliza más allá de árboles "físicos": un nodo contenedor no
//! tiene por qué existir como entidad en disco.
//!
//! Puro local y determinista: el pipeline scan→cluster usa
//! pseudo-embeddings deterministas cuando no hay daemon de embeddings, así
//! que no requiere red ni servicio. Detrás de la feature `nouser` para no
//! arrastrar el peso de `chasqui-core` (sled, walkdir) a quien sólo quiere
//! POSIX/wawa.
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use chasqui_core::cluster::by_directory;
use chasqui_core::scanner::{scan_directory, ScanConfig};
use crate::{Node, NodeId, Source};
/// Id de la raíz sintética que lista las Mónadas.
const RAIZ: &str = "@monadas";
/// Prefijo de id de una Mónada (contenedor semántico).
const PREF_MONADA: &str = "m:";
/// Prefijo de id de un archivo miembro (hoja POSIX).
const PREF_ARCHIVO: &str = "f:";
struct MonadaVista {
id: String,
label: String,
miembros: Vec<String>,
}
struct ArchivoVista {
nombre: String,
ruta: PathBuf,
}
/// Fuente que navega los archivos de un directorio agrupados en Mónadas.
pub struct NouserSource {
etiqueta: String,
monadas: Vec<MonadaVista>,
archivos: HashMap<String, ArchivoVista>,
}
impl NouserSource {
/// Escanea `dir` y clusteriza sus archivos en Mónadas. `min_archivos` es
/// el tamaño mínimo de un cluster para promoverlo a Mónada (usar 1 para
/// que hasta un directorio de un solo archivo aparezca).
pub fn escanear(dir: impl AsRef<Path>, min_archivos: usize) -> io::Result<Self> {
let dir = dir.as_ref();
let files = scan_directory(dir, &ScanConfig::default()).map_err(io::Error::other)?;
let archivos: HashMap<String, ArchivoVista> = files
.iter()
.map(|fe| {
let nombre = fe
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| fe.path.to_string_lossy().into_owned());
(fe.id.to_string(), ArchivoVista { nombre, ruta: fe.path.clone() })
})
.collect();
let monadas = by_directory(&files, min_archivos)
.into_iter()
.map(|m| MonadaVista {
id: m.id.to_string(),
label: if m.label.is_empty() {
m.path_hint.clone().unwrap_or_else(|| m.id.to_string())
} else {
m.label.clone()
},
miembros: m.members.iter().map(|f| f.to_string()).collect(),
})
.collect();
let etiqueta = dir.to_string_lossy().into_owned();
Ok(Self { etiqueta, monadas, archivos })
}
fn nodo_archivo(&self, fid: &str) -> Option<Node> {
self.archivos
.get(fid)
.map(|a| Node::new(format!("{PREF_ARCHIVO}{fid}"), a.nombre.clone(), false))
}
}
impl Source for NouserSource {
fn label(&self) -> String {
self.etiqueta.clone()
}
fn root(&self) -> Node {
Node::new(RAIZ, self.etiqueta.clone(), true)
}
fn children(&self, id: &NodeId) -> io::Result<Vec<Node>> {
if id == RAIZ {
return Ok(self
.monadas
.iter()
.map(|m| {
Node::new(
format!("{PREF_MONADA}{}", m.id),
format!("{} ({})", m.label, m.miembros.len()),
true,
)
})
.collect());
}
if let Some(mid) = id.strip_prefix(PREF_MONADA) {
let monada = self.monadas.iter().find(|m| m.id == mid).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, format!("Mónada inexistente: {id}"))
})?;
return Ok(monada
.miembros
.iter()
.filter_map(|fid| self.nodo_archivo(fid))
.collect());
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("una hoja no tiene hijos: {id}"),
))
}
fn read(&self, id: &NodeId) -> io::Result<Vec<u8>> {
let fid = id.strip_prefix(PREF_ARCHIVO).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("sólo los archivos miembro son leíbles: {id}"),
)
})?;
let archivo = self.archivos.get(fid).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, format!("archivo inexistente: {id}"))
})?;
std::fs::read(&archivo.ruta)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
fn arbol() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("proyecto_a")).unwrap();
fs::create_dir(dir.path().join("proyecto_b")).unwrap();
let mut f = fs::File::create(dir.path().join("proyecto_a/uno.txt")).unwrap();
f.write_all(b"contenido uno").unwrap();
fs::File::create(dir.path().join("proyecto_a/dos.txt")).unwrap();
fs::File::create(dir.path().join("proyecto_b/tres.rs")).unwrap();
dir
}
#[test]
fn navega_raiz_monadas_y_archivos() {
let dir = arbol();
let src = NouserSource::escanear(dir.path(), 1).unwrap();
let root = src.root();
assert!(root.is_container);
let monadas = src.children(&root.id).unwrap();
assert!(
monadas.len() >= 2,
"esperaba al menos 2 Mónadas (proyecto_a, proyecto_b), hubo {}",
monadas.len()
);
assert!(monadas.iter().all(|m| m.is_container));
// Encontrá la Mónada que contiene uno.txt y leé su contenido.
let mut encontrado = false;
for m in &monadas {
let archivos = src.children(&m.id).unwrap();
if let Some(uno) = archivos.iter().find(|a| a.name == "uno.txt") {
assert!(!uno.is_container);
assert_eq!(src.read(&uno.id).unwrap(), b"contenido uno");
encontrado = true;
}
}
assert!(encontrado, "ninguna Mónada contenía uno.txt");
}
#[test]
fn read_de_monada_o_raiz_es_error() {
let dir = arbol();
let src = NouserSource::escanear(dir.path(), 1).unwrap();
assert!(src.read(&RAIZ.to_string()).is_err());
let monadas = src.children(&RAIZ.to_string()).unwrap();
assert!(src.read(&monadas[0].id).is_err());
}
#[test]
fn escanear_dir_inexistente_es_error() {
assert!(NouserSource::escanear("/no/existe/jamas", 1).is_err());
}
}
@@ -0,0 +1,118 @@
//! Adapter [`Source`] sobre el filesystem POSIX vivo.
//!
//! Es lo que `nahual-file-explorer-llimphi` hacía a mano (`std::fs::read_dir`
//! + `Entry{name,is_dir}`), ahora detrás del trait común. El [`NodeId`] es la
//! ruta absoluta como string; los hijos vienen ordenados directorios-primero
//! y luego alfabético case-insensitive — el mismo orden presentable que el
//! explorador histórico.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::{Node, NodeId, Source};
/// Fuente que navega un subárbol del filesystem POSIX a partir de una raíz.
pub struct PosixSource {
root: PathBuf,
}
impl PosixSource {
/// Crea la fuente anclada en `root`. No valida que exista — un `root`
/// inválido simplemente devuelve `children` con error al navegarse.
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
}
fn nombre_de(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
impl Source for PosixSource {
fn label(&self) -> String {
self.root.to_string_lossy().into_owned()
}
fn root(&self) -> Node {
Node::new(self.root.to_string_lossy().into_owned(), nombre_de(&self.root), true)
}
fn children(&self, id: &NodeId) -> io::Result<Vec<Node>> {
let mut entries: Vec<(bool, String, String)> = Vec::new();
for entry in fs::read_dir(Path::new(id))? {
let entry = entry?;
let path = entry.path();
// `file_type` evita un stat extra; cae a metadata si es symlink.
let is_dir = match entry.file_type() {
Ok(ft) if ft.is_symlink() => fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false),
Ok(ft) => ft.is_dir(),
Err(_) => false,
};
entries.push((is_dir, nombre_de(&path), path.to_string_lossy().into_owned()));
}
// Directorios primero, luego alfabético case-insensitive — mismo
// criterio que el explorador POSIX histórico.
entries.sort_by(|a, b| {
b.0.cmp(&a.0).then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase()))
});
Ok(entries
.into_iter()
.map(|(is_dir, name, id)| Node::new(id, name, is_dir))
.collect())
}
fn read(&self, id: &NodeId) -> io::Result<Vec<u8>> {
fs::read(Path::new(id))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn arbol() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("zeta_dir")).unwrap();
fs::create_dir(dir.path().join("alpha_dir")).unwrap();
let mut f = fs::File::create(dir.path().join("hola.txt")).unwrap();
f.write_all(b"contenido posix").unwrap();
dir
}
#[test]
fn root_y_children_navegan_y_ordenan() {
let dir = arbol();
let src = PosixSource::new(dir.path());
let root = src.root();
assert!(root.is_container);
let kids = src.children(&root.id).unwrap();
// dos dirs primero (alpha, zeta) y luego el archivo.
assert_eq!(kids.len(), 3);
assert_eq!(kids[0].name, "alpha_dir");
assert!(kids[0].is_container);
assert_eq!(kids[1].name, "zeta_dir");
assert!(kids[1].is_container);
assert_eq!(kids[2].name, "hola.txt");
assert!(!kids[2].is_container);
}
#[test]
fn read_hoja_devuelve_bytes() {
let dir = arbol();
let src = PosixSource::new(dir.path());
let kids = src.children(&src.root().id).unwrap();
let hola = kids.iter().find(|n| n.name == "hola.txt").unwrap();
assert_eq!(src.read(&hola.id).unwrap(), b"contenido posix");
}
#[test]
fn children_de_ruta_inexistente_es_error() {
let src = PosixSource::new("/no/existe/jamas");
assert!(src.children(&"/no/existe/jamas".to_string()).is_err());
}
}
@@ -0,0 +1,217 @@
//! Adapter [`Source`] sobre los objetos content-addressed de una imagen
//! wawa (`.img`).
//!
//! Reusa `wawa-explorer-core::Disco` (lectura pura, sin red ni daemon: carga
//! el log entero en memoria y sirve lookups O(1) por hash). Navega el DAG
//! BLAKE3: el [`NodeId`] de un objeto es su hash en hex; los hijos son
//! `Objeto::hijos`; la hoja se lee de `Objeto::datos`.
//!
//! Los objetos wawa son **anónimos** (su identidad ES el hash), así que el
//! nombre de fila es el hash corto — feo pero navegable. La raíz se toma del
//! ancla del superbloque (`raiz`, si no `manifiesto`); si la imagen no tiene
//! anclas, se sintetiza una raíz `@imagen` que lista todos los objetos.
use std::io;
use wawa_explorer_core::Disco;
use crate::{from_hex, to_hex, Node, NodeId, Source};
/// Id sintético de la raíz cuando el superbloque no tiene anclas — su único
/// rol es contener "todos los objetos del grafo" para que la imagen siga
/// siendo navegable.
const RAIZ_SINTETICA: &str = "@imagen";
/// Fuente que navega el DAG de una imagen wawa cargada en memoria.
pub struct WawaImgSource {
disco: Disco,
etiqueta: String,
}
impl WawaImgSource {
/// Abre y carga la imagen `.img` en `ruta`. Error de I/O o de formato si
/// la imagen está corrupta o no es una imagen wawa.
pub fn abrir(ruta: impl AsRef<std::path::Path>) -> io::Result<Self> {
let ruta = ruta.as_ref();
let disco = Disco::abrir(ruta).map_err(io::Error::other)?;
let etiqueta = ruta
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| ruta.to_string_lossy().into_owned());
Ok(Self { disco, etiqueta })
}
/// Construye el `Node` de un objeto del grafo. El nombre es el hash
/// corto; es contenedor si tiene hijos.
fn nodo_de(&self, hash: &[u8; 32], nombre: Option<String>) -> Node {
let hex = to_hex(hash);
let nombre = nombre.unwrap_or_else(|| hex.chars().take(12).collect());
let es_contenedor = self
.disco
.hijos(hash)
.map(|h| !h.is_empty())
.unwrap_or(false);
Node::new(hex, nombre, es_contenedor)
}
fn parse_id(id: &NodeId) -> io::Result<[u8; 32]> {
from_hex(id).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, format!("id wawa inválido: {id}"))
})
}
}
impl Source for WawaImgSource {
fn label(&self) -> String {
self.etiqueta.clone()
}
fn root(&self) -> Node {
let sb = self.disco.superbloque();
if let Some(raiz) = sb.raiz {
self.nodo_de(&raiz, Some("raíz".into()))
} else if let Some(man) = sb.manifiesto {
self.nodo_de(&man, Some("manifiesto".into()))
} else {
// Sin anclas: raíz sintética que contiene todo el grafo.
Node::new(RAIZ_SINTETICA, self.etiqueta.clone(), true)
}
}
fn children(&self, id: &NodeId) -> io::Result<Vec<Node>> {
if id == RAIZ_SINTETICA {
// Todos los objetos del grafo, en orden estable por hash.
let mut hashes: Vec<[u8; 32]> = self.disco.hashes().copied().collect();
hashes.sort_unstable();
return Ok(hashes.iter().map(|h| self.nodo_de(h, None)).collect());
}
let hash = Self::parse_id(id)?;
let hijos = self.disco.hijos(&hash).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, format!("objeto wawa inexistente: {id}"))
})?;
Ok(hijos.to_vec().iter().map(|h| self.nodo_de(h, None)).collect())
}
fn read(&self, id: &NodeId) -> io::Result<Vec<u8>> {
if id == RAIZ_SINTETICA {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"la raíz sintética @imagen no tiene contenido leíble",
));
}
let hash = Self::parse_id(id)?;
self.disco
.objeto(&hash)
.map(|o| o.datos.clone())
.ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, format!("objeto wawa inexistente: {id}"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::to_hex;
use format::{componer_registro, Objeto, SuperBloque, MAGIA, TAM_SECTOR, VERSION_SUPERBLOQUE};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
/// Construye un `.img` sintético: dos hojas y un objeto-directorio que
/// las referencia. `anclar` decide si el superbloque apunta su `raiz` al
/// directorio (true) o queda sin anclas (false). Devuelve además los
/// hashes del directorio y de la primera hoja para las aserciones.
fn img_sintetico(anclar: bool) -> (tempfile::TempDir, PathBuf, [u8; 32], [u8; 32]) {
let dir = tempfile::tempdir().unwrap();
let ruta = dir.path().join("t.img");
let mut f = File::create(&ruta).unwrap();
let hoja_a = Objeto { datos: b"hola wawa".to_vec(), hijos: vec![] };
let pa = hoja_a.serializar().unwrap();
let ha = format::hash(&pa);
let hoja_b = Objeto { datos: vec![0xFF; 4], hijos: vec![] };
let pb = hoja_b.serializar().unwrap();
let hb = format::hash(&pb);
let dir_obj = Objeto { datos: b"raiz".to_vec(), hijos: vec![ha, hb] };
let pd = dir_obj.serializar().unwrap();
let hd = format::hash(&pd);
let reg_a = componer_registro(&pa);
let reg_b = componer_registro(&pb);
let reg_d = componer_registro(&pd);
let sectores = (reg_a.len() + reg_b.len() + reg_d.len()) / TAM_SECTOR;
let sb = SuperBloque {
magia: MAGIA,
version: VERSION_SUPERBLOQUE,
log_inicio: 1,
cursor: 1 + sectores as u64,
raiz: if anclar { Some(hd) } else { None },
manifiesto: None,
};
let sbb = sb.serializar().unwrap();
let mut sbs = vec![0u8; TAM_SECTOR];
sbs[..sbb.len()].copy_from_slice(&sbb);
f.write_all(&sbs).unwrap();
f.write_all(&reg_a).unwrap();
f.write_all(&reg_b).unwrap();
f.write_all(&reg_d).unwrap();
f.sync_all().unwrap();
(dir, ruta, hd, ha)
}
#[test]
fn raiz_anclada_navega_a_las_hojas() {
let (_dir, ruta, hd, ha) = img_sintetico(true);
let src = WawaImgSource::abrir(&ruta).unwrap();
let root = src.root();
assert_eq!(root.id, to_hex(&hd));
assert_eq!(root.name, "raíz");
assert!(root.is_container);
let kids = src.children(&root.id).unwrap();
assert_eq!(kids.len(), 2);
let hoja = kids.iter().find(|n| n.id == to_hex(&ha)).expect("hoja A");
assert!(!hoja.is_container);
assert_eq!(src.read(&hoja.id).unwrap(), b"hola wawa");
}
#[test]
fn sin_anclas_usa_raiz_sintetica() {
let (_dir, ruta, _hd, ha) = img_sintetico(false);
let src = WawaImgSource::abrir(&ruta).unwrap();
let root = src.root();
assert_eq!(root.id, RAIZ_SINTETICA);
assert!(root.is_container);
// Lista los 3 objetos del grafo.
let todos = src.children(&root.id).unwrap();
assert_eq!(todos.len(), 3);
// La hoja A sigue siendo leíble por su hash.
assert_eq!(src.read(&to_hex(&ha)).unwrap(), b"hola wawa");
// La raíz sintética no tiene contenido.
assert!(src.read(&RAIZ_SINTETICA.to_string()).is_err());
}
#[test]
fn id_basura_es_error() {
let (_dir, ruta, _hd, _ha) = img_sintetico(true);
let src = WawaImgSource::abrir(&ruta).unwrap();
assert!(src.children(&"no-es-hex".to_string()).is_err());
assert!(src.read(&"no-es-hex".to_string()).is_err());
}
#[test]
fn abrir_archivo_no_wawa_es_error() {
let dir = tempfile::tempdir().unwrap();
let ruta = dir.path().join("basura.img");
std::fs::write(&ruta, b"esto no es una imagen wawa").unwrap();
assert!(WawaImgSource::abrir(&ruta).is_err());
}
}
@@ -0,0 +1,5 @@
[package]
name = "nahual-svg-viewer-llimphi"
version.workspace = true
edition.workspace = true
publish.workspace = true
@@ -0,0 +1 @@
// stub temporal — el otro agente está creando este crate
@@ -0,0 +1,14 @@
[package]
name = "nahual-table-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-table-viewer-llimphi — visor de CSV/TSV sobre Llimphi. Parsea (con comillas básicas) y pinta una tabla alineada por columnas en fuente monoespaciada. Octavo visor del shell nahual; preview rápido de datos tabulares, distinto del editor de planillas nakui."
[dependencies]
nahual-viewer-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
@@ -0,0 +1,147 @@
//! `nahual-table-viewer-llimphi` — visor de CSV/TSV.
//!
//! Octavo visor del shell meta-app. `shuma-discern` marca `.csv`/`.tsv`
//! con lens `table` (por el hint de path + presencia del delimitador);
//! hasta ahora caían al text viewer, que muestra las filas crudas sin
//! alinear. Este visor parsea la tabla (comillas básicas estilo CSV) y
//! la pinta **alineada por columnas** en fuente monoespaciada — un
//! preview rápido para ver la forma de los datos.
//!
//! NO es el editor de planillas (`nakui`): es de sólo-lectura, capado en
//! filas/columnas, pensado para "echarle un ojo" desde el shell. Patrón
//! fino de los otros viewers: carga sync en [`load_table`], render en
//! [`table_viewer_view`].
#![forbid(unsafe_code)]
use std::path::Path;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
// El dominio (parseo + tipos) vive en `nahual-viewer-core`; lo
// re-exportamos para no romper a los consumidores.
pub use nahual_viewer_core::table::*;
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct TableViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_error: Color,
}
impl Default for TableViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl TableViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
}
}
}
/// Pinta header (nombre · filas×cols) + body con la tabla monoespaciada.
pub fn table_viewer_view<Msg>(
state: &TablePreview,
path: Option<&Path>,
palette: &TableViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let name = match path {
Some(p) => p
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string()),
None => "(seleccioná un CSV/TSV)".to_string(),
};
let header_text = match state {
TablePreview::Table { rows, cols, .. } => {
format!("table · {name} · {rows} × {cols}")
}
_ => format!("table · {name}"),
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let (body_text, body_color) = match state {
TablePreview::Empty => ("".to_string(), palette.fg_muted),
TablePreview::Table { text, .. } => (text.clone(), palette.fg_text),
TablePreview::TooBig(n) => (
format!("(tabla muy grande: {n} bytes — sin preview)"),
palette.fg_muted,
),
TablePreview::Error(e) => (format!("(error: {e})"), palette.fg_error),
};
let body = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.text_aligned_full(
body_text,
12.0,
body_color,
Alignment::Start,
false,
Some("monospace".to_string()),
);
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
@@ -0,0 +1,13 @@
[package]
name = "nahual-text-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-text-viewer-llimphi — visor de texto plano sobre Llimphi. Crate fino con la lógica de carga (size cap + null-byte guard + UTF-8 check) extraída en `PreviewState` + helper `load_preview`, y un `text_viewer_view` que pinta header + body con el tema activo."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
@@ -0,0 +1,15 @@
# nahual-text-viewer-llimphi
> Viewer de texto plano de [nahual](../README.md).
Read-only sobre [`text-editor`](../../llimphi/widgets/text-editor/README.md): syntax highlight según extensión, scroll, find, wrap toggle. Sin modificar el archivo.
## Uso
```sh
cargo run --release -p nahual-text-viewer-llimphi -- path/to/file
```
## Deps
- [`llimphi-widget-text-editor`](../../llimphi/widgets/text-editor/README.md)
@@ -0,0 +1,15 @@
# nahual-text-viewer-llimphi
> Plain-text viewer of [nahual](../README.md).
Read-only over [`text-editor`](../../llimphi/widgets/text-editor/README.md): syntax highlight by extension, scroll, find, wrap toggle. No file modification.
## Usage
```sh
cargo run --release -p nahual-text-viewer-llimphi -- path/to/file
```
## Deps
- [`llimphi-widget-text-editor`](../../llimphi/widgets/text-editor/README.md)
@@ -0,0 +1,213 @@
//! `nahual-text-viewer-llimphi` — visor de texto plano sobre Llimphi.
//!
//! Reemplazo Llimphi del `nahual-text-viewer` GPUI. Crate fino: la
//! lógica de carga vive en [`load_preview`] (size cap + null-byte
//! guard + UTF-8 check + truncate por líneas/chars), el render en
//! [`text_viewer_view`].
//!
//! La carga es sync: Llimphi-ui no tiene `cx.spawn` async como GPUI,
//! pero el límite de tamaño (`max_bytes`, default 256 KB) hace que un
//! `fs::read` típico complete dentro del budget de un frame. Para
//! archivos más grandes, el caller debería envolver `load_preview` en
//! un `Handle::spawn` y reentrar con un Msg al terminar.
//!
//! No incluye AppBus: el caller pasa `path: Option<&Path>` directo a
//! `text_viewer_view`. Eso lo hace consumible por `nahual-shell-llimphi`
//! sin depender de un bus aún no portado.
#![forbid(unsafe_code)]
use std::fs;
use std::path::Path;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
/// Tope por defecto de bytes a leer (256 KB). El caller puede pasar
/// otro a [`load_preview`] si el dominio lo justifica.
pub const DEFAULT_PREVIEW_BYTES_MAX: u64 = 256 * 1024;
/// Estado del preview de un archivo. Mismo shape que el viejo GPUI
/// pero sin las variantes async (`Loading`/`Unsupported`) — el caller
/// puede modelarlas afuera si necesita carga diferida.
#[derive(Debug, Clone)]
pub enum PreviewState {
/// Sin archivo seleccionado.
Empty,
/// Texto válido (posiblemente truncado a [`MAX_LINES`]/[`MAX_CHARS`]).
Text(String),
/// Bytes con null o no UTF-8 — etiquetado sin contenido.
Binary,
/// Excede `max_bytes` — etiquetado con el tamaño real.
TooBig(u64),
/// `fs::metadata` o `fs::read` falló — mensaje en el body.
Error(String),
}
impl Default for PreviewState {
fn default() -> Self {
PreviewState::Empty
}
}
const MAX_LINES: usize = 200;
const MAX_CHARS: usize = 8_000;
/// Lee el archivo y devuelve el estado correspondiente. Sync — ver el
/// note del crate sobre archivos grandes. `max_bytes` corta antes de
/// leer (vía `fs::metadata`), así no leemos un blob enorme aunque la
/// detección de binario nos saque después.
pub fn load_preview(path: &Path, max_bytes: u64) -> PreviewState {
match fs::metadata(path) {
Ok(meta) if meta.len() > max_bytes => return PreviewState::TooBig(meta.len()),
Err(e) => return PreviewState::Error(e.to_string()),
_ => {}
}
match fs::read(path) {
Ok(bytes) => {
if bytes.contains(&0) {
PreviewState::Binary
} else {
match String::from_utf8(bytes) {
Ok(s) => PreviewState::Text(truncate_preview(&s)),
Err(_) => PreviewState::Binary,
}
}
}
Err(e) => PreviewState::Error(e.to_string()),
}
}
/// Recorta `s` a `MAX_LINES` líneas o `MAX_CHARS` chars (lo que se
/// alcance primero) y agrega "\n…" al final. Mantiene el render
/// instantáneo aunque el archivo esté en el límite de `max_bytes`
/// (parley tarda en wrappear textos muy largos).
fn truncate_preview(s: &str) -> String {
let mut out = String::new();
for (i, line) in s.lines().enumerate() {
if i >= MAX_LINES || out.len() + line.len() + 1 > MAX_CHARS {
out.push_str("\n");
break;
}
out.push_str(line);
out.push('\n');
}
out
}
/// Paleta del viewer. Slots semánticos para que el caller pueda
/// reusar el tema de la app — la default usa `Theme::dark()`.
#[derive(Debug, Clone, Copy)]
pub struct TextViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_error: Color,
}
impl Default for TextViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl TextViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
}
}
}
/// Pinta el viewer: header con el nombre del archivo (o un placeholder
/// si no hay path) + body con el contenido según `state`. Usa `clip`
/// para que el texto no sangre al vecino.
pub fn text_viewer_view<Msg>(
state: &PreviewState,
path: Option<&Path>,
palette: &TextViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let header_text = match path {
Some(p) => p
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string()),
None => "(seleccioná un archivo)".to_string(),
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let (body_text, body_color) = match state {
PreviewState::Empty => ("".to_string(), palette.fg_muted),
PreviewState::Text(s) => (s.clone(), palette.fg_text),
PreviewState::Binary => (
"(archivo binario — sin preview)".to_string(),
palette.fg_muted,
),
PreviewState::TooBig(n) => (
format!("(archivo muy grande: {} bytes — sin preview)", n),
palette.fg_muted,
),
PreviewState::Error(e) => (format!("(error: {e})"), palette.fg_error),
};
let body = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.text_aligned(body_text, 12.0, body_color, Alignment::Start);
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
@@ -0,0 +1,11 @@
[package]
name = "nahual-thumb-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-thumb-core — pipeline de miniaturas agnóstico (sin Llimphi): genera thumbnails (decode + downscale), los cachea en RAM con invalidación por mtime, y planifica la cola de generación priorizada al viewport. Base para galerías tipo gThumb/FastStone. La concurrencia la pone el frontend con Handle::spawn; este crate sólo decide qué generar y guarda el resultado."
[dependencies]
image = { workspace = true }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
[package]
name = "nahual-tree-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-tree-viewer-llimphi — visor de estructuras JSON/TOML sobre Llimphi. Parsea a un árbol y lo pinta indentado con tipo+tamaño por nodo, legible aun para JSON minificado (que el text viewer mostraría como una sola línea). Sexto visor del shell nahual."
[dependencies]
nahual-viewer-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
@@ -0,0 +1,138 @@
//! `nahual-tree-viewer-llimphi` — visor de estructuras JSON/TOML.
//!
//! Sexto visor del shell meta-app. `shuma-discern` marca JSON y TOML con
//! lens `tree`, pero hasta ahora caían al text viewer — que muestra un
//! JSON **minificado** como una sola línea inservible. Este visor parsea
//! el documento a un árbol (`serde_json::Value`, unificando JSON y TOML)
//! y lo pinta **indentado**, con el tipo y el tamaño de cada nodo: se
//! escanea aunque el archivo venga en una línea.
//!
//! Patrón fino de los otros viewers: carga sync en [`load_tree`], render
//! en [`tree_viewer_view`]. No conoce el AppBus: el caller pasa el path.
//!
//! MVP feo-primero: el árbol es un bloque de texto indentado, estático
//! (sin colapsar nodos con click todavía). Capa primero la utilidad —
//! ver la forma del dato — sobre la interacción.
#![forbid(unsafe_code)]
use std::path::Path;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
// El dominio (parseo + tipos) vive en `nahual-viewer-core`; lo
// re-exportamos para no romper a los consumidores.
pub use nahual_viewer_core::tree::*;
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct TreeViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_error: Color,
}
impl Default for TreeViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl TreeViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
}
}
}
/// Pinta header (nombre del archivo) + body con el árbol.
pub fn tree_viewer_view<Msg>(
state: &TreePreview,
path: Option<&Path>,
palette: &TreeViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let header_text = match path {
Some(p) => format!(
"tree · {}",
p.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
None => "(seleccioná un JSON/TOML)".to_string(),
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let (body_text, body_color) = match state {
TreePreview::Empty => ("".to_string(), palette.fg_muted),
TreePreview::Tree(s) => (s.clone(), palette.fg_text),
TreePreview::TooBig(n) => (
format!("(árbol muy grande: {n} bytes — sin preview)"),
palette.fg_muted,
),
TreePreview::Error(e) => (format!("(no parsea: {e})"), palette.fg_error),
};
let body = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.text_aligned(body_text, 12.0, body_color, Alignment::Start);
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
@@ -0,0 +1,21 @@
[package]
name = "nahual-video-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-video-viewer-llimphi — reproductor/visor de video sobre Llimphi. Decodifica AV1 nativo (media-source-av1) frame a frame y lo pinta con View::image; header con tiempo + transporte. Análogo Llimphi del nahual-image-viewer."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
media-core = { workspace = true }
media-source-av1 = { workspace = true }
media-source-webm = { workspace = true }
media-source-gif = { workspace = true }
[[example]]
name = "video_viewer_demo"
path = "examples/video_viewer_demo.rs"
@@ -0,0 +1,45 @@
# nahual-video-viewer-llimphi
Visor/reproductor de video sobre Llimphi — el tercer visor de la familia
nahual, junto a `nahual-text-viewer-llimphi` y `nahual-image-viewer-llimphi`.
Crate fino, mismo patrón que sus hermanos:
- **`VideoViewerState::open_av1(path)`** — abre un `.ivf` con el decoder
**AV1 nativo** (`media-source-av1`, puro-Rust, sin ffmpeg). Arranca
reproduciendo.
- **`VideoViewerState::from_source(src, …)`** — envuelve cualquier
`Box<dyn FrameSource>` (p.ej. un puente `shared/foreign-av` para
H.264, o el `TestCard` de media-core). El viewer no sabe de códecs.
- **`VideoViewerState::tick(dt)`** — avanza la fuente; cuando hay frame
nuevo arma un `peniko::Image` y lo deja listo para pintar.
- **`video_viewer_view(state, palette)`** — header (`nombre · W×H · ▶/⏸ ·
mm:ss / mm:ss`) + cuerpo con el frame aspect-fit, o placeholder de
estado / error.
## Render por frame vs. llimphi-surface
Pinta cada frame con `View::image` (reconstruye un `peniko::Image`). Es
simple, reusable y devuelve un `View<Msg>` sin plumbing de wgpu — sirve
hasta ~1080p. Para 4K@60 fps el camino de cero-copia es `llimphi-surface`
(textura GPU persistente), como hace `media-app`; eso requiere acceso
directo al device/queue y no cabe en un componente que sólo retorna
`View<Msg>`. Ese trade-off está documentado en el doc del crate.
## Demo
```bash
# archivo AV1
cargo run -p nahual-video-viewer-llimphi --example video_viewer_demo --release -- clip.ivf
# procedural (TestCard de media-core), sin archivo
cargo run -p nahual-video-viewer-llimphi --example video_viewer_demo --release
```
Generá un `.ivf` de prueba con:
`ffmpeg -f lavfi -i testsrc=size=640x480:rate=30:duration=3 -c:v libsvtav1 clip.ivf`
## Tests
```bash
cargo test -p nahual-video-viewer-llimphi
```
@@ -0,0 +1,90 @@
//! Showcase de `nahual-video-viewer-llimphi`.
//!
//! Modo archivo: `cargo run -p nahual-video-viewer-llimphi --example video_viewer_demo --release -- /path/clip.ivf`
//! Modo procedural (sin args): usa el `TestCard` de media-core (gradiente
//! animado + círculo) para validar el pintado sin un archivo real.
use std::path::PathBuf;
use std::time::Duration;
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::{App, Handle, View};
use media_core::TestCard;
use nahual_video_viewer_llimphi::{video_viewer_view, VideoViewerPalette, VideoViewerState};
const TICK: Duration = Duration::from_millis(33); // ~30 Hz
struct Model {
state: VideoViewerState,
}
#[derive(Clone)]
enum Msg {
Tick,
}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · video viewer showcase"
}
fn initial_size() -> (u32, u32) {
(960, 700)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(TICK, || Msg::Tick);
let arg = std::env::args().nth(1).map(PathBuf::from);
let state = match arg {
Some(p) => {
let ext = p
.extension()
.and_then(|s| s.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("webm" | "mkv") => VideoViewerState::open_webm(&p),
_ => VideoViewerState::open_av1(&p),
}
}
None => VideoViewerState::from_source(
Box::new(TestCard::new(512, 320, 30.0)),
"testcard 512×320",
512,
320,
None,
),
};
Model { state }
}
fn update(mut model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
match msg {
Msg::Tick => {
model.state.tick(TICK);
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let palette = VideoViewerPalette::default();
let viewer = video_viewer_view::<Msg>(&model.state, &palette);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![viewer])
}
}
fn main() {
llimphi_ui::run::<Showcase>();
}
@@ -0,0 +1,452 @@
//! `nahual-video-viewer-llimphi` — visor/reproductor de video sobre Llimphi.
//!
//! Análogo Llimphi del `nahual-image-viewer-llimphi`, pero para video: la
//! carga vive en [`VideoViewerState::open_av1`] (abre un `.ivf` con el
//! decoder **AV1 nativo** de `media-source-av1` — puro-Rust, sin ffmpeg),
//! el avance en [`VideoViewerState::tick`], y el render en
//! [`video_viewer_view`] (header con tiempo + cuerpo con el frame).
//!
//! ## Render por frame vs. llimphi-surface
//!
//! Este visor pinta cada frame reconstruyendo un `peniko::Image` y
//! usando [`llimphi_ui::View::image`] (aspect-fit centrado). Es simple,
//! reusable y devuelve un `View<Msg>` sin plumbing de wgpu — ideal hasta
//! ~1080p. Para 4K@60 fps el camino de cero-copia es `llimphi-surface`
//! (textura GPU persistente que el decoder escribe sin pasar por la CPU),
//! como hace `media-app`; eso exige acceso directo al device/queue y no
//! cabe en un componente que sólo retorna `View<Msg>`.
//!
//! Para formatos ajenos (H.264/H.265…), la app puede construir su propio
//! `Box<dyn FrameSource>` con `shared/foreign-av` y pasarlo a
//! [`VideoViewerState::from_source`] — el viewer no sabe de códecs.
#![forbid(unsafe_code)]
use std::path::Path;
use std::time::Duration;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::{Blob, Color, Image, ImageFormat};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use media_core::FrameSource;
use media_source_av1::Av1VideoSource;
/// Estado del reproductor. Mantiene la fuente de frames, el último frame
/// decodificado como `peniko::Image`, y el transporte (play/pausa,
/// posición). `Clone` no aplica (la fuente no es clonable); la app lo
/// guarda en su modelo.
pub struct VideoViewerState {
source: Option<Box<dyn FrameSource + Send>>,
/// Último frame listo para pintar.
frame: Option<Image>,
width: u32,
height: u32,
/// Buffer RGBA reusado entre ticks (evita realocs).
rgba: Vec<u8>,
playing: bool,
position: Duration,
duration: Option<Duration>,
name: String,
error: Option<String>,
}
impl Default for VideoViewerState {
fn default() -> Self {
Self {
source: None,
frame: None,
width: 0,
height: 0,
rgba: Vec::new(),
playing: false,
position: Duration::ZERO,
duration: None,
name: String::new(),
error: None,
}
}
}
impl VideoViewerState {
/// Abre un `.ivf` con video AV1 vía el decoder nativo. Arranca
/// reproduciendo. Si falla, queda en estado de error (lo refleja el
/// header) y sin fuente.
pub fn open_av1(path: &Path) -> Self {
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
match Av1VideoSource::open(path) {
Ok(src) => {
let (w, h) = src.dimensions();
// `Seekable::duration` antes de boxear (perdemos el trait
// al borrar el tipo, pero la duración no cambia).
let duration = {
use media_core::Seekable;
src.duration()
};
Self {
source: Some(Box::new(src)),
frame: None,
width: w,
height: h,
rgba: Vec::new(),
playing: true,
position: Duration::ZERO,
duration,
name,
error: None,
}
}
Err(e) => Self {
name,
error: Some(e),
..Default::default()
},
}
}
/// Abre un `.webm`/`.mkv` con video AV1 vía el demuxer nativo
/// (`media-source-webm` → AV1 puro-Rust). Usa sólo el track de video
/// (el viewer no tiene sink de audio). Si no hay track AV1 o falla el
/// demux, queda en estado de error.
pub fn open_webm(path: &Path) -> Self {
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
match media_source_webm::WebmMedia::open(path) {
Ok(media) => match media.video {
Some(src) => {
let (w, h) = (media.width, media.height);
Self {
source: Some(Box::new(src)),
frame: None,
width: w,
height: h,
rgba: Vec::new(),
playing: true,
position: Duration::ZERO,
duration: media.duration,
name,
error: None,
}
}
None => Self {
name,
error: Some("el webm no tiene track de video AV1".to_string()),
..Default::default()
},
},
Err(e) => Self {
name,
error: Some(e.to_string()),
..Default::default()
},
}
}
/// Abre un GIF animado vía `media-source-gif` (frames RGBA8
/// precomputados con sus delays). El visor lo trata como cualquier
/// `FrameSource`: lo anima en loop. Las dimensiones se corrigen en
/// el primer `tick` (se pasan en 0 acá; el frame inicial las fija).
pub fn open_gif(path: &Path) -> Self {
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
match media_source_gif::GifSource::from_path(path) {
Ok(src) => {
let duration = Some(src.total_duration());
Self::from_source(Box::new(src), name, 0, 0, duration)
}
Err(e) => Self {
name,
error: Some(e.to_string()),
..Default::default()
},
}
}
/// Construye el viewer sobre una fuente arbitraria (p.ej. un puente
/// `foreign-av`). El viewer no decodifica: sólo tickea y pinta.
pub fn from_source(
source: Box<dyn FrameSource + Send>,
name: impl Into<String>,
width: u32,
height: u32,
duration: Option<Duration>,
) -> Self {
Self {
source: Some(source),
frame: None,
width,
height,
rgba: Vec::new(),
playing: true,
position: Duration::ZERO,
duration,
name: name.into(),
error: None,
}
}
pub fn dimensions(&self) -> (u32, u32) {
(self.width, self.height)
}
pub fn position(&self) -> Duration {
self.position
}
pub fn duration(&self) -> Option<Duration> {
self.duration
}
pub fn is_playing(&self) -> bool {
self.playing
}
/// Pausa/reanuda. En pausa, `tick` no avanza la fuente.
pub fn toggle_play(&mut self) {
self.playing = !self.playing;
}
pub fn set_playing(&mut self, playing: bool) {
self.playing = playing;
}
/// `true` si la reproducción terminó (la fuente se agotó).
pub fn finished(&self) -> bool {
self.source.is_none() && self.error.is_none() && self.frame.is_some()
}
/// Avanza el tiempo. Si hay un frame nuevo, actualiza la imagen a
/// pintar y devuelve `true`. Cuando la fuente se agota, la suelta
/// (deja el último frame congelado).
pub fn tick(&mut self, dt: Duration) -> bool {
if !self.playing {
return false;
}
let Some(src) = self.source.as_mut() else {
return false;
};
match src.tick(dt, &mut self.rgba) {
Some((w, h)) => {
self.width = w;
self.height = h;
let blob = Blob::from(self.rgba.clone());
self.frame = Some(Image::new(blob, ImageFormat::Rgba8, w, h));
self.position = self.position.saturating_add(dt);
true
}
None => {
// Sin frame este tick: avanzamos el reloj igual mientras
// haya fuente; si está agotada (varios None seguidos no
// los distinguimos sin Seekable), seguimos hasta que la
// app decida. Para no congelar el reloj, avanzamos.
self.position = self.position.saturating_add(dt);
false
}
}
}
}
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct VideoViewerPalette {
pub bg: Color,
pub fg: Color,
pub fg_muted: Color,
pub fg_error: Color,
pub accent: Color,
}
impl Default for VideoViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl VideoViewerPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
fg: t.fg_text,
fg_muted: t.fg_muted,
fg_error: t.fg_destructive,
accent: t.accent,
}
}
}
fn fmt_time(d: Duration) -> String {
let total = d.as_secs();
format!("{:02}:{:02}", total / 60, total % 60)
}
/// Pinta header (nombre · dims · ▶/⏸ · mm:ss / mm:ss) + body con el
/// frame actual (aspect-fit) o un placeholder.
pub fn video_viewer_view<Msg>(
state: &VideoViewerState,
palette: &VideoViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let name = if state.name.is_empty() {
"(seleccioná un video)".to_string()
} else {
state.name.clone()
};
let header_text = if let Some(e) = &state.error {
format!("{name} · error: {e}")
} else if state.width > 0 {
let glyph = if state.playing { "" } else { "" };
let time = match state.duration {
Some(d) => format!("{} / {}", fmt_time(state.position), fmt_time(d)),
None => fmt_time(state.position),
};
format!("{name} · {}×{} · {glyph} {time}", state.width, state.height)
} else {
name
};
let header_color = if state.error.is_some() {
palette.fg_error
} else {
palette.fg_muted
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, header_color, Alignment::Start);
let body = match (&state.error, &state.frame) {
(Some(e), _) => placeholder_body(&format!("(error: {e})"), palette.fg_error),
(None, Some(image)) => frame_body(image.clone()),
(None, None) => placeholder_body("", palette.fg_muted),
};
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![header, body])
}
fn placeholder_body<Msg>(text: &str, color: Color) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.text_aligned(text.to_string(), 12.0, color, Alignment::Center)
}
fn frame_body<Msg>(image: Image) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(6.0_f32),
bottom: length(12.0_f32),
},
..Default::default()
})
.image(image)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_time_basico() {
assert_eq!(fmt_time(Duration::from_secs(0)), "00:00");
assert_eq!(fmt_time(Duration::from_secs(75)), "01:15");
}
#[test]
fn tick_sobre_fuente_produce_frame() {
// Usamos el TestCard de media-core como fuente sintética: evita
// acoplar el test a un archivo y prueba el camino tick→frame.
// (El decode AV1 real se valida en media-source-av1.)
use media_core::TestCard;
let mut st = VideoViewerState::from_source(
Box::new(TestCard::new(64, 48, 30.0)),
"testcard",
64,
48,
None,
);
assert_eq!(st.dimensions(), (64, 48));
assert!(st.is_playing());
assert!(st.frame.is_none(), "sin tick todavía no hay frame");
let got = st.tick(Duration::from_secs(1));
assert!(got, "el primer tick debería producir un frame");
assert!(st.frame.is_some());
// En pausa, tick no avanza.
st.toggle_play();
assert!(!st.tick(Duration::from_secs(1)));
}
#[test]
fn open_inexistente_es_error() {
let st = VideoViewerState::open_av1(Path::new("/no/existe.ivf"));
assert!(st.error.is_some());
assert!(st.source.is_none());
}
}
@@ -0,0 +1,19 @@
[package]
name = "nahual-viewer-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-viewer-core — núcleos agnósticos de GUI de los visores simples de nahual: parseo/decode + tipos de preview de CSV/TSV (table), JSON/TOML (tree), volcado hex, archivos zip/tar (archive), fuentes TTF/OTF (font), Markdown y Cards. Un módulo por formato; cada `*-viewer-llimphi` sólo pinta el preview. Sin dependencias de render."
[dependencies]
serde_json = { workspace = true }
toml = { workspace = true }
zip = { workspace = true }
tar = { workspace = true }
flate2 = { workspace = true }
ttf-parser = { workspace = true }
pulldown-cmark = { workspace = true }
card-core = { workspace = true }
@@ -0,0 +1,402 @@
//! `archive` — núcleo agnóstico del visor de archive de nahual (parseo + tipos de preview). El render vive en `nahual-archive-viewer-llimphi`.
use std::io::Read;
use std::path::Path;
/// Entradas máximas a listar. Un ZIP con más se trunca para que parley no
/// se atragante; igual mostramos el conteo total en el resumen.
const MAX_ENTRIES: usize = 2000;
/// Ancho máximo del nombre en el render (se trunca con `…` por la
/// izquierda para conservar el sufijo, que suele ser lo distintivo).
pub const MAX_NAME: usize = 64;
/// Una entrada del archivo (sin su contenido).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveEntry {
pub name: String,
pub is_dir: bool,
/// Tamaño sin comprimir, en bytes.
pub size: u64,
/// Tamaño comprimido en el archivo, en bytes.
pub compressed: u64,
}
/// Qué formato de contenedor se abrió. Cambia cómo se rotula el resumen
/// (el ratio de compresión sólo tiene sentido por-entrada en ZIP).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveKind {
Zip,
Tar,
TarGz,
}
/// Resumen + listado de un archivo abierto.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveListing {
pub kind: ArchiveKind,
pub entries: Vec<ArchiveEntry>,
/// Total de entradas listadas. Para ZIP es el total real del archivo;
/// para tar/tar.gz (streaming) es lo que alcanzamos a leer.
pub total_entries: usize,
pub total_size: u64,
pub total_compressed: u64,
pub truncated: bool,
}
/// Estado del visor. Replica la forma de los otros para que el shell lo
/// trate igual.
#[derive(Debug, Clone, Default)]
pub enum ArchivePreview {
/// Sin archivo seleccionado.
#[default]
Empty,
/// Archivo abierto y listado.
Listing(ArchiveListing),
/// No es un ZIP válido o falló la E/S.
Error(String),
}
/// Abre el archivo, olfatea su magic y despacha al lister del formato. La
/// detección es por **contenido**: `PK` → ZIP, `ustar` en off 257 → tar,
/// `1f 8b` → gzip (que asumimos envuelve un tar).
pub fn load_archive(path: &Path) -> ArchivePreview {
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) => return ArchivePreview::Error(e.to_string()),
};
// 512 bytes alcanzan para el magic de ZIP/gzip (off 0) y el de tar
// (off 257); es además el tamaño de un header tar.
let mut head = [0u8; 512];
let n = match file.read(&mut head) {
Ok(n) => n,
Err(e) => return ArchivePreview::Error(e.to_string()),
};
let head = &head[..n];
if head.starts_with(b"PK\x03\x04") || head.starts_with(b"PK\x05\x06") {
// ZIP necesita el directorio central (al final): reabrimos por path.
return load_zip(path);
}
if head.len() >= 262 && &head[257..262] == b"ustar" {
return match std::fs::File::open(path) {
Ok(f) => list_tar(f, ArchiveKind::Tar),
Err(e) => ArchivePreview::Error(e.to_string()),
};
}
if head.starts_with(&[0x1F, 0x8B]) {
return match std::fs::File::open(path) {
Ok(f) => list_tar(flate2::read::GzDecoder::new(f), ArchiveKind::TarGz),
Err(e) => ArchivePreview::Error(e.to_string()),
};
}
ArchivePreview::Error("formato de archivo no reconocido".to_string())
}
/// Lee el directorio central de un ZIP. No descomprime nada: usa
/// `by_index_raw`, que sólo lee los headers (metadata), así que es barato
/// aun para archivos grandes y no necesita el backend de compresión.
fn load_zip(path: &Path) -> ArchivePreview {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) => return ArchivePreview::Error(e.to_string()),
};
let mut archive = match zip::ZipArchive::new(file) {
Ok(a) => a,
Err(e) => return ArchivePreview::Error(format!("no es un ZIP válido: {e}")),
};
let total_entries = archive.len();
let mut entries = Vec::with_capacity(total_entries.min(MAX_ENTRIES));
let mut total_size = 0u64;
let mut total_compressed = 0u64;
for i in 0..total_entries {
let entry = match archive.by_index_raw(i) {
Ok(e) => e,
Err(e) => return ArchivePreview::Error(e.to_string()),
};
total_size = total_size.saturating_add(entry.size());
total_compressed = total_compressed.saturating_add(entry.compressed_size());
if entries.len() < MAX_ENTRIES {
entries.push(ArchiveEntry {
name: entry.name().to_string(),
is_dir: entry.is_dir(),
size: entry.size(),
compressed: entry.compressed_size(),
});
}
}
ArchivePreview::Listing(ArchiveListing {
kind: ArchiveKind::Zip,
truncated: entries.len() < total_entries,
entries,
total_entries,
total_size,
total_compressed,
})
}
/// Recorre los headers de un tar (posiblemente envuelto en un decoder gzip
/// en streaming). `tar::Archive::entries` salta los datos de cada entrada,
/// así que no carga el archivo entero en memoria. tar no comprime
/// por-entrada, así que `compressed == size`.
fn list_tar<R: Read>(reader: R, kind: ArchiveKind) -> ArchivePreview {
let mut archive = tar::Archive::new(reader);
let iter = match archive.entries() {
Ok(it) => it,
Err(e) => return ArchivePreview::Error(e.to_string()),
};
let mut entries = Vec::new();
let mut total_size = 0u64;
let mut truncated = false;
for item in iter {
let entry = match item {
Ok(e) => e,
Err(e) => return ArchivePreview::Error(e.to_string()),
};
let size = entry.header().size().unwrap_or(0);
total_size = total_size.saturating_add(size);
if entries.len() >= MAX_ENTRIES {
truncated = true;
break;
}
let is_dir = entry.header().entry_type().is_dir();
let name = entry
.path()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "(nombre ilegible)".to_string());
entries.push(ArchiveEntry {
name,
is_dir,
size,
compressed: size,
});
}
ArchivePreview::Listing(ArchiveListing {
kind,
total_entries: entries.len(),
total_size,
total_compressed: total_size,
truncated,
entries,
})
}
/// Renderiza el listado a un bloque de texto monoespaciado: una línea por
/// entrada con `tamaño ratio nombre`. Las carpetas se marcan con `/`.
pub fn render_listing(l: &ArchiveListing) -> String {
let mut out = String::new();
match l.kind {
ArchiveKind::Zip => {
let ratio = if l.total_size > 0 {
100 - (l.total_compressed * 100 / l.total_size).min(100)
} else {
0
};
out.push_str(&format!(
"zip · {} entradas · {} → {} ({}% ahorro)\n",
l.total_entries,
fmt_bytes(l.total_size),
fmt_bytes(l.total_compressed),
ratio,
));
}
ArchiveKind::Tar => {
out.push_str(&format!(
"tar · {} entradas · {} (sin compresión)\n",
l.total_entries,
fmt_bytes(l.total_size),
));
}
ArchiveKind::TarGz => {
out.push_str(&format!(
"tar.gz · {} entradas · {} sin comprimir\n",
l.total_entries,
fmt_bytes(l.total_size),
));
}
}
out.push_str("────────────────────────────────────────\n");
for e in &l.entries {
let name = if e.is_dir {
format!("{}/", e.name.trim_end_matches('/'))
} else {
e.name.clone()
};
let name = ellipsize_left(&name, MAX_NAME);
if e.is_dir {
out.push_str(&format!("{:>10} {}\n", "", name));
} else if l.kind == ArchiveKind::Zip {
// El ratio por-entrada sólo tiene sentido en ZIP (compresión
// por archivo). En tar todos los datos están sin comprimir.
let r = if e.size > 0 {
format!("{}%", 100 - (e.compressed * 100 / e.size).min(100))
} else {
"".to_string()
};
out.push_str(&format!("{:>10} {:>5} {}\n", fmt_bytes(e.size), r, name));
} else {
out.push_str(&format!("{:>10} {}\n", fmt_bytes(e.size), name));
}
}
if l.truncated {
let suffix = if l.kind == ArchiveKind::Zip {
format!(
"{} entradas más sin listar",
l.total_entries - l.entries.len()
)
} else {
"hay más entradas sin listar".to_string()
};
out.push_str(&format!("… ({suffix})\n"));
}
out
}
/// Tamaño humano-legible (KiB/MiB/GiB), una cifra decimal.
pub fn fmt_bytes(n: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = KIB * 1024;
const GIB: u64 = MIB * 1024;
if n >= GIB {
format!("{:.1} GiB", n as f64 / GIB as f64)
} else if n >= MIB {
format!("{:.1} MiB", n as f64 / MIB as f64)
} else if n >= KIB {
format!("{:.1} KiB", n as f64 / KIB as f64)
} else {
format!("{n} B")
}
}
/// Trunca por la izquierda conservando el sufijo (`…rd/document.xml`), que
/// es lo que distingue rutas largas con prefijo común.
pub fn ellipsize_left(s: &str, max: usize) -> String {
let count = s.chars().count();
if count <= max {
return s.to_string();
}
let tail: String = s.chars().skip(count - (max - 1)).collect();
format!("{tail}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bytes_humanos() {
assert_eq!(fmt_bytes(512), "512 B");
assert_eq!(fmt_bytes(2048), "2.0 KiB");
assert_eq!(fmt_bytes(1024 * 1024 * 3), "3.0 MiB");
}
#[test]
fn ellipsize_conserva_sufijo() {
let s = "word/very/long/path/to/document.xml";
let e = ellipsize_left(s, 16);
assert!(e.starts_with('…'));
assert!(e.ends_with("document.xml"));
assert!(e.chars().count() <= 16);
}
#[test]
fn ellipsize_no_toca_cortos() {
assert_eq!(ellipsize_left("a.txt", 16), "a.txt");
}
#[test]
fn listing_resume_y_lista() {
let l = ArchiveListing {
kind: ArchiveKind::Zip,
entries: vec![
ArchiveEntry {
name: "dir/".into(),
is_dir: true,
size: 0,
compressed: 0,
},
ArchiveEntry {
name: "a.txt".into(),
is_dir: false,
size: 1000,
compressed: 400,
},
],
total_entries: 2,
total_size: 1000,
total_compressed: 400,
truncated: false,
};
let out = render_listing(&l);
assert!(out.contains("2 entradas"));
assert!(out.contains("60% ahorro")); // 1 - 400/1000
assert!(out.contains("a.txt"));
assert!(out.contains("dir/"));
}
/// Construye un tar en memoria con dos entradas.
fn tar_de_prueba() -> Vec<u8> {
let mut b = tar::Builder::new(Vec::new());
let mut h = tar::Header::new_gnu();
h.set_size(5);
h.set_mode(0o644);
b.append_data(&mut h, "hola.txt", &b"hello"[..]).unwrap();
let mut h2 = tar::Header::new_gnu();
h2.set_size(3);
h2.set_mode(0o644);
b.append_data(&mut h2, "dir/x.bin", &b"abc"[..]).unwrap();
b.into_inner().unwrap()
}
#[test]
fn lista_tar_en_memoria() {
let data = tar_de_prueba();
match list_tar(&data[..], ArchiveKind::Tar) {
ArchivePreview::Listing(l) => {
assert_eq!(l.kind, ArchiveKind::Tar);
assert_eq!(l.total_entries, 2);
assert_eq!(l.total_size, 8);
assert!(l
.entries
.iter()
.any(|e| e.name == "hola.txt" && e.size == 5));
assert!(l.entries.iter().any(|e| e.name == "dir/x.bin"));
}
other => panic!("esperaba Listing, obtuve {other:?}"),
}
}
#[test]
fn lista_tar_gz_descomprimiendo() {
use std::io::Write;
let tar = tar_de_prueba();
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(&tar).unwrap();
let gz = enc.finish().unwrap();
// Lo escribimos a disco y lo abrimos por load_archive (sniff real).
let tmp = std::env::temp_dir().join("nahual-archive-viewer-test.tar.gz");
std::fs::write(&tmp, &gz).unwrap();
match load_archive(&tmp) {
ArchivePreview::Listing(l) => {
assert_eq!(l.kind, ArchiveKind::TarGz);
assert_eq!(l.total_entries, 2);
assert_eq!(l.total_size, 8);
}
other => panic!("esperaba Listing TarGz, obtuve {other:?}"),
}
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn archivo_inexistente_es_error() {
let p = std::path::Path::new("/no/existe/x.zip");
assert!(matches!(load_archive(p), ArchivePreview::Error(_)));
}
#[test]
fn basura_no_es_zip() {
let tmp = std::env::temp_dir().join("nahual-archive-viewer-test-bad.zip");
std::fs::write(&tmp, b"no soy un zip").unwrap();
assert!(matches!(load_archive(&tmp), ArchivePreview::Error(_)));
let _ = std::fs::remove_file(&tmp);
}
}
@@ -0,0 +1,200 @@
//! `card` — núcleo agnóstico del visor de card de nahual (parseo + tipos de preview). El render vive en `nahual-card-viewer-llimphi`.
use card_core::{Card, CardKind, Payload, Supervision};
use std::fmt::Write as _;
use std::path::Path;
/// Estado del visor. La Card se boxea (es grande) y se guarda parseada
/// para que el render no re-parsee en cada frame.
pub enum CardPreview {
/// Sin archivo / no es una Card.
Empty,
/// Card parseada, lista para presentar.
Card(Box<Card>),
/// El archivo decía ser Card (lens `card`) pero no parseó.
Error(String),
}
impl Default for CardPreview {
fn default() -> Self {
CardPreview::Empty
}
}
/// Lee y parsea la Card del archivo. Intenta JSON y, si falla, TOML —
/// el shell ya discernió el contenido como Card, pero no asume el
/// formato textual. Sync: una Card pesa KB, no MB.
pub fn load_card(path: &Path) -> CardPreview {
let src = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return CardPreview::Error(e.to_string()),
};
match Card::from_json(&src).or_else(|_| Card::from_toml(&src)) {
Ok(card) => CardPreview::Card(Box::new(card)),
Err(e) => CardPreview::Error(e.to_string()),
}
}
/// Resume la Card en líneas `clave valor`. Sólo los campos con señal:
/// los vacíos/default se omiten para no ahogar lo relevante.
pub fn summarize(card: &Card) -> String {
let mut s = String::new();
let row = |s: &mut String, k: &str, v: &str| {
// `{:<13}` alinea las claves en una columna fija.
let _ = writeln!(s, "{k:<13}{v}");
};
row(&mut s, "label", &card.label);
row(&mut s, "id", &card.id.to_string());
if let Some(lineage) = card.lineage {
row(&mut s, "lineage", &lineage.to_string());
}
row(
&mut s,
"kind",
match card.kind {
CardKind::Ente => "ente (proceso)",
CardKind::Data => "data (mónada)",
},
);
row(&mut s, "payload", &fmt_payload(&card.payload));
row(&mut s, "supervision", &fmt_supervision(&card.supervision));
row(
&mut s,
"lifecycle",
&format!("{:?}", card.lifecycle).to_lowercase(),
);
row(
&mut s,
"priority",
&format!("{:?}", card.priority).to_lowercase(),
);
if !card.provides.is_empty() {
row(&mut s, "provides", &fmt_caps(&card.provides));
}
if !card.requires.is_empty() {
row(&mut s, "requires", &fmt_caps(&card.requires));
}
let perms = &card.permissions;
let mut pol = Vec::new();
pol.push(format!("net={:?}", perms.networking).to_lowercase());
pol.push(format!("fs={:?}", perms.filesystem).to_lowercase());
if perms.processes {
pol.push("processes".into());
}
row(&mut s, "permissions", &pol.join(" "));
if let Some(socket) = &card.service_socket {
row(&mut s, "socket", &socket.display().to_string());
}
if !card.references.is_empty() {
let refs: Vec<String> = card
.references
.iter()
.map(|r| {
let target = if r.target_label.is_empty() {
r.target_id.to_string()
} else {
r.target_label.clone()
};
format!("{}{target}", format!("{:?}", r.kind).to_lowercase())
})
.collect();
row(&mut s, "references", &refs.join(", "));
}
if !card.genesis.is_empty() {
row(
&mut s,
"genesis",
&format!("{} hija(s)", card.genesis.len()),
);
}
if let Some(data) = &card.data {
if !data.summary.is_empty() {
row(&mut s, "summary", &data.summary);
}
if !data.keywords.is_empty() {
row(&mut s, "keywords", &data.keywords.join(", "));
}
if data.member_count > 0 {
row(&mut s, "members", &data.member_count.to_string());
}
if !data.presentation_hint.is_empty() {
row(&mut s, "lens", &data.presentation_hint);
}
}
s
}
pub fn fmt_payload(p: &Payload) -> String {
match p {
Payload::Wasm { entry, .. } => format!("wasm (entry: {entry})"),
Payload::Native { exec, .. } => format!("native ({exec})"),
Payload::Virtual => "virtual (nodo lógico)".into(),
Payload::Legacy { exec, .. } => format!("legacy ({exec})"),
}
}
pub fn fmt_supervision(sv: &Supervision) -> String {
match sv {
Supervision::Restart { initial, max } => {
format!("restart ({}ms…{}ms)", initial.as_millis(), max.as_millis())
}
Supervision::OneShot => "oneshot".into(),
Supervision::Delegate => "delegate".into(),
}
}
pub fn fmt_caps<T: std::fmt::Debug>(caps: impl IntoIterator<Item = T>) -> String {
caps.into_iter()
.map(|c| format!("{c:?}"))
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"schema_version": 1,
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"label": "nakui-ventas",
"provides": ["Spawn", "Journal"],
"payload": {"Wasm": {"module_sha256": [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32], "entry": "main"}},
"supervision": "OneShot"
}"#;
#[test]
fn parsea_y_resume_card() {
let card = Card::from_json(SAMPLE).unwrap();
let out = summarize(&card);
assert!(out.contains("nakui-ventas"));
assert!(out.contains("wasm (entry: main)"));
assert!(out.contains("oneshot"));
assert!(out.contains("Spawn"));
}
#[test]
fn card_invalida_es_error() {
// Bytes que no son una Card válida.
let tmp = std::env::temp_dir().join("nahual-card-viewer-test-invalid.json");
std::fs::write(&tmp, b"{not a card}").unwrap();
assert!(matches!(load_card(&tmp), CardPreview::Error(_)));
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn omite_campos_vacios() {
let card = Card::from_json(SAMPLE).unwrap();
let out = summarize(&card);
// Sin `requires` declarado, no debe aparecer la fila.
assert!(!out.contains("requires"));
}
}
@@ -0,0 +1,128 @@
//! `hex` — núcleo agnóstico del visor de hex de nahual (parseo + tipos de preview). El render vive en `nahual-hex-viewer-llimphi`.
use std::path::Path;
/// Bytes que se vuelcan por defecto (4 KiB = 256 filas). El caller puede
/// pedir más; pasado cierto punto un dump deja de ser legible a ojo.
pub const DEFAULT_HEX_BYTES_MAX: usize = 4 * 1024;
/// Bytes por fila del dump.
const COLS: usize = 16;
/// Estado del visor.
#[derive(Debug, Clone)]
pub enum HexPreview {
/// Sin archivo seleccionado.
Empty,
/// Dump listo. `total` es el tamaño real del archivo (puede exceder
/// los bytes volcados → el header lo señala).
Dump {
text: String,
total: u64,
shown: usize,
},
/// `fs::read`/`metadata` falló.
Error(String),
}
impl Default for HexPreview {
fn default() -> Self {
HexPreview::Empty
}
}
/// Lee hasta `max_bytes` del inicio del archivo y arma el dump.
pub fn load_hex(path: &Path, max_bytes: usize) -> HexPreview {
use std::io::Read;
let total = match std::fs::metadata(path) {
Ok(m) => m.len(),
Err(e) => return HexPreview::Error(e.to_string()),
};
let mut f = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) => return HexPreview::Error(e.to_string()),
};
let mut buf = vec![0u8; max_bytes];
let n = match f.read(&mut buf) {
Ok(n) => n,
Err(e) => return HexPreview::Error(e.to_string()),
};
buf.truncate(n);
HexPreview::Dump {
text: dump(&buf),
total,
shown: n,
}
}
/// Formatea `bytes` como `OFFSET hex(8) hex(8) |ascii|`, 16 por fila.
fn dump(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 4);
for (row, chunk) in bytes.chunks(COLS).enumerate() {
if row > 0 {
out.push('\n');
}
// Offset.
let offset = row * COLS;
out.push_str(&format!("{offset:08x} "));
// Hex, en dos grupos de 8 separados por un espacio extra.
for i in 0..COLS {
if i == COLS / 2 {
out.push(' ');
}
match chunk.get(i) {
Some(b) => out.push_str(&format!("{b:02x} ")),
None => out.push_str(" "), // relleno para alinear el ascii
}
}
// ASCII.
out.push_str(" |");
for &b in chunk {
let c = if (0x20..0x7f).contains(&b) {
b as char
} else {
'.'
};
out.push(c);
}
out.push('|');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dump_basico_alinea_offset_hex_ascii() {
let d = dump(b"Hello, world!");
// Una sola fila: 13 bytes.
assert!(d.starts_with("00000000 "));
assert!(d.contains("48 65 6c 6c 6f")); // "Hello"
assert!(d.ends_with("|Hello, world!|"));
}
#[test]
fn no_imprimibles_son_punto() {
let d = dump(&[0x00, 0x1f, 0x7f, 0x41]);
assert!(d.ends_with("|...A|"));
}
#[test]
fn dos_filas_tienen_offset_correcto() {
let bytes: Vec<u8> = (0u8..20).collect();
let d = dump(&bytes);
let mut lines = d.lines();
assert!(lines.next().unwrap().starts_with("00000000 "));
assert!(lines.next().unwrap().starts_with("00000010 "));
}
#[test]
fn load_inexistente_es_error() {
assert!(matches!(
load_hex(Path::new("/no/existe.bin"), DEFAULT_HEX_BYTES_MAX),
HexPreview::Error(_)
));
}
}
@@ -0,0 +1,9 @@
//! `nahual-viewer-core` — núcleos agnósticos de los visores simples de
//! nahual (un módulo por formato). Parseo + tipos de preview, sin render.
pub mod archive;
pub mod card;
pub mod hex;
pub mod markdown;
pub mod table;
pub mod tree;
@@ -0,0 +1,283 @@
//! `markdown` — núcleo agnóstico del visor de markdown de nahual (parseo + tipos de preview). El render vive en `nahual-markdown-viewer-llimphi`.
use std::path::Path;
/// Tope de bytes a leer (1 MiB). Un Markdown más grande que eso no es un
/// documento a ojo; el caller puede subirlo si hace falta.
pub const DEFAULT_MARKDOWN_BYTES_MAX: u64 = 1024 * 1024;
/// Bloques máximos a renderizar. Corta documentos enormes para que el
/// panel siga instantáneo.
const MAX_BLOCKS: usize = 500;
/// Indentación máxima de listas anidadas (en niveles).
const MAX_LIST_DEPTH: u8 = 8;
/// Un bloque del documento con su estilo semántico. El render mapea cada
/// variante a un tamaño/fuente/color.
#[derive(Debug, Clone, PartialEq)]
pub enum MdBlock {
/// Encabezado `#`..`######` (nivel 16) con su texto aplanado.
Heading { level: u8, text: String },
/// Párrafo de texto corrido.
Paragraph(String),
/// Bloque de código (fenced o indentado), en monoespaciada.
Code(String),
/// Ítem de lista; `depth` 0 = nivel raíz.
ListItem { depth: u8, text: String },
/// Cita (`>`), en itálica.
Quote(String),
/// Regla horizontal (`---`).
Rule,
}
/// Estado del visor. Replica la forma de los otros para que el shell lo
/// trate igual.
#[derive(Debug, Clone, Default)]
pub enum MarkdownPreview {
/// Sin archivo seleccionado.
#[default]
Empty,
/// Documento parseado a bloques (posiblemente truncado).
Doc {
blocks: Vec<MdBlock>,
truncated: bool,
},
/// Excede el tope de tamaño.
TooBig(u64),
/// E/S falló.
Error(String),
}
/// Lee el archivo y lo parsea a bloques. La detección de tipo ya la hizo
/// el shell (lens `markdown`); acá sólo leemos UTF-8 y parseamos.
pub fn load_markdown(path: &Path, max_bytes: u64) -> MarkdownPreview {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > max_bytes => return MarkdownPreview::TooBig(meta.len()),
Err(e) => return MarkdownPreview::Error(e.to_string()),
_ => {}
}
let src = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return MarkdownPreview::Error(e.to_string()),
};
let (blocks, truncated) = parse_blocks(&src);
MarkdownPreview::Doc { blocks, truncated }
}
/// Parsea Markdown a una lista plana de [`MdBlock`]. El segundo valor es
/// `true` si se cortó en [`MAX_BLOCKS`]. El formato inline se aplana a
/// texto; sólo la estructura de bloques sobrevive.
pub fn parse_blocks(src: &str) -> (Vec<MdBlock>, bool) {
use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
let mut blocks: Vec<MdBlock> = Vec::new();
// Buffer del texto del bloque en curso.
let mut buf = String::new();
// Profundidad de listas anidadas (cantidad de `List` abiertas).
let mut list_depth: u8 = 0;
let mut in_item = false;
let mut quote_depth: u8 = 0;
let push = |blocks: &mut Vec<MdBlock>, b: MdBlock| {
if blocks.len() < MAX_BLOCKS {
blocks.push(b);
}
};
for ev in Parser::new(src) {
if blocks.len() >= MAX_BLOCKS {
return (blocks, true);
}
match ev {
Event::Start(Tag::Heading { .. }) => {
buf.clear();
}
Event::End(TagEnd::Heading(level)) => {
let lvl = match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
push(
&mut blocks,
MdBlock::Heading {
level: lvl,
text: std::mem::take(&mut buf).trim().to_string(),
},
);
}
Event::Start(Tag::CodeBlock(_)) => {
buf.clear();
}
Event::End(TagEnd::CodeBlock) => {
let code = std::mem::take(&mut buf);
push(
&mut blocks,
MdBlock::Code(code.trim_end_matches('\n').to_string()),
);
}
Event::Start(Tag::List(_)) => {
// Una lista anidada arranca dentro de un ítem; su texto de
// cabecera (el del ítem padre) está en `buf` y se perdería
// al limpiarlo en el `Start(Item)` hijo. Lo emitimos ahora,
// a la profundidad del ítem padre.
if in_item {
let text = std::mem::take(&mut buf).trim().to_string();
if !text.is_empty() {
let depth = list_depth.saturating_sub(1).min(MAX_LIST_DEPTH);
push(&mut blocks, MdBlock::ListItem { depth, text });
}
}
list_depth = list_depth.saturating_add(1);
}
Event::End(TagEnd::List(_)) => {
list_depth = list_depth.saturating_sub(1);
}
Event::Start(Tag::Item) => {
in_item = true;
buf.clear();
}
Event::End(TagEnd::Item) => {
in_item = false;
let text = std::mem::take(&mut buf).trim().to_string();
if !text.is_empty() {
let depth = list_depth.saturating_sub(1).min(MAX_LIST_DEPTH);
push(&mut blocks, MdBlock::ListItem { depth, text });
}
}
Event::Start(Tag::BlockQuote(_)) => {
quote_depth = quote_depth.saturating_add(1);
}
Event::End(TagEnd::BlockQuote(_)) => {
quote_depth = quote_depth.saturating_sub(1);
}
Event::End(TagEnd::Paragraph) => {
// El cierre de párrafo emite el bloque, salvo que el texto
// pertenezca a un ítem de lista (lo emite End(Item)).
if in_item {
continue;
}
let text = std::mem::take(&mut buf).trim().to_string();
if text.is_empty() {
continue;
}
if quote_depth > 0 {
push(&mut blocks, MdBlock::Quote(text));
} else {
push(&mut blocks, MdBlock::Paragraph(text));
}
}
Event::Text(t) => buf.push_str(&t),
Event::Code(t) => {
// Código inline: conservamos los backticks como pista.
buf.push('`');
buf.push_str(&t);
buf.push('`');
}
Event::SoftBreak => buf.push(' '),
Event::HardBreak => buf.push('\n'),
Event::Rule => {
buf.clear();
push(&mut blocks, MdBlock::Rule);
}
_ => {}
}
}
(blocks, false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encabezados_por_nivel() {
let (b, _) = parse_blocks("# uno\n\n## dos\n\n### tres\n");
assert_eq!(
b[0],
MdBlock::Heading {
level: 1,
text: "uno".into()
}
);
assert_eq!(
b[1],
MdBlock::Heading {
level: 2,
text: "dos".into()
}
);
assert_eq!(
b[2],
MdBlock::Heading {
level: 3,
text: "tres".into()
}
);
}
#[test]
fn parrafo_aplana_inline() {
let (b, _) = parse_blocks("hola **mundo** y `code` final\n");
// negrita se aplana a texto; inline code conserva backticks.
assert_eq!(b[0], MdBlock::Paragraph("hola mundo y `code` final".into()));
}
#[test]
fn lista_con_profundidad() {
let (b, _) = parse_blocks("- a\n- b\n - c\n");
assert_eq!(
b[0],
MdBlock::ListItem {
depth: 0,
text: "a".into()
}
);
assert_eq!(
b[1],
MdBlock::ListItem {
depth: 0,
text: "b".into()
}
);
assert_eq!(
b[2],
MdBlock::ListItem {
depth: 1,
text: "c".into()
}
);
}
#[test]
fn bloque_de_codigo() {
let (b, _) = parse_blocks("```rust\nfn main() {}\n```\n");
assert_eq!(b[0], MdBlock::Code("fn main() {}".into()));
}
#[test]
fn cita_y_regla() {
let (b, _) = parse_blocks("> citado\n\n---\n");
assert_eq!(b[0], MdBlock::Quote("citado".into()));
assert_eq!(b[1], MdBlock::Rule);
}
#[test]
fn documento_grande_se_trunca() {
let src = "# h\n\n".repeat(MAX_BLOCKS + 50);
let (b, truncated) = parse_blocks(&src);
assert!(truncated);
assert!(b.len() <= MAX_BLOCKS);
}
#[test]
fn vacio_no_panica() {
let (b, truncated) = parse_blocks("");
assert!(b.is_empty());
assert!(!truncated);
}
}
@@ -0,0 +1,215 @@
//! `table` — núcleo agnóstico del visor de table de nahual (parseo + tipos de preview). El render vive en `nahual-table-viewer-llimphi`.
use std::path::Path;
/// Tope de bytes a leer (2 MiB). Un preview no necesita el archivo entero.
pub const DEFAULT_TABLE_BYTES_MAX: u64 = 2 * 1024 * 1024;
/// Límites del render: filas y columnas mostradas, y ancho máximo de
/// celda (chars). Cortan tablas enormes para no atragantar el layout.
const MAX_ROWS: usize = 200;
const MAX_COLS: usize = 32;
const MAX_CELL_W: usize = 32;
/// Estado del visor.
#[derive(Debug, Clone)]
pub enum TablePreview {
/// Sin archivo seleccionado.
Empty,
/// Tabla renderizada + metadatos de tamaño real (para el header).
Table {
text: String,
rows: usize,
cols: usize,
},
/// Excede el tope de tamaño.
TooBig(u64),
/// E/S falló.
Error(String),
}
impl Default for TablePreview {
fn default() -> Self {
TablePreview::Empty
}
}
/// Lee y renderiza el archivo. El delimitador se elige por extensión:
/// `.tsv` → tab, cualquier otra → coma.
pub fn load_table(path: &Path, max_bytes: u64) -> TablePreview {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > max_bytes => return TablePreview::TooBig(meta.len()),
Err(e) => return TablePreview::Error(e.to_string()),
_ => {}
}
let src = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return TablePreview::Error(e.to_string()),
};
let delim = if path.extension().and_then(|s| s.to_str()) == Some("tsv") {
'\t'
} else {
','
};
render(&src, delim)
}
/// Parsea `src` y arma la tabla alineada. Cuenta filas/columnas reales
/// (no las capadas) para el header.
fn render(src: &str, delim: char) -> TablePreview {
let all_rows: Vec<Vec<String>> = src
.lines()
.filter(|l| !l.is_empty())
.map(|line| parse_row(line, delim))
.collect();
if all_rows.is_empty() {
return TablePreview::Table {
text: "(tabla vacía)".to_string(),
rows: 0,
cols: 0,
};
}
let total_rows = all_rows.len();
let total_cols = all_rows.iter().map(Vec::len).max().unwrap_or(0);
// Vista capada.
let rows: Vec<&Vec<String>> = all_rows.iter().take(MAX_ROWS).collect();
let cols = total_cols.min(MAX_COLS);
// Ancho por columna = máx celda (capado), sobre las filas mostradas.
let mut widths = vec![0usize; cols];
for row in &rows {
for (c, width) in widths.iter_mut().enumerate() {
let cell = row.get(c).map(String::as_str).unwrap_or("");
*width = (*width).max(cell.chars().count().min(MAX_CELL_W));
}
}
let mut out = String::new();
for (r, row) in rows.iter().enumerate() {
if r > 0 {
out.push('\n');
}
for c in 0..cols {
if c > 0 {
out.push_str("");
}
let cell = row.get(c).map(String::as_str).unwrap_or("");
out.push_str(&pad(cell, widths[c]));
}
// Separador bajo la cabecera.
if r == 0 {
out.push('\n');
for c in 0..cols {
if c > 0 {
out.push_str("─┼─");
}
out.push_str(&"".repeat(widths[c]));
}
}
}
if total_rows > rows.len() || total_cols > cols {
out.push_str(&format!(
"\n… ({total_rows} filas × {total_cols} cols; mostradas {}×{})",
rows.len(),
cols
));
}
TablePreview::Table {
text: out,
rows: total_rows,
cols: total_cols,
}
}
/// Trunca/rellena `cell` al ancho `w` (en chars). Trunca con `…`.
fn pad(cell: &str, w: usize) -> String {
let n = cell.chars().count();
if n > w {
let head: String = cell.chars().take(w.saturating_sub(1)).collect();
format!("{head}")
} else {
let mut s = cell.to_string();
s.extend(std::iter::repeat(' ').take(w - n));
s
}
}
/// Parsea una línea CSV/TSV con comillas dobles básicas: un campo entre
/// `"` puede contener el delimitador y `""` como comilla escapada.
fn parse_row(line: &str, delim: char) -> Vec<String> {
let mut fields = Vec::new();
let mut cur = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if in_quotes {
if ch == '"' {
if chars.peek() == Some(&'"') {
cur.push('"');
chars.next();
} else {
in_quotes = false;
}
} else {
cur.push(ch);
}
} else if ch == '"' {
in_quotes = true;
} else if ch == delim {
fields.push(std::mem::take(&mut cur).trim().to_string());
} else {
cur.push(ch);
}
}
fields.push(cur.trim().to_string());
fields
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsea_campos_simples() {
assert_eq!(parse_row("a,b,c", ','), vec!["a", "b", "c"]);
}
#[test]
fn respeta_comillas_con_delimitador() {
assert_eq!(parse_row(r#"x,"a,b",z"#, ','), vec!["x", "a,b", "z"]);
}
#[test]
fn comilla_escapada() {
assert_eq!(
parse_row(r#""di ""hola""",y"#, ','),
vec![r#"di "hola""#, "y"]
);
}
#[test]
fn render_alinea_y_cuenta() {
let csv = "fecha,monto\n2026-01,10\n2026-02,200\n";
match render(csv, ',') {
TablePreview::Table { text, rows, cols } => {
assert_eq!(rows, 3);
assert_eq!(cols, 2);
// Header + separador + filas.
assert!(text.contains("fecha"));
assert!(text.contains("─┼─"));
assert!(text.contains(""));
}
other => panic!("esperaba Table, obtuve {other:?}"),
}
}
#[test]
fn celda_larga_se_trunca() {
let long = "z".repeat(MAX_CELL_W + 10);
let p = pad(&long, MAX_CELL_W);
assert!(p.ends_with('…'));
assert_eq!(p.chars().count(), MAX_CELL_W);
}
}
@@ -0,0 +1,203 @@
//! `tree` — núcleo agnóstico del visor de tree de nahual (parseo + tipos de preview). El render vive en `nahual-tree-viewer-llimphi`.
use serde_json::Value;
use std::path::Path;
/// Tope de bytes a leer (1 MiB). Un árbol más grande que eso no se
/// escanea a ojo de todas formas; el caller puede subirlo si hace falta.
pub const DEFAULT_TREE_BYTES_MAX: u64 = 1024 * 1024;
/// Líneas y profundidad máximas del render. Cortan árboles enormes para
/// que parley no se atragante y el panel siga instantáneo.
const MAX_LINES: usize = 600;
const MAX_DEPTH: usize = 24;
/// Strings más largos que esto se truncan con `…` (un valor suelto no
/// debe empujar el árbol fuera de pantalla).
const MAX_STR: usize = 96;
/// Estado del visor. La forma replica al text viewer para que el shell
/// lo trate igual.
#[derive(Debug, Clone)]
pub enum TreePreview {
/// Sin archivo seleccionado.
Empty,
/// Árbol renderizado (posiblemente truncado a [`MAX_LINES`]).
Tree(String),
/// Excede el tope de tamaño.
TooBig(u64),
/// Parseo o E/S falló.
Error(String),
}
impl Default for TreePreview {
fn default() -> Self {
TreePreview::Empty
}
}
/// Lee y parsea el archivo. JSON vía `serde_json`, TOML vía `toml` (ambos
/// deserializan a `serde_json::Value`, el modelo unificado). El formato
/// se prueba JSON primero (lo más común) y TOML como fallback.
pub fn load_tree(path: &Path, max_bytes: u64) -> TreePreview {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > max_bytes => return TreePreview::TooBig(meta.len()),
Err(e) => return TreePreview::Error(e.to_string()),
_ => {}
}
let src = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return TreePreview::Error(e.to_string()),
};
let value = serde_json::from_str::<Value>(&src).or_else(|_| toml::from_str::<Value>(&src));
match value {
Ok(v) => TreePreview::Tree(render_tree(&v)),
Err(e) => TreePreview::Error(e.to_string()),
}
}
/// Renderiza el valor raíz como un árbol indentado.
fn render_tree(root: &Value) -> String {
let mut out = String::new();
let mut lines = 0usize;
walk(root, 0, "root", &mut out, &mut lines);
if lines >= MAX_LINES {
out.push_str("\n… (árbol truncado)");
}
out
}
/// Emite una línea por nodo. Compuestos muestran `tipo (n)` y recursan;
/// escalares se imprimen inline. `label` es la clave/índice del padre.
fn walk(v: &Value, depth: usize, label: &str, out: &mut String, lines: &mut usize) {
if *lines >= MAX_LINES {
return;
}
let indent = " ".repeat(depth);
match v {
Value::Object(map) => {
push_line(
out,
lines,
&format!("{indent}{label}: object ({})", map.len()),
);
if depth + 1 > MAX_DEPTH {
push_line(out, lines, &format!("{indent} … (demasiado profundo)"));
return;
}
for (k, child) in map {
walk(child, depth + 1, k, out, lines);
if *lines >= MAX_LINES {
break;
}
}
}
Value::Array(arr) => {
push_line(
out,
lines,
&format!("{indent}{label}: array ({})", arr.len()),
);
if depth + 1 > MAX_DEPTH {
push_line(out, lines, &format!("{indent} … (demasiado profundo)"));
return;
}
for (i, child) in arr.iter().enumerate() {
let idx = format!("[{i}]");
walk(child, depth + 1, &idx, out, lines);
if *lines >= MAX_LINES {
break;
}
}
}
scalar => {
push_line(
out,
lines,
&format!("{indent}{label}: {}", fmt_scalar(scalar)),
);
}
}
}
fn push_line(out: &mut String, lines: &mut usize, line: &str) {
if *lines >= MAX_LINES {
return;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
*lines += 1;
}
fn fmt_scalar(v: &Value) -> String {
match v {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => {
let shown: String = if s.chars().count() > MAX_STR {
let head: String = s.chars().take(MAX_STR).collect();
format!("{head}")
} else {
s.clone()
};
// Una línea: sin saltos que rompan la indentación.
format!("\"{}\"", shown.replace('\n', ""))
}
// Object/Array no llegan acá (los maneja `walk`).
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_minificado_se_indenta() {
let v: Value = serde_json::from_str(r#"{"a":1,"b":[true,null],"c":{"d":"x"}}"#).unwrap();
let out = render_tree(&v);
assert!(out.contains("root: object (3)"));
assert!(out.contains(" a: 1"));
assert!(out.contains(" b: array (2)"));
assert!(out.contains(" [0]: true"));
assert!(out.contains(" [1]: null"));
assert!(out.contains(" c: object (1)"));
assert!(out.contains(" d: \"x\""));
}
#[test]
fn string_largo_se_trunca() {
let long = "z".repeat(MAX_STR + 50);
let v = Value::String(long);
let s = fmt_scalar(&v);
assert!(s.ends_with("\""));
assert!(s.chars().count() <= MAX_STR + 4);
}
#[test]
fn toml_tambien_parsea() {
let tmp = std::env::temp_dir().join("nahual-tree-viewer-test.toml");
std::fs::write(&tmp, b"title = \"x\"\n[owner]\nname = \"y\"\n").unwrap();
match load_tree(&tmp, DEFAULT_TREE_BYTES_MAX) {
TreePreview::Tree(s) => {
assert!(s.contains("title: \"x\""));
assert!(s.contains("owner: object (1)"));
}
other => panic!("esperaba Tree, obtuve {other:?}"),
}
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn basura_es_error() {
let tmp = std::env::temp_dir().join("nahual-tree-viewer-test-bad.json");
std::fs::write(&tmp, b"\x00\x01 no soy json ni toml =[").unwrap();
assert!(matches!(
load_tree(&tmp, DEFAULT_TREE_BYTES_MAX),
TreePreview::Error(_)
));
let _ = std::fs::remove_file(&tmp);
}
}