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:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
@@ -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 200–600 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 200–600 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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(¤t, &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(¤t, &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(¤t, &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(¤t, &proposed).is_empty());
|
||||
|
||||
let current_str = json!({"qty": "100"});
|
||||
let proposed_int = map(&[("qty", json!(100_i64))]);
|
||||
assert_eq!(compute_field_delta(¤t_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(¤t, &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(¤t, &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(¤t, &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(¤t, &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, ¶ms)?;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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 Paz–Cusco–Lima" },
|
||||
"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
|
||||
/// ~4–8 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(®, 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(®, Some(&disc(None, Some("image/png")))).is_none());
|
||||
// Sin mime / sin discernment → None.
|
||||
assert!(external_handler_for(®, Some(&disc(None, None))).is_none());
|
||||
assert!(external_handler_for(®, 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(®_a).unwrap();
|
||||
f.write_all(®_b).unwrap();
|
||||
f.write_all(®_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 1–6) 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);
|
||||
}
|
||||
}
|
||||
Generated
+7950
File diff suppressed because it is too large
Load Diff
+474
@@ -0,0 +1,474 @@
|
||||
# Cargo.toml raíz STANDALONE de nahual — visores open-with sobre Llimphi.
|
||||
# Front-door limpio: solo el código de nahual; lo fundacional (card, chasqui,
|
||||
# minga, media, wawa-explorer, shuma-discern, hojas shared) se consume por
|
||||
# git-dep del monorepo gioser.git, y Llimphi de su repo. Cero vendoring.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"02_ruway/nahual/libs/meta-runtime",
|
||||
"02_ruway/nahual/libs/meta-schema",
|
||||
"02_ruway/nahual/nahual-archive-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-audio-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-card-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-file-explorer-llimphi",
|
||||
"02_ruway/nahual/nahual-font-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-gallery-llimphi",
|
||||
"02_ruway/nahual/nahual-geo-core",
|
||||
"02_ruway/nahual/nahual-hex-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-image-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-map-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-markdown-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-shell-llimphi",
|
||||
"02_ruway/nahual/nahual-source-core",
|
||||
"02_ruway/nahual/nahual-svg-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-table-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-text-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-thumb-core",
|
||||
"02_ruway/nahual/nahual-tree-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-video-viewer-llimphi",
|
||||
"02_ruway/nahual/nahual-viewer-core",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.80"
|
||||
license = "MIT"
|
||||
authors = ["Sergio <gerencia@jlsoltech.com>"]
|
||||
publish = false
|
||||
repository = "https://gitea.gioser.net/sergio/nahual"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
# === Registro de apps / menú global ===
|
||||
app-bus = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# === Serialización ===
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
lsp-types = "0.97"
|
||||
serde-big-array = "0.5"
|
||||
postcard = { version = "1", features = ["use-std"] }
|
||||
toml = "0.8"
|
||||
ron = "0.8"
|
||||
bincode = "1"
|
||||
base64 = "0.22"
|
||||
|
||||
# === Errores ===
|
||||
thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores
|
||||
anyhow = "1"
|
||||
|
||||
# === Async ===
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["compat"] }
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
|
||||
# === Observabilidad ===
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
# === Linux primitives (arje) ===
|
||||
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
|
||||
libc = "0.2"
|
||||
|
||||
# === IDs / Hash / Crypto ===
|
||||
ulid = { version = "1", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "rng-getrandom"] }
|
||||
sha2 = "0.10"
|
||||
blake3 = "1.5"
|
||||
ed25519-dalek = "2"
|
||||
aes-gcm = "0.10"
|
||||
chacha20poly1305 = "0.10"
|
||||
argon2 = "0.5"
|
||||
rand = "0.8"
|
||||
|
||||
# === WASM (arje) ===
|
||||
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
|
||||
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
|
||||
wasmi = "1.0"
|
||||
wat = "1"
|
||||
|
||||
# === Storage / DB ===
|
||||
sled = "0.34"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "blob"] }
|
||||
|
||||
# === Ingesta de documentos (iniy-ingest: PDF / EPUB) ===
|
||||
pdf-extract = "0.7"
|
||||
epub = "2.1"
|
||||
|
||||
# === Bulk import Wikipedia (iniy-wiki dump) ===
|
||||
bzip2 = "0.4"
|
||||
|
||||
# === Compresión (minga multi-bundle) ===
|
||||
zstd = "0.13"
|
||||
|
||||
# === HTTP server (iniy-server) ===
|
||||
axum = "0.7"
|
||||
tower = "0.5"
|
||||
|
||||
# === ANN sobre embeddings (iniy nli --ann) ===
|
||||
instant-distance = "0.6"
|
||||
|
||||
# === P2P (minga) ===
|
||||
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
|
||||
libp2p-stream = "=0.4.0-alpha"
|
||||
libp2p-allow-block-list = "0.6"
|
||||
|
||||
# === SSH (ssh, sandokan RemoteEngine, matilda) ===
|
||||
russh = "0.54"
|
||||
|
||||
# === Math determinista cross-platform (dominium) ===
|
||||
libm = "0.2"
|
||||
|
||||
# === SMF (takiy-midi) ===
|
||||
# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path.
|
||||
midly = "0.5"
|
||||
|
||||
# === Code parsing (minga) ===
|
||||
arboard = "3"
|
||||
ropey = "1.6"
|
||||
tree-sitter = "0.24"
|
||||
tree-sitter-rust = "0.23"
|
||||
tree-sitter-python = "0.23"
|
||||
tree-sitter-typescript = "0.23"
|
||||
tree-sitter-javascript = "0.23"
|
||||
tree-sitter-go = "0.23"
|
||||
|
||||
# === FS notify ===
|
||||
notify = "6.1"
|
||||
|
||||
# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) ===
|
||||
petgraph = "0.6"
|
||||
|
||||
# === Image decoding (nahual-image-viewer-llimphi) ===
|
||||
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
|
||||
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
|
||||
# los pide específicamente.
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
|
||||
# === FUSE (minga-vfs) ===
|
||||
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
|
||||
# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime).
|
||||
fuser = { version = "0.15", default-features = false }
|
||||
|
||||
# === CLI / auth (minga) ===
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rpassword = "7"
|
||||
|
||||
# === PAM (auth-core) ===
|
||||
pam = "0.8"
|
||||
|
||||
# === D-Bus (arje compat) ===
|
||||
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
||||
|
||||
# === Tests ===
|
||||
tempfile = "3"
|
||||
|
||||
# === Llimphi (motor gráfico soberano) ===
|
||||
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
|
||||
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24.
|
||||
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
|
||||
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
|
||||
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
|
||||
wgpu = "24"
|
||||
winit = "0.30"
|
||||
raw-window-handle = "0.6"
|
||||
pollster = "0.4"
|
||||
vello = "0.5"
|
||||
taffy = "0.9"
|
||||
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
|
||||
parley = "0.4"
|
||||
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
|
||||
llimphi-ui = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# Paleta semántica compartida por las apps y los widgets.
|
||||
llimphi-theme = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# Tweens y helpers de animación sobre el bucle Elm.
|
||||
llimphi-motion = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
|
||||
llimphi-icons = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# Widgets reusables sobre llimphi-ui — uno por crate.
|
||||
llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-menubar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
|
||||
llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
|
||||
# modal, empty, status-bar, shortcuts-help, splash).
|
||||
llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# Controles de formulario y signaling (switch, segmented, breadcrumb,
|
||||
# badge, avatar, skeleton, field).
|
||||
llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# Firma visual transversal (gradient sutil + hairline accent).
|
||||
llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
llimphi-workspace = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
# Abstracción Selector — host (paths) + wawa (khipus).
|
||||
llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
|
||||
# === Filesystem helpers ===
|
||||
directories = "5"
|
||||
|
||||
# === Diff line-based (llimphi-module-diff-viewer) ===
|
||||
# `similar` es la crate de facto: implementa Myers + Patience + LCS,
|
||||
# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete),
|
||||
# zero deps fuera de std. La 2.x es estable hace años.
|
||||
similar = "2"
|
||||
|
||||
# === Fuzzy matching (shuma-history) ===
|
||||
# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct,
|
||||
# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple
|
||||
# que necesitamos (Matcher + Pattern + score).
|
||||
nucleo-matcher = "0.3"
|
||||
|
||||
# === Transporte autenticado (shuma-link) ===
|
||||
# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente
|
||||
# conoce la pubkey del servidor, server descubre la del cliente y la
|
||||
# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s.
|
||||
# La versión 0.9 viene pinneada por libp2p, así nos alineamos.
|
||||
snow = "0.9"
|
||||
hex = "0.4"
|
||||
|
||||
# === PTY + emulador de terminal (shuma-exec, módulos REPL) ===
|
||||
# portable-pty aloja un PTY cross-platform; lo usamos para los
|
||||
# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad.
|
||||
# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor
|
||||
# movement + erase + screen state) y mantiene un buffer de pantalla
|
||||
# renderizable como grid.
|
||||
portable-pty = "0.9"
|
||||
vt100 = "0.16"
|
||||
|
||||
# === WASM web (gioser) ===
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = "0.3"
|
||||
glam = "0.30"
|
||||
|
||||
# === Markdown (pluma) ===
|
||||
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
|
||||
|
||||
# === Archivos comprimidos (nahual archive viewer) ===
|
||||
# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos,
|
||||
# por eso default-features=false alcanza para ZIP. Para tar.gz sí
|
||||
# descomprimimos en streaming con flate2 (ya declarado arriba), saltando
|
||||
# los datos de cada entrada — sólo leemos headers.
|
||||
zip = { version = "2.4", default-features = false }
|
||||
tar = { version = "0.4", default-features = false }
|
||||
|
||||
# === Fuentes (nahual font viewer) ===
|
||||
# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths.
|
||||
ttf-parser = "0.25"
|
||||
|
||||
# ============================================================
|
||||
# Intra-workspace deps de nahual (referenciadas por workspace = true)
|
||||
# ============================================================
|
||||
nahual-text-viewer-llimphi = { path = "02_ruway/nahual/nahual-text-viewer-llimphi" }
|
||||
nahual-image-viewer-llimphi = { path = "02_ruway/nahual/nahual-image-viewer-llimphi" }
|
||||
nahual-thumb-core = { path = "02_ruway/nahual/nahual-thumb-core" }
|
||||
nahual-gallery-llimphi = { path = "02_ruway/nahual/nahual-gallery-llimphi" }
|
||||
nahual-video-viewer-llimphi = { path = "02_ruway/nahual/nahual-video-viewer-llimphi" }
|
||||
nahual-card-viewer-llimphi = { path = "02_ruway/nahual/nahual-card-viewer-llimphi" }
|
||||
nahual-audio-viewer-llimphi = { path = "02_ruway/nahual/nahual-audio-viewer-llimphi" }
|
||||
nahual-tree-viewer-llimphi = { path = "02_ruway/nahual/nahual-tree-viewer-llimphi" }
|
||||
nahual-hex-viewer-llimphi = { path = "02_ruway/nahual/nahual-hex-viewer-llimphi" }
|
||||
nahual-table-viewer-llimphi = { path = "02_ruway/nahual/nahual-table-viewer-llimphi" }
|
||||
nahual-markdown-viewer-llimphi = { path = "02_ruway/nahual/nahual-markdown-viewer-llimphi" }
|
||||
nahual-archive-viewer-llimphi = { path = "02_ruway/nahual/nahual-archive-viewer-llimphi" }
|
||||
nahual-font-viewer-llimphi = { path = "02_ruway/nahual/nahual-font-viewer-llimphi" }
|
||||
nahual-map-viewer-llimphi = { path = "02_ruway/nahual/nahual-map-viewer-llimphi" }
|
||||
nahual-geo-core = { path = "02_ruway/nahual/nahual-geo-core" }
|
||||
nahual-viewer-core = { path = "02_ruway/nahual/nahual-viewer-core" }
|
||||
nahual-file-explorer-llimphi = { path = "02_ruway/nahual/nahual-file-explorer-llimphi" }
|
||||
|
||||
# ============================================================
|
||||
# Intra-workspace deps de pineal (módulo de gráficos)
|
||||
# ============================================================
|
||||
pineal-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-render = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-cartesian = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-stream = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-mesh = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-financial = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-polar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-heatmap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-treemap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-flow = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-phosphor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-export = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-hexbin = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-contour = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal-bars = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
pineal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
|
||||
# ============================================================
|
||||
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
|
||||
# ============================================================
|
||||
iniy-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
iniy-ingest = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
iniy-extract = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
iniy-nli = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
iniy-nli-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
iniy-graph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
iniy-store = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
|
||||
# === auto: declarados por crates internos faltantes ===
|
||||
cosmos-coords = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
cosmos-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
cosmos-ephemeris = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
cosmos-time = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
cosmos-wcs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
|
||||
# === auto: externas de eternal ===
|
||||
celestial-eop-data = { version = "0.1"}
|
||||
approx = "0.5"
|
||||
byteorder = "1.5"
|
||||
cc = "1.0"
|
||||
chrono = "0.4"
|
||||
crc32fast = "1.4"
|
||||
criterion = "0.5"
|
||||
csv = "1.4"
|
||||
flate2 = "1.0"
|
||||
glob = "0.3"
|
||||
indicatif = "0.18"
|
||||
lz4_flex = "0.11"
|
||||
memmap2 = "0.9"
|
||||
mockito = "1.0"
|
||||
ndarray = "0.15"
|
||||
num-traits = "0.2"
|
||||
once_cell = "1.19"
|
||||
parking_lot = "0.12"
|
||||
png = "0.18"
|
||||
proptest = "1.4"
|
||||
quick-xml = "0.31"
|
||||
rayon = "1.8"
|
||||
regex = "1.11"
|
||||
reqwest = "0.12"
|
||||
tiff = "0.11"
|
||||
wide = "0.7"
|
||||
wiremock = "0.6"
|
||||
|
||||
# === i18n (rimay-localize) ===
|
||||
fluent-bundle = "0.15"
|
||||
unic-langid = { version = "0.9", features = ["macros"] }
|
||||
sys-locale = "0.3"
|
||||
|
||||
# === Servo (puriy-engine) ===
|
||||
# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever
|
||||
# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no
|
||||
# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente
|
||||
# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer
|
||||
# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista,
|
||||
# evita pull de tokio en el engine.
|
||||
html5ever = "0.39"
|
||||
markup5ever = "0.39"
|
||||
markup5ever_rcdom = "0.39"
|
||||
cssparser = "0.35"
|
||||
url = "2"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
|
||||
# === takiy-synth (SoundFont MIDI) ===
|
||||
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
|
||||
# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc).
|
||||
rustysynth = "1.3"
|
||||
|
||||
# === takiy-playback (audio device output) ===
|
||||
# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en
|
||||
# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para
|
||||
# abrir el device default y empujar muestras f32 — nada de mezclado
|
||||
# ni efectos en el callback.
|
||||
cpal = "0.15"
|
||||
|
||||
# === media-source-wav (decoder PCM en disco) ===
|
||||
# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM
|
||||
# entero (8/16/24/32) y float (32). Suficiente para abrir samples y
|
||||
# stems de prueba sin meter ffmpeg/symphonia.
|
||||
hound = "3.5"
|
||||
|
||||
# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) ===
|
||||
# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre
|
||||
# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre
|
||||
# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg)
|
||||
# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac:
|
||||
# ese tier patentado entra por shared/foreign-av.
|
||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
|
||||
|
||||
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
|
||||
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
|
||||
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
|
||||
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
|
||||
ogg = "0.9"
|
||||
opus-wave = "3"
|
||||
|
||||
# === media-source-webm (demux nativo Matroska/WebM) ===
|
||||
# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los
|
||||
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
|
||||
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
|
||||
matroska-demuxer = "0.7"
|
||||
# === git-deps al monorepo (agregados por la extracción) ===
|
||||
card-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
chasqui-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
format = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-audio-cpal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-source-av1 = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-source-flac = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-source-gif = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-source-mp3 = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-source-opus = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-source-vorbis = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-source-wav = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
media-source-webm = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
minga-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
minga-store = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
rimay-localize = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
shuma-discern = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
wawa-config = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
wawa-config-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
wawa-explorer-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sergio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,37 @@
|
||||
# nahual
|
||||
|
||||
> `nahual` (Nahuatl: *companion spirit*). Everyday "open-with" viewers, in Rust, on [Llimphi](https://gitea.gioser.net/sergio/llimphi).
|
||||
|
||||
`nahual` is the set of viewers a desktop user expects — a file shell that dispatches the right viewer by content, plus viewers for text, images, audio, video, SVG, maps, fonts, hex, tables, markdown, archives and more. All render on the same GPU-accelerated Llimphi UI. A **meta-runtime** lets you define a new viewer from a JSON schema, with no Rust.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
cargo run --release -p nahual-shell-llimphi # the open-with shell (dispatches by content)
|
||||
cargo run --release -p nahual-file-explorer-llimphi # file tree
|
||||
cargo run --release -p nahual-image-viewer-llimphi # image viewer
|
||||
cargo run --release -p nahual-text-viewer-llimphi # text viewer
|
||||
```
|
||||
|
||||
## Viewers
|
||||
|
||||
`text` · `image` (PNG/JPEG/WebP) · `audio` · `video` · `svg` · `map` (geo) · `font` (TTF/OTF glyph outlines) · `hex` · `table` · `markdown` · `archive` (zip/tar) · `card` · `gallery` · `tree` — plus `nahual-viewer-core` / `nahual-source-core` / `nahual-thumb-core` and the `meta-runtime` + `meta-schema` libraries.
|
||||
|
||||
## How dependencies work
|
||||
|
||||
This is a clean front-door repo: it contains **only nahual's own crates**. Everything else is pulled as a git dependency —
|
||||
|
||||
- the **Llimphi** UI framework → [`llimphi.git`](https://gitea.gioser.net/sergio/llimphi)
|
||||
- foundational crates (content discern, media decoders, content-addressed sources, shared leaves) → the [`gioser`](https://gitea.gioser.net/sergio/gioser) monorepo, the suite's source of truth.
|
||||
|
||||
No vendoring, no duplication. First build clones those repos (cached afterwards).
|
||||
|
||||
## Considerations
|
||||
|
||||
- **Viewers, not editors.** To *edit* text use `nada`; to edit an image use `pineal`.
|
||||
- The media viewers decode AV1/Opus/FLAC/MP3/Vorbis/WebM natively (pure-Rust, no ffmpeg).
|
||||
- Cross-platform Llimphi UI (Linux/macOS/Windows); viewers also compile inside the Wawa kernel.
|
||||
|
||||
## License
|
||||
|
||||
MIT. Builds on [Llimphi](https://gitea.gioser.net/sergio/llimphi) and the [gioser](https://gitea.gioser.net/sergio/gioser) suite.
|
||||
Reference in New Issue
Block a user