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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user