chore: refresco desde el monorepo (pin tawasuyu.git v0.2.0)

Regenerado con scripts/actualizar-standalone.py: código del dominio al día,
git-deps repineadas a v0.2.0 y doble-fuente resuelta con [patch] al source git.
cargo check --workspace verde.
This commit is contained in:
Sergio
2026-07-25 21:31:17 +00:00
parent 0768050067
commit c9006ca202
203 changed files with 45438 additions and 4431 deletions
+44 -14
View File
@@ -2,34 +2,64 @@
> `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.
El "abridor universal" de la suite: un shell de archivos que discierne cualquier archivo **por contenido** (`shuma-discern``viewer_registry::pick`) y lo despacha a uno de 23 visores in-process — texto, imagen (pan/zoom, EXIF), video (AV1/WebM/GIF), audio (con espectro en vivo), card, tree (JSON/TOML), hex, tabla (CSV/TSV), markdown, documentos `pluma`, ODT, decks, mapa (GeoJSON/GPX/KML, ruteo A*, basemap PMTiles/MVT), archive (zip/tar), fuente, PDF, cómics CBZ, SVG, JPEG XL, PSD, EPUB, tablas DBF/xBase y hojas `.xlsx` — más un despacho web (el HTML lanza `puriy`, que además renderiza inline vía `puriy-render`). Completan el front universal una galería de miniaturas y el trait `Source` (POSIX · imagen wawa `.img` · nouser · minga). Implementados con la misma framework de UI; comparten preferencias con el resto del monorepo via `wawa-config`.
## 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
cargo run --release -p nahual-shell-llimphi # shell + los 23 visores
cargo run --release -p nahual-gallery-llimphi # galería de miniaturas
```
Los crates de visores son bibliotecas que el shell monta; sólo el shell y la galería son binarios.
## Compatibilidad
- **Linux / macOS / Windows** — UI Llimphi nativa.
- **Wawa** — los viewers compilan adentro del kernel; el file explorer habla con `wawa-fs`.
- **Wawa** — el shell navega imágenes wawa `.img` (objetos content-addressed) a través del adapter `Source` de `nahual-source-core`, host-side sobre `wawa-explorer-core`.
Diseño de detección/despacho y registro de visores en [ARQUITECTURA.md](ARQUITECTURA.md).
## 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). |
| [`nahual-shell-llimphi`](nahual-shell-llimphi/README.md) | Shell de archivos (bin): navegación + despacho por contenido a los visores. |
| `nahual-gallery-llimphi` | Galería de miniaturas (bin): zoom de grilla, EXIF, slideshow, ordenamiento. |
| [`nahual-file-explorer-llimphi`](nahual-file-explorer-llimphi/README.md) | Lógica de exploración de directorios (lista virtualizada). |
| [`nahual-text-viewer-llimphi`](nahual-text-viewer-llimphi/README.md) | Visor de texto (fallback universal, syntax por extensión). |
| [`nahual-image-viewer-llimphi`](nahual-image-viewer-llimphi/README.md) | Visor de imagen (PNG/JPEG/WebP; pan/zoom, EXIF) sobre `llimphi-image`. |
| [`nahual-video-viewer-llimphi`](nahual-video-viewer-llimphi/README.md) | Reproductor de video (AV1 puro-Rust: WebM/MKV/IVF + GIF animado). |
| `nahual-audio-viewer-llimphi` | Reproductor de audio (WAV/MP3/FLAC/Opus/Vorbis; espectro 48 bandas). |
| `nahual-card-viewer-llimphi` | Visor estructurado de Cards (`shared/card`). |
| `nahual-tree-viewer-llimphi` | Árbol JSON/TOML indentado. |
| `nahual-hex-viewer-llimphi` | Volcado hex/ASCII para binarios (ELF/wasm). |
| `nahual-table-viewer-llimphi` | Tabla CSV/TSV con columnas alineadas. |
| `nahual-markdown-viewer-llimphi` | Markdown renderizado (pulldown-cmark). |
| `nahual-map-viewer-llimphi` | Mapa: GeoJSON/GPX/KML, inspección, choropleth, búsqueda, ruteo. |
| `nahual-archive-viewer-llimphi` | Listado de comprimidos (ZIP/jar/apk/epub/OOXML, tar, tar.gz). |
| `nahual-font-viewer-llimphi` | Fuentes TTF/OTF: metadatos + muestra dibujada con los contornos. |
| `nahual-pdf-viewer-llimphi` | PDF: rasteriza cada página en CPU (`shared/foreign-pdf`). |
| `nahual-cbz-viewer-llimphi` | Cómics `.cbz` (un zip de imágenes) con navegación de páginas. |
| `nahual-sheet-viewer-llimphi` | Hojas `.xlsx` vía `shared/foreign-xlsx` + el motor de fórmulas `yupay`. |
| `nahual-dbf-viewer-llimphi` | Tablas DBF/xBase rescatadas (`shared/foreign-dbf`). |
| `nahual-svg-viewer-llimphi` | Gráficos vectoriales SVG. |
| `nahual-pluma-viewer-llimphi` | Documentos nativos de `pluma`. |
| `nahual-deck-viewer-llimphi` | Presentaciones (deck) en modo lectura. |
| `nahual-cotejo-llimphi` | Cotejo: comparar dos documentos lado a lado. |
| `nahual-geo-voxel` | Puente mapa real → relieve voxel (DEM GeoTIFF/PGM → `Heightfield`). |
| `nahual-shell-core` | La lógica del shell independiente de la UI (operaciones de archivo, asistencia). |
| `nahual-module` | El explorador como **módulo embebible** en otra app Llimphi. |
| `nahual-iconos` | Los íconos del explorador. |
| `nahual-viewer-core` | Núcleos agnósticos de GUI de los visores simples (parseo/decode + tipos de preview). |
| `nahual-geo-core` | Núcleo geoespacial agnóstico: parseo, proyección, hit-test, A*, basemap PMTiles v3 + MVT. |
| `nahual-source-core` | Trait `Source` + adapters (POSIX, wawa `.img`, nouser, minga). |
| `nahual-thumb-core` | Pipeline de miniaturas: generación, cache, cola priorizada al viewport. |
| [`meta-schema`](libs/meta-schema/README.md) | Schema declarativo de UIs data-driven. |
| [`meta-runtime`](libs/meta-runtime/README.md) | Helpers puros sobre el schema (parseo tipado, validación, delta). |
## 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.
- **Visualizadores, no editores.** Si quieres editar el archivo, `nada`. Si quieres editar la imagen, `pineal` o un editor externo.
- Los visores nuevos se registran in-process en `viewer_registry`; la costura open-with (`external_handler_for` sobre `shared/app-bus`) resuelve apps externas registradas por mime/lens.
- Las libs `meta-schema`/`meta-runtime` apuntan a **definir un viewer en JSON** sin código; hoy las consumen otros dominios (nakui), todavía no el shell.
+9 -8
View File
@@ -2,25 +2,26 @@
> `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.
The suite's universal "open-with": a file shell that discerns any file **by content** (`shuma-discern``viewer_registry::pick`) and dispatches it to one of 23 in-process viewers — text, image (pan/zoom, EXIF), video (AV1/WebM/GIF), audio (with live spectrum), card, tree (JSON/TOML), hex, table (CSV/TSV), markdown, `pluma` documents, ODT, decks, map (GeoJSON/GPX/KML, A* routing, PMTiles/MVT basemap), archive (zip/tar), font, PDF, CBZ comics, SVG, JPEG XL, PSD, EPUB, DBF/xBase tables and `.xlsx` sheets — plus a web handoff (HTML launches `puriy`, which also renders inline through `puriy-render`). A thumbnail gallery and a `Source` trait (POSIX · wawa `.img` · nouser · minga) round out the universal front. Built on the same UI framework; preferences shared via `wawa-config`.
## 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
cargo run --release -p nahual-shell-llimphi # shell + the 23 viewers
cargo run --release -p nahual-gallery-llimphi # thumbnail gallery
```
The viewer crates are libraries the shell mounts; only the shell and the gallery are binaries.
## Compatibility
- **Linux / macOS / Windows** — native Llimphi UI.
- **Wawa** — viewers compile inside the kernel; file explorer speaks `wawa-fs`.
- **Wawa** — the shell navigates wawa `.img` images (content-addressed objects) through the `Source` adapter in `nahual-source-core`, host-side over `wawa-explorer-core`.
Crates listed in [README.md](README.md).
Crate table in [LEEME.md](LEEME.md); detection/dispatch design and viewer registry in [ARQUITECTURA.md](ARQUITECTURA.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.
- New viewers register in-process in `viewer_registry`; the open-with seam (`external_handler_for` over `shared/app-bus`) resolves external registered apps by mime/lens.
- The `meta-schema`/`meta-runtime` libs aim at **defining a viewer in JSON** without code; today they're consumed by other domains (nakui), not yet by the shell.
@@ -7,6 +7,5 @@ description = "Yahweh — meta-runtime: helpers puros (parse, delta, validación
[dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
nahual-meta-schema = { path = "../meta-schema" }
+1 -1
View File
@@ -1,4 +1,4 @@
# meta-runtime
# nahual-meta-runtime
> Runtime que monta un viewer desde [`meta-schema`](../meta-schema/README.md) de [nahual](../../README.md).
+1 -1
View File
@@ -1,4 +1,4 @@
# meta-runtime
# nahual-meta-runtime
> Runtime mounting a viewer from [`meta-schema`](../meta-schema/README.md) of [nahual](../../README.md).
@@ -115,13 +115,69 @@ pub trait MetaBackend: 'static {
///
/// `module_id` ubica al módulo (el trait no asume estructura del
/// manifest — el backend lo resuelve internamente).
///
/// Forma general: `inputs` es una lista ORDENADA `(rol, id)` que
/// admite el MISMO rol repetido — para morfismos con inputs
/// **variádicos** (p.ej. un asiento de N patas que liga N cuentas
/// bajo el rol `lineas`). El orden se preserva, así el script puede
/// alinear `ids.<rol>[i]` con un array paralelo de params.
fn morphism_n(
&mut self,
module_id: &str,
name: &str,
inputs: Vec<(String, Uuid)>,
params: Value,
) -> Result<WriteOutcome, String>;
/// Conveniencia para el caso escalar (un id por rol). Delega en
/// [`morphism_n`]. Mantiene compat con los callers que arman un
/// `BTreeMap`.
fn morphism(
&mut self,
module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String>;
) -> Result<WriteOutcome, String> {
self.morphism_n(module_id, name, inputs.into_iter().collect(), params)
}
}
/// Un `Box<dyn MetaBackend>` (o `Box<T>`) es a su vez un `MetaBackend`: reenvía
/// todo al contenido. Permite que el backend de la UI viva tras un objeto-trait
/// —para envolverlo en decoradores (p.ej. la cadena firmada de hampi) sin que
/// el shell conozca el tipo concreto.
impl<T: MetaBackend + ?Sized> MetaBackend for Box<T> {
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> {
(**self).list_records(entity)
}
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value> {
(**self).load_record(entity, id)
}
fn seed(&mut self, entity: &str, data: serde_json::Map<String, Value>) -> Result<WriteOutcome, String> {
(**self).seed(entity, data)
}
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String> {
(**self).update(entity, id, set, clear)
}
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
(**self).delete(entity, id)
}
fn morphism_n(
&mut self,
module_id: &str,
name: &str,
inputs: Vec<(String, Uuid)>,
params: Value,
) -> Result<WriteOutcome, String> {
(**self).morphism_n(module_id, name, inputs, params)
}
}
#[cfg(test)]
+1 -1
View File
@@ -42,5 +42,5 @@ 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 parse::{infer_param_value, parse_array_value, parse_field_value, resolve_param_value};
pub use refs::validate_entity_refs;
@@ -227,6 +227,9 @@ fn filter_passes(v: &Value, f: &CardFilter) -> bool {
match f.op {
FilterOp::Eq => cell.as_deref() == f.value.as_deref(),
FilterOp::Ne => cell.as_deref() != f.value.as_deref(),
FilterOp::In => cell
.map(|c| f.values.iter().any(|x| x == &c))
.unwrap_or(false),
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 {
@@ -612,6 +615,7 @@ mod tests {
value: Some("ganada".into()),
min: None,
max: None,
values: Vec::new(),
};
assert_eq!(
compute_metric(&Metric::Count, Some(&f), &rs),
@@ -639,6 +643,7 @@ mod tests {
value: Some("acme".into()),
min: None,
max: None,
values: Vec::new(),
};
assert_eq!(
compute_metric(&Metric::CountDistinct { field: "cliente".into() }, Some(&f), &rs),
@@ -653,6 +658,7 @@ mod tests {
value: value.map(Into::into),
min: None,
max: None,
values: Vec::new(),
}
}
@@ -680,6 +686,7 @@ mod tests {
value: None,
min: Some("200".into()),
max: Some("800".into()),
values: Vec::new(),
};
assert_eq!(
compute_metric(&Metric::Count, Some(&between), &rs),
@@ -700,6 +707,7 @@ mod tests {
value: None,
min: Some("2026-01-01".into()),
max: Some("2026-12-31".into()),
values: Vec::new(),
};
assert_eq!(
compute_metric(&Metric::Count, Some(&q1_h1), &rs),
@@ -716,6 +724,34 @@ mod tests {
);
}
#[test]
fn in_filter_matches_any_of_values() {
let rs = recs(&[
json!({"tipo": "ingreso", "saldo": -700}),
json!({"tipo": "gasto", "saldo": 120}),
json!({"tipo": "activo", "saldo": 500}),
]);
let f = CardFilter {
field: "tipo".into(),
op: FilterOp::In,
value: None,
min: None,
max: None,
values: vec!["ingreso".into(), "gasto".into()],
};
// Σ saldo de cuentas tipo ∈ {ingreso, gasto} = -700 + 120 = -580.
// (El «resultado neto» = -(-580) = 580 lo da el flag `negate`.)
assert_eq!(
compute_metric(&Metric::Sum { field: "saldo".into() }, Some(&f), &rs),
MetricResult::Scalar(-580.0)
);
// El record de tipo `activo` no entra en el conjunto.
assert_eq!(
compute_metric(&Metric::Count, Some(&f), &rs),
MetricResult::Scalar(2.0)
);
}
#[test]
fn breakdown_csv_roundtrip() {
let res = MetricResult::ValueBreakdown(vec![
+126 -1
View File
@@ -28,7 +28,7 @@ pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
FieldKind::EntityRef => {
let trimmed = raw.trim();
Uuid::parse_str(trimmed)
.map_err(|_| format!("'{raw}' no es UUID válido (usá el selector de records)"))?;
.map_err(|_| format!("'{raw}' no es UUID válido (usa el selector de records)"))?;
Ok(json!(trimmed))
}
FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
@@ -45,9 +45,70 @@ pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
Err(format!("'{raw}' no es número"))
}
}
// Un Array no es un valor escalar: se parsea con `parse_array_value`
// (necesita `item_fields`). Llegar aquí es un misuse del caller.
FieldKind::Array => {
Err("un campo array se parsea con parse_array_value, no como escalar".into())
}
}
}
/// Parsea el texto multilínea de un campo [`FieldKind::Array`] a un
/// `Value::Array` de objetos. Una fila por línea no vacía; las columnas
/// se separan por `delimiter` y se mapean POSICIONALMENTE a `item_fields`.
///
/// Una columna `AutoId` NO consume celda: se le pone un UUID v4 por fila
/// (para los ids de idempotencia de cada record que cree el morfismo). El
/// resto de columnas se parsean con [`parse_field_value`] según su kind.
/// Una celda vacía en columna requerida rebota; en opcional → `Null`.
pub fn parse_array_value(
raw: &str,
item_fields: &[FieldSpec],
delimiter: &str,
) -> Result<Value, String> {
let col_label = |f: &FieldSpec| {
if f.label.is_empty() {
f.name.clone()
} else {
f.label.clone()
}
};
let mut rows: Vec<Value> = Vec::new();
for (i, line) in raw.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let cells: Vec<&str> = line.split(delimiter).map(str::trim).collect();
let mut obj = serde_json::Map::new();
let mut cell_idx = 0usize;
for f in item_fields {
let value = if f.kind == FieldKind::AutoId {
json!(Uuid::new_v4().to_string())
} else {
let cell = cells.get(cell_idx).copied().unwrap_or("");
cell_idx += 1;
if cell.is_empty() {
if f.required {
return Err(format!(
"fila {}: columna '{}' es obligatoria",
i + 1,
col_label(f)
));
}
Value::Null
} else {
parse_field_value(f.kind, cell)
.map_err(|e| format!("fila {}, columna '{}': {e}", i + 1, col_label(f)))?
}
};
obj.insert(f.name.clone(), value);
}
rows.push(Value::Object(obj));
}
Ok(Value::Array(rows))
}
/// 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
@@ -79,6 +140,12 @@ pub fn resolve_param_value(
if raw.is_empty() && !s.required {
return Ok(Value::Null);
}
// Un Array se resuelve con su parser dedicado (necesita item_fields).
if s.kind == FieldKind::Array {
let delim = s.delimiter.as_deref().unwrap_or("|");
return parse_array_value(raw, &s.item_fields, delim)
.map_err(|e| format!("param '{label}': {e}"));
}
parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
}
@@ -123,9 +190,67 @@ mod tests {
ref_entity: None,
options: Vec::new(),
section: None,
item_fields: Vec::new(),
delimiter: None,
}
}
#[test]
fn parse_array_maps_columns_and_autogenerates_ids() {
// item_fields: id (AutoId, no consume celda) + concepto (Text) +
// cantidad (Number) + precio (Number).
let cols = vec![
spec("id", FieldKind::AutoId, false),
spec("concepto", FieldKind::Text, true),
spec("cantidad", FieldKind::Number, true),
spec("precio", FieldKind::Number, true),
];
let raw = "Servicio de diseño | 2 | 500\nHosting anual | 1 | 300\n";
let arr = parse_array_value(raw, &cols, "|").unwrap();
let rows = arr.as_array().unwrap();
assert_eq!(rows.len(), 2, "dos filas no vacías");
let r0 = &rows[0];
assert_eq!(r0.get("concepto").and_then(Value::as_str), Some("Servicio de diseño"));
assert_eq!(r0.get("cantidad").and_then(Value::as_i64), Some(2));
assert_eq!(r0.get("precio").and_then(Value::as_i64), Some(500));
// El id se autogeneró y es un UUID válido (no vino del texto).
let id = r0.get("id").and_then(Value::as_str).unwrap();
assert!(Uuid::parse_str(id).is_ok());
// Cada fila trae un id distinto.
let id1 = rows[1].get("id").and_then(Value::as_str).unwrap();
assert_ne!(id, id1);
}
#[test]
fn parse_array_skips_blank_lines_and_rejects_missing_required() {
let cols = vec![
spec("concepto", FieldKind::Text, true),
spec("monto", FieldKind::Number, true),
];
// Línea en blanco en el medio se ignora.
let ok = parse_array_value("a | 10\n\n \nb | 20", &cols, "|").unwrap();
assert_eq!(ok.as_array().unwrap().len(), 2);
// Falta la columna requerida `monto` → error con número de fila.
let err = parse_array_value("solo concepto", &cols, "|").unwrap_err();
assert!(err.contains("fila 1"), "err: {err}");
assert!(err.contains("monto") || err.contains("obligatoria"), "err: {err}");
}
#[test]
fn resolve_param_array_dispatches_to_array_parser() {
let mut s = spec("lineas", FieldKind::Array, true);
s.item_fields = vec![
spec("concepto", FieldKind::Text, true),
spec("monto", FieldKind::Number, true),
];
let v = resolve_param_value("lineas", "café | 5\nté | 3", Some(&s)).unwrap();
let rows = v.as_array().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].get("monto").and_then(Value::as_i64), Some(5));
}
#[test]
fn infer_handles_basic_types() {
assert_eq!(infer_param_value(""), Value::Null);
@@ -29,7 +29,7 @@ use crate::backend::{MetaBackend, WriteOutcome};
/// 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
/// `"MockBackend no soporta morphism '<name>'"`. Si quieres
/// simular morphisms, registrá callbacks via
/// [`MockBackend::with_morphism`].
/// - `list_records`: orden lexicográfico por id (estable).
@@ -177,13 +177,15 @@ impl MetaBackend for MockBackend {
})
}
fn morphism(
fn morphism_n(
&mut self,
_module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
inputs: Vec<(String, Uuid)>,
params: Value,
) -> Result<WriteOutcome, String> {
// El mock no ejercita variádico: colapsa a mapa para el handler.
let inputs: BTreeMap<String, Uuid> = inputs.into_iter().collect();
match self.morphisms.get(name) {
Some(handler) => {
let changed = handler(&inputs, &params)?;
+1 -1
View File
@@ -1,4 +1,4 @@
# meta-schema
# nahual-meta-schema
> Schema declarativo de viewers de [nahual](../../README.md).
+1 -1
View File
@@ -1,4 +1,4 @@
# meta-schema
# nahual-meta-schema
> Declarative viewer schema for [nahual](../../README.md).
+419 -4
View File
@@ -16,7 +16,7 @@
//! wirea esto a `nakui_core` + KCL post-checks).
//! - **Schema primero, semántica después**: validación semántica
//! (referencias rotas a entities, campos faltantes, etc.) vive
//! en el runtime que lo carga, no a.
//! en el runtime que lo carga, no aquí.
//!
//! ## Anatomía de un módulo
//!
@@ -132,6 +132,17 @@ pub enum View {
/// aristas de flujo de datos (escritura→lectura del mismo token) son
/// los cables. Visualiza la cascada reactiva que conecta el dato.
Graph(GraphView),
/// Cola de trabajo: una lista con **carriles de estado**, **prioridad** y
/// orden explícito — la sala de espera con triaje, la bandeja de recetas,
/// las órdenes pendientes. Lo que `List` no da: las tarjetas se reparten en
/// lanes por un campo de estado y se ordenan por prioridad dentro de cada
/// lane. (Nace para `hampi` §9, sirve a cualquier flujo de trabajo.)
Queue(QueueView),
/// Línea de tiempo: el **feed cronológico** de UN record, unificando varias
/// entities relacionadas (nota, signo, problema, alergia…) en un solo hilo
/// ordenado por fecha, filtrable por tipo. Donde `Dashboard` agrega por
/// período, esto muestra cada evento individual con su detalle. (§9.2.)
Timeline(TimelineView),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -175,6 +186,47 @@ pub struct DetailView {
/// arriba de las listas relacionadas.
#[serde(default)]
pub metrics: Vec<DetailMetric>,
/// Miniseries por-record: gráficas de una magnitud a lo largo del tiempo
/// sobre los records relacionados (§9.2, los signos vitales de la ficha).
/// A diferencia de las cards de un tablero —que agregan una colección
/// entera— cada serie es la evolución de UN campo de UN paciente, ordenada
/// por su fecha. Renderizadas como sparklines arriba de las listas.
#[serde(default)]
pub series: Vec<DetailSeries>,
}
/// Una miniserie temporal scopeada a un record dentro de una [`DetailView`]:
/// grafica `value_field` contra `date_field` sobre los records de `entity`
/// cuyo `via_field` referencia al record actual. El caso canónico son los
/// signos vitales de un paciente (temperatura, PA, peso) a lo largo de sus
/// encuentros. Un `filter` opcional separa una serie por tipo (p.ej.
/// `tipo=temp`) cuando varias magnitudes comparten la misma entity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetailSeries {
pub label: String,
/// Entity de los puntos de la serie (p.ej. `SignoVital`).
pub entity: String,
/// Campo (UUID) que referencia al record actual (mismo scope que
/// [`RelatedList`]/[`DetailMetric`]).
pub via_field: String,
/// Campo numérico graficado en el eje Y.
pub value_field: String,
/// Campo por el que se ordena el eje X (fecha ISO o ts numérico).
pub date_field: String,
/// Filtro adicional (AND con el scope) para aislar una magnitud cuando
/// varias conviven en la misma entity (p.ej. `tipo=temp`).
#[serde(default)]
pub filter: Option<CardFilter>,
/// Forma del minigráfico. `Line` por defecto (una tendencia).
#[serde(default = "default_series_chart")]
pub chart: ChartKind,
/// Unidad mostrada junto al último valor (°C, mmHg, kg…).
#[serde(default)]
pub unit: Option<String>,
}
fn default_series_chart() -> ChartKind {
ChartKind::Line
}
/// Un KPI scopeado a un record dentro de una [`DetailView`]: computa
@@ -234,6 +286,16 @@ pub struct ReportView {
/// —recortando el reporte sin tocar el `module.json`—.
#[serde(default)]
pub toggles: Vec<ReportToggle>,
/// **Reporte tipado**: cuando está set, el runtime NO computa las `cards`
/// genéricas (sum/filter) sino que delega el cómputo a una función Rust
/// tipada del dominio, identificada por esta clave. El caso canónico son
/// los estados contables de nakui (`libro_diario`, `libro_mayor`,
/// `balanza`, `resultados`, `balance_general`), que reconstruyen filas y
/// totales exactos en centavos desde el diario en lugar de agregados
/// meta-driven sobre `Cuenta`. Agnóstico del dominio: meta-schema sólo
/// guarda el nombre; el runtime que lo entiende decide qué pinta.
#[serde(default)]
pub typed: Option<String>,
}
/// Un control de filtro interactivo de un [`ReportView`].
@@ -253,7 +315,7 @@ pub struct ReportToggle {
/// Vista grafo: el DAG de morfismos del módulo nakui. No tiene
/// parámetros más allá del título y un subtítulo opcional — el grafo se
/// deriva en runtime del manifest del `Executor` del módulo (los
/// morfismos y los tokens que lee/escribe cada uno), no se declara a.
/// morfismos y los tokens que lee/escribe cada uno), no se declara aquí.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphView {
pub title: String,
@@ -262,6 +324,96 @@ pub struct GraphView {
pub subtitle: Option<String>,
}
/// Cola de trabajo con carriles de estado + prioridad. Cada record de `entity`
/// cae en el carril que dicta su `lane_field`, y dentro del carril las tarjetas
/// se ordenan por `priority_field` (mayor primero) y luego por `sort_field`
/// (FIFO por llegada). Es la sala de espera con triaje, la bandeja de recetas o
/// la de órdenes (§9.1/§9.4/§9.5).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueView {
pub title: String,
/// Entity cuyas instancias se encolan.
pub entity: String,
/// Campo cuyo valor decide el carril de cada tarjeta (p.ej. `estado`).
pub lane_field: String,
/// Los carriles, en orden de presentación. Un valor de `lane_field` no
/// declarado aquí cae en un carril "otros" al final. Vacío = un solo carril.
#[serde(default)]
pub lanes: Vec<QueueLane>,
/// Campo numérico de prioridad (triaje): mayor valor, más arriba dentro del
/// carril. `None` = sin prioridad, sólo `sort_field`.
#[serde(default)]
pub priority_field: Option<String>,
/// Campo de desempate/orden secundario (p.ej. `ts` de llegada, ascendente:
/// el que esperó más va primero). `None` = orden de inserción.
#[serde(default)]
pub sort_field: Option<String>,
/// Campos resumidos en cada tarjeta.
pub columns: Vec<Column>,
/// Acciones a nivel de la cola (header): "Nuevo turno", etc.
#[serde(default)]
pub actions: Vec<Action>,
/// Acciones por tarjeta (avanzar de carril, atender…). Renderizadas en cada
/// ítem; típicamente `Action::Morphism` que muta el `lane_field`.
#[serde(default)]
pub card_actions: Vec<Action>,
/// Si está set, la tarjeta abre esta vista `Detail` al clickearla.
#[serde(default)]
pub row_detail: Option<String>,
}
/// Un carril de una [`QueueView`]: un valor de `lane_field` con su rótulo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueLane {
/// Valor crudo de `lane_field` que agrupa las tarjetas de este carril.
pub value: String,
/// Rótulo del carril (encabezado de la columna).
pub label: String,
/// Color/acento opcional (nombre semántico que resuelve el runtime).
#[serde(default)]
pub accent: Option<String>,
}
/// Línea de tiempo clínica: el feed cronológico de UN record ancla (p.ej. un
/// paciente), unificando varias entities relacionadas en un solo hilo ordenado
/// por fecha. Cada [`TimelineSource`] aporta un tipo de evento; la UI ofrece un
/// chip de filtro por fuente. El record ancla lo fija el runtime (viene de la
/// fila/ficha desde la que se abre el timeline).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineView {
pub title: String,
/// Entity ancla del hilo (el sujeto del que es la línea de tiempo).
pub entity: String,
/// Las fuentes de eventos que se intercalan cronológicamente.
pub sources: Vec<TimelineSource>,
}
/// Una fuente de eventos de un [`TimelineView`]: los records de `entity` cuyo
/// `via_field` referencia al record ancla, volcados al hilo y ordenados por
/// `date_field`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineSource {
/// Rótulo del tipo de evento; también el texto del chip de filtro
/// (p.ej. "Nota", "Signo", "Problema").
pub label: String,
/// Entity de los eventos de esta fuente.
pub entity: String,
/// Campo (UUID) que referencia al record ancla.
pub via_field: String,
/// Campo de fecha/tiempo por el que se ordena el hilo (ISO o ts numérico).
pub date_field: String,
/// Campos resumidos en la tarjeta del evento.
pub columns: Vec<Column>,
/// Campo que, presente y no vacío, marca el ítem como reemplazado por una
/// enmienda (p.ej. `supersedes`): la UI lo pinta tenue/tachado sin
/// ocultarlo (invariantes 1, 5). `None` = la fuente no se enmienda.
#[serde(default)]
pub superseded_field: Option<String>,
/// Si está set, el ítem abre esta vista `Detail` al clickearlo.
#[serde(default)]
pub row_detail: Option<String>,
}
/// Una tarjeta de KPI del tablero.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardCard {
@@ -317,6 +469,13 @@ pub struct DashboardCard {
/// período (default).
#[serde(default)]
pub cumulative: bool,
/// Invierte el signo del resultado ESCALAR (`Sum`/`Avg`/`Min`/`Max`)
/// al presentarlo. Útil en contabilidad deudor-normal: un pasivo,
/// patrimonio o ingreso tienen saldo negativo, y `negate` los muestra
/// con su signo natural (positivo) en un balance o estado de
/// resultados. Ignorado por desgloses. `false` = sin invertir.
#[serde(default)]
pub negate: bool,
}
/// Granularidad de truncado de una fecha ISO-8601 para series temporales.
@@ -418,6 +577,10 @@ pub enum FilterOp {
Between,
/// El campo existe y no está vacío.
NonEmpty,
/// El campo iguala (textualmente) a ALGUNO de `CardFilter.values`.
/// Para agregar sobre varias categorías a la vez — p.ej. el resultado
/// del ejercicio = saldo de las cuentas con tipo ∈ {ingreso, gasto}.
In,
}
/// Filtro de una [`DashboardCard`]: decide qué records entran al
@@ -442,6 +605,10 @@ pub struct CardFilter {
/// Cota superior para `between` (inclusiva). `None` = sin techo.
#[serde(default)]
pub max: Option<String>,
/// Conjunto de valores para el operador `in`: el record pasa si el
/// campo iguala a alguno. Ignorado por los demás operadores.
#[serde(default)]
pub values: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -525,6 +692,15 @@ pub struct FieldSpec {
/// consecutivos con la misma sección se agrupan bajo un encabezado.
#[serde(default)]
pub section: Option<String>,
/// Columnas de un campo `kind == Array`: el orden y tipo de cada
/// celda de una fila. Ignorado para los demás kinds. `Module::validate`
/// exige que un Array las tenga. Una columna `AutoId` la rellena el
/// runtime (UUID por fila); el resto se teclean en orden.
#[serde(default)]
pub item_fields: Vec<FieldSpec>,
/// Delimitador de columnas de un campo `Array`. Default `"|"`.
#[serde(default)]
pub delimiter: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -553,6 +729,15 @@ pub enum FieldKind {
/// abrir el formulario; el usuario no lo teclea ni lo edita. Para
/// los ids de idempotencia que piden los morfismos.
AutoId,
/// Lista de records repetidos (líneas de factura, patas de asiento…).
/// MVP "feo pero sirve": se edita como texto multilínea, una fila por
/// línea, columnas separadas por el delimitador (`|` por defecto). El
/// runtime parsea cada fila a un objeto cuyas claves/tipos vienen de
/// `FieldSpec.item_fields`, mapeadas POSICIONALMENTE; una columna
/// `AutoId` no se teclea: el runtime le pone un UUID por fila (para
/// los ids de idempotencia que pide cada record creado). El value del
/// param es un `Value::Array` de esos objetos.
Array,
}
/// Una opción de un campo [`FieldKind::Select`].
@@ -610,6 +795,13 @@ pub enum Action {
/// morphism `vender` que toma roles `stock` y `caja`.
#[serde(default)]
inputs: BTreeMap<String, String>,
/// Inputs VARIÁDICOS alimentados desde una columna de un campo
/// `Array`: por cada rol, de qué campo-array y columna sale el
/// valor de cada fila, que se liga como un input (el mismo rol
/// repetido N veces, en orden de fila). Para asientos de N patas
/// y similares. Ver [`ArrayInputBind`].
#[serde(default)]
array_inputs: BTreeMap<String, ArrayInputBind>,
/// Lista de fields del form cuyos values van al `params`
/// JSON object pasado al morphism. Si está vacío, todos los
/// fields que no estén en `inputs` van a params.
@@ -620,6 +812,25 @@ pub enum Action {
},
}
/// Cómo un input variádico de un morfismo se alimenta desde una columna
/// de un campo `Array` del form (ver [`Action::Morphism::array_inputs`]).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArrayInputBind {
/// Nombre del campo `Array` del form del que salen las filas.
pub field: String,
/// Columna (nombre de `item_field`) cuyo valor se liga por fila.
pub column: String,
/// Si está, el valor de la celda NO es un UUID directo: se resuelve
/// buscando el record de esta entity cuyo `lookup_field` lo iguala
/// (p.ej. el código de cuenta `"1010"` → el id de esa Cuenta). Hace
/// usable el textarea: el usuario tipea un código legible, no un UUID.
#[serde(default)]
pub lookup_entity: Option<String>,
/// Campo por el que se resuelve cuando hay `lookup_entity`.
#[serde(default)]
pub lookup_field: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntitySpec {
/// Nombre de la entity (clave en el store). Único dentro del módulo.
@@ -676,6 +887,15 @@ pub enum SchemaError {
view: String,
field: String,
},
#[error(
"módulo {id} vista '{view}': field '{field}' tiene kind=array \
pero no declaró item_fields (columnas)"
)]
ArrayMissingItemFields {
id: String,
view: String,
field: String,
},
#[error(
"módulo {id} vista '{view}': row_detail='{target}' no apunta a \
una vista kind=detail"
@@ -690,7 +910,7 @@ pub enum SchemaError {
impl Module {
/// Carga un module.json desde disco. Validación estructural
/// posterior (vistas referenciadas existen, etc.) la ejecuta el
/// runtime — a sólo parseamos.
/// runtime — aquí sólo parseamos.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
@@ -735,11 +955,18 @@ impl Module {
field: f.name.clone(),
});
}
if f.kind == FieldKind::Array && f.item_fields.is_empty() {
return Err(SchemaError::ArrayMissingItemFields {
id: self.id.clone(),
view: view_key.clone(),
field: f.name.clone(),
});
}
}
}
View::List(list) => {
if let Some(target) = &list.row_detail {
if !matches!(self.views.get(target), Some(View::Detail(_))) {
if !self.is_record_anchored_view(target) {
return Err(SchemaError::RowDetailInvalid {
id: self.id.clone(),
view: view_key.clone(),
@@ -748,11 +975,48 @@ impl Module {
}
}
}
View::Queue(q) => {
// La cola apunta a una vista anclada a un record (una
// `Detail` o un `Timeline`): el 👁 de la tarjeta la abre
// con el id de esa fila.
if let Some(target) = &q.row_detail {
if !self.is_record_anchored_view(target) {
return Err(SchemaError::RowDetailInvalid {
id: self.id.clone(),
view: view_key.clone(),
target: target.clone(),
});
}
}
}
View::Timeline(t) => {
for src in &t.sources {
if let Some(target) = &src.row_detail {
if !self.is_record_anchored_view(target) {
return Err(SchemaError::RowDetailInvalid {
id: self.id.clone(),
view: view_key.clone(),
target: target.clone(),
});
}
}
}
}
View::Detail(_) | View::Dashboard(_) | View::Report(_) | View::Graph(_) => {}
}
}
Ok(())
}
/// `true` si `key` nombra una vista **anclada a un solo record** —una
/// `Detail` (ficha) o un `Timeline` (línea de tiempo)—: las dos que el
/// runtime abre con el id de una fila (destino válido de un `row_detail`).
fn is_record_anchored_view(&self, key: &str) -> bool {
matches!(
self.views.get(key),
Some(View::Detail(_)) | Some(View::Timeline(_))
)
}
}
/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
@@ -820,6 +1084,8 @@ mod tests {
ref_entity: None,
options: Vec::new(),
section: None,
item_fields: Vec::new(),
delimiter: None,
},
FieldSpec {
name: "email".into(),
@@ -831,6 +1097,8 @@ mod tests {
ref_entity: None,
options: Vec::new(),
section: None,
item_fields: Vec::new(),
delimiter: None,
},
],
}],
@@ -891,6 +1159,8 @@ mod tests {
ref_entity: None,
options: Vec::new(),
section: None,
item_fields: Vec::new(),
delimiter: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
@@ -985,6 +1255,8 @@ mod tests {
ref_entity: None,
options: Vec::new(),
section: None,
item_fields: Vec::new(),
delimiter: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
@@ -1017,6 +1289,8 @@ mod tests {
ref_entity: Some("supplier".into()),
options: Vec::new(),
section: None,
item_fields: Vec::new(),
delimiter: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
@@ -1050,6 +1324,8 @@ mod tests {
ref_entity: None,
options: Vec::new(),
section: None,
item_fields: Vec::new(),
delimiter: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
@@ -1091,6 +1367,8 @@ mod tests {
},
],
section: None,
item_fields: Vec::new(),
delimiter: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
@@ -1191,4 +1469,141 @@ mod tests {
let err = load_modules_from_dir(tmp.path()).unwrap_err();
assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
}
// --- las tres vistas nuevas (queue / timeline / series en detail) --------
#[test]
fn queue_view_parses_lanes_priority_and_defaults() {
let q: View = serde_json::from_value(serde_json::json!({
"kind": "queue",
"title": "Sala de espera",
"entity": "Turno",
"lane_field": "estado",
"priority_field": "triaje",
"sort_field": "ts",
"lanes": [
{ "value": "en_espera", "label": "En espera", "accent": "warn" },
{ "value": "en_atencion", "label": "En atención" }
],
"columns": [ { "field": "paciente", "label": "Paciente" } ]
}))
.unwrap();
let View::Queue(q) = q else { panic!("esperaba queue") };
assert_eq!(q.lane_field, "estado");
assert_eq!(q.priority_field.as_deref(), Some("triaje"));
assert_eq!(q.lanes.len(), 2);
assert_eq!(q.lanes[0].accent.as_deref(), Some("warn"));
assert!(q.lanes[1].accent.is_none());
// Sin declarar: card_actions/actions/row_detail vienen vacíos.
assert!(q.card_actions.is_empty());
assert!(q.row_detail.is_none());
}
#[test]
fn timeline_view_parses_sources_and_supersede_marker() {
let t: View = serde_json::from_value(serde_json::json!({
"kind": "timeline",
"title": "Historia",
"entity": "Paciente",
"sources": [
{
"label": "Nota",
"entity": "NotaClinica",
"via_field": "paciente",
"date_field": "ts",
"columns": [ { "field": "analisis", "label": "Dx" } ],
"superseded_field": "supersedes",
"row_detail": "ficha_nota"
},
{
"label": "Signo",
"entity": "SignoVital",
"via_field": "paciente",
"date_field": "ts",
"columns": [ { "field": "valor", "label": "Valor" } ]
}
]
}))
.unwrap();
let View::Timeline(t) = t else { panic!("esperaba timeline") };
assert_eq!(t.sources.len(), 2);
assert_eq!(t.sources[0].superseded_field.as_deref(), Some("supersedes"));
assert_eq!(t.sources[0].row_detail.as_deref(), Some("ficha_nota"));
assert!(t.sources[1].superseded_field.is_none());
}
#[test]
fn detail_series_defaults_to_line() {
let s: DetailSeries = serde_json::from_value(serde_json::json!({
"label": "Temperatura",
"entity": "SignoVital",
"via_field": "paciente",
"value_field": "valor",
"date_field": "ts",
"filter": { "field": "tipo", "value": "temp" },
"unit": "°C"
}))
.unwrap();
assert_eq!(s.chart, ChartKind::Line);
assert_eq!(s.unit.as_deref(), Some("°C"));
assert!(s.filter.is_some());
// Detail sin `series` (back-compat) deserializa con lista vacía.
let d: DetailView = serde_json::from_value(serde_json::json!({
"title": "Ficha", "entity": "Paciente"
}))
.unwrap();
assert!(d.series.is_empty());
}
#[test]
fn queue_row_detail_must_point_to_a_detail_view() {
let mut m = sample_module();
m.views.insert(
"cola".into(),
View::Queue(QueueView {
title: "Cola".into(),
entity: "customer".into(),
lane_field: "estado".into(),
lanes: Vec::new(),
priority_field: None,
sort_field: None,
columns: vec![Column {
field: "name".into(),
label: "Nombre".into(),
weight: 1.0,
ref_entity: None,
format: ValueFormat::Plain,
}],
actions: Vec::new(),
card_actions: Vec::new(),
// Apunta a "form" (que es un Form, no un Detail): debe rebotar.
row_detail: Some("form".into()),
}),
);
let err = m.validate().unwrap_err();
assert!(matches!(err, SchemaError::RowDetailInvalid { .. }), "got: {err:?}");
}
#[test]
fn timeline_source_row_detail_must_point_to_a_detail_view() {
let mut m = sample_module();
m.views.insert(
"hilo".into(),
View::Timeline(TimelineView {
title: "Hilo".into(),
entity: "customer".into(),
sources: vec![TimelineSource {
label: "Evento".into(),
entity: "customer".into(),
via_field: "cliente".into(),
date_field: "ts".into(),
columns: Vec::new(),
superseded_field: None,
row_detail: Some("list".into()), // List, no Detail → error.
}],
}),
);
let err = m.validate().unwrap_err();
assert!(matches!(err, SchemaError::RowDetailInvalid { .. }), "got: {err:?}");
}
}
@@ -25,8 +25,12 @@ fn loads_demo_modules() {
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'"
vec![
"compra_ve_libros", "compras", "contabilidad", "coop_ve", "crm", "divisas",
"facturacion", "inventario", "pedidos", "pos_ve_caja", "proyectos", "punto_venta",
"terceros", "tesoro", "venta_ve_libros", "ventas"
],
"se esperaban los módulos demo del shell en orden alfabético"
);
}
@@ -40,7 +44,12 @@ fn every_demo_module_has_list_and_form_views() {
match v {
View::List(_) => has_list = true,
View::Form(_) => has_form = true,
View::Detail(_) | View::Dashboard(_) | View::Report(_) | View::Graph(_) => {}
View::Detail(_)
| View::Dashboard(_)
| View::Report(_)
| View::Graph(_)
| View::Queue(_)
| View::Timeline(_) => {}
}
}
assert!(
@@ -9,6 +9,7 @@ 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]
rimay-localize = { workspace = true }
nahual-viewer-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
@@ -0,0 +1,34 @@
# nahual-archive-viewer-llimphi
*Read this in English: [README.md](README.md).*
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 ZIP, tar, los tres compresores de stream (gz/xz/zst) y los
contenedores 7z y RAR, 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.
- **gz/xz/zst** (`1f 8b` / `FD 37 7A 58 5A 00` / `28 B5 2F FD`):
descomprime en streaming (`flate2`/`xz2`/`zstd`) y mira el contenido.
Si adentro hay un tar lo lista como `.tar.gz`/`.tar.xz`/`.tar.zst`
(saltando los datos, sin cargar todo en memoria); si no, es un archivo
comprimido **suelto** (`logs.txt.gz`) y muestra su tamaño real.
- **7z** (`37 7A BC AF 27 1C`) y **RAR** (`Rar!\x1A\x07`): contenedores
con índice propio; se listan sin extraer (`sevenz-rust` / `unrar`).
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).
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,33 @@
# nahual-archive-viewer-llimphi
Compressed archive viewer.
The tenth viewer of the meta-app shell. `shuma-discern` detects a
`.zip`/`.tar`/`.tar.gz` by its magic, but until now they fell to the **hex
viewer** (or to text) — unreadable bytes. A compressed archive is a *container*:
what is useful is seeing what is **inside**, not its entropy. This viewer lists
each entry with its size (and, for ZIP, its ratio).
It supports ZIP, tar, the three stream compressors (gz/xz/zst) and the 7z and RAR
containers, decided by **content** (not by extension):
- **ZIP** (`PK`): reads the central directory with `by_index_raw`, without
decompressing. It covers the whole family — `.jar`/`.apk`/`.epub` and the OOXML
office formats (`.docx`/`.xlsx`/`.pptx`) are ZIPs.
- **tar** (`ustar` at offset 257): walks the headers in streaming.
- **gz/xz/zst** (`1f 8b` / `FD 37 7A 58 5A 00` / `28 B5 2F FD`): decompresses in
streaming (`flate2`/`xz2`/`zstd`) and looks at the content. If there is a tar
inside it lists it as `.tar.gz`/`.tar.xz`/`.tar.zst` (skipping the data,
without loading everything into memory); otherwise it is a **standalone**
compressed file (`logs.txt.gz`) and its real size is shown.
- **7z** (`37 7A BC AF 27 1C`) and **RAR** (`Rar!\x1A\x07`): containers with
their own index; listed without extracting (`sevenz-rust` / `unrar`).
The thin pattern of the other viewers: sync loading in `load_archive`, rendering
in `archive_viewer_view`. It knows nothing about the AppBus: the caller passes the
path. Ugly-first MVP: the list is a static block of monospaced text (no
click-to-extract yet).
---
Part of **nahual** — see [nahual](../README.md).
@@ -6,14 +6,19 @@
//! *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):
//! Soporta ZIP, tar, los tres compresores de stream (gz/xz/zst) y los
//! contenedores 7z y RAR, 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.
//! - **gz/xz/zst** (`1f 8b` / `FD 37 7A 58 5A 00` / `28 B5 2F FD`):
//! descomprime en streaming (`flate2`/`xz2`/`zstd`) y mira el contenido.
//! Si adentro hay un tar lo lista como `.tar.gz`/`.tar.xz`/`.tar.zst`
//! (saltando los datos, sin cargar todo en memoria); si no, es un archivo
//! comprimido **suelto** (`logs.txt.gz`) y muestra su tamaño real.
//! - **7z** (`37 7A BC AF 27 1C`) y **RAR** (`Rar!\x1A\x07`): contenedores
//! con índice propio; se listan sin extraer (`sevenz-rust` / `unrar`).
//!
//! Patrón fino de los otros viewers: carga sync en [`load_archive`],
//! render en [`archive_viewer_view`]. No conoce el AppBus: el caller
@@ -78,7 +83,7 @@ where
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
None => "(seleccioná un ZIP/tar/tar.gz)".to_string(),
None => rimay_localize::t("nahual-archive-select"),
};
let header = View::new(Style {
@@ -100,7 +105,7 @@ where
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),
ArchivePreview::Error(e) => (rimay_localize::t_args("nahual-archive-error", &[("err", e.to_string().into())]), palette.fg_error),
};
let body = View::new(Style {
@@ -9,6 +9,7 @@ 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]
rimay-localize = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
media-core = { workspace = true }
@@ -0,0 +1,32 @@
# nahual-audio-viewer-llimphi
*Read this in English: [README.md](README.md).*
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`.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,16 @@
# nahual-audio-viewer-llimphi
Audio player/viewer.
The fifth viewer of the meta-app shell (after text/image/video/card). It opens an
audio file (WAV/MP3/FLAC/Opus/Vorbis through `media-source-*`), plays it through
the cpal sink (`media-audio-cpal`) and paints a **live spectrum** — log-spaced
bands computed with `media-core`'s `Spectrum` (Goertzel) over the samples an
`AudioProbe` taps from the realtime stream.
The `AudioSink` wraps a `cpal::Stream`, which is `!Send`/`!Sync`, so the stream is
kept alive off the UI thread and only the probe's snapshot crosses over.
---
Part of **nahual** — see [nahual](../README.md).
@@ -280,7 +280,7 @@ where
Msg: Clone + 'static,
{
let name = if state.name.is_empty() {
"(seleccioná un audio)".to_string()
rimay_localize::t("nahual-audio-select")
} else {
state.name.clone()
};
@@ -324,7 +324,7 @@ where
.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),
(Some(e), _) => placeholder_body(&rimay_localize::t_args("nahual-audio-error", &[("err", e.to_string().into())]), palette.fg_error),
(None, true) => spectrum_body(state.magnitudes().to_vec(), palette),
(None, false) => placeholder_body("", palette.fg_muted),
};
@@ -9,6 +9,7 @@ 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]
rimay-localize = { workspace = true }
nahual-viewer-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
@@ -0,0 +1,25 @@
# nahual-card-viewer-llimphi
*Read this in English: [README.md](README.md).*
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.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,17 @@
# nahual-card-viewer-llimphi
Structured Card viewer.
The fourth viewer of the meta-app shell (after text/image/video). A Card
(`shared/card`) is JSON, so the text viewer would open it as such; but the `card`
lens `shuma-discern` produces over its content deserves a viewer that
**presents** it — not the raw blob. This crate reads the Card, extracts the
salient fields (identity, nature, payload, supervision, capabilities,
permissions, references) and paints them as legible rows.
It follows the thin pattern of the other viewers: loading lives in `load_card`
(sync — a Card is small), rendering in the view function.
---
Part of **nahual** — see [nahual](../README.md).
@@ -85,7 +85,7 @@ where
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
None => "(seleccioná una card)".to_string(),
None => rimay_localize::t("nahual-card-select"),
},
};
@@ -108,7 +108,7 @@ where
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),
CardPreview::Error(e) => (rimay_localize::t_args("nahual-card-invalid", &[("err", e.to_string().into())]), palette.fg_error),
};
let body = View::new(Style {
@@ -0,0 +1,20 @@
[package]
name = "nahual-cbz-viewer-llimphi"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-cbz-viewer-llimphi — visor de cómic (.cbz) sobre Llimphi. Un CBZ es un zip de imágenes; el visor lista las páginas ordenadas, decodifica la visible con `llimphi-image` y navega con . Espejo del visor de PDF."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-widget-empty = { workspace = true }
llimphi-image = { workspace = true }
zip = { workspace = true, features = ["deflate"] }
rimay-localize = { workspace = true }
[dev-dependencies]
image = { workspace = true }
@@ -0,0 +1,25 @@
# nahual-cbz-viewer-llimphi
*Read this in English: [README.md](README.md).*
Visor de cómic (`.cbz`) sobre Llimphi.
Miembro de la familia de visores de nahual (uno por naturaleza de dato).
Un CBZ es un **zip de imágenes**: cada entrada es una página (`001.jpg`,
`002.png`…). Este crate abre el zip, ordena las páginas por nombre
(comparación *natural*: `2` antes que `10`), y mantiene la página visible
como `peniko::Image` (vía `llimphi_image::decode_bytes`), navegando con
. Espejo estructural del visor de PDF (`nahual-pdf-viewer-llimphi`).
**Decode lazy con caché**: al abrir se decodifica sólo la página 0; cada
salto decodifica la nueva (si no está cacheada) en el `update` del caller —
nunca en `view`, que es puro (`&state`). El `ZipArchive` se mantiene vivo
dentro del estado (`PreviewPane` del shell no es `Clone`, así que no hace
falta que este estado lo sea).
La carga es **sync**, como el resto de la familia; para CBZ pesados conviene
envolver `load_cbz` en `Handle::spawn` y reentrar con un Msg.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,18 @@
# nahual-cbz-viewer-llimphi
Comic (`.cbz`) viewer over Llimphi.
A member of nahual's viewer family (one per nature of data). A CBZ is a **zip of
images**: each entry is a page (`001.jpg`, `002.png`…). This crate opens the zip,
sorts the pages by name (*natural* comparison: `2` before `10`), and keeps the
visible page as a `peniko::Image` (through `llimphi_image::decode_bytes`),
navigating with . Structurally a mirror of the PDF viewer
(`nahual-pdf-viewer-llimphi`).
**Lazy decode with a cache**: on open only page 0 is decoded; each jump decodes
the new one (if not cached) in the caller's `update` — never in `view`, which is
pure (`&state`). The `ZipArchive` is kept alive.
---
Part of **nahual** — see [nahual](../README.md).
@@ -0,0 +1,607 @@
//! `nahual-cbz-viewer-llimphi` — visor de cómic (`.cbz`) sobre Llimphi.
//!
//! Miembro de la familia de visores de nahual (uno por naturaleza de dato).
//! Un CBZ es un **zip de imágenes**: cada entrada es una página (`001.jpg`,
//! `002.png`…). Este crate abre el zip, ordena las páginas por nombre
//! (comparación *natural*: `2` antes que `10`), y mantiene la página visible
//! como `peniko::Image` (vía [`llimphi_image::decode_bytes`]), navegando con
//! . Espejo estructural del visor de PDF ([`nahual-pdf-viewer-llimphi`]).
//!
//! **Decode lazy con caché**: al abrir se decodifica sólo la página 0; cada
//! salto decodifica la nueva (si no está cacheada) en el `update` del caller —
//! nunca en `view`, que es puro (`&state`). El `ZipArchive` se mantiene vivo
//! dentro del estado (`PreviewPane` del shell no es `Clone`, así que no hace
//! falta que este estado lo sea).
//!
//! La carga es **sync**, como el resto de la familia; para CBZ pesados conviene
//! envolver [`load_cbz`] en `Handle::spawn` y reentrar con un Msg.
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::path::Path;
use llimphi_image::{decode_bytes, Image};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::color::AlphaColor;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_icons::Icon;
use llimphi_theme::{alpha, motion};
use llimphi_widget_empty::{empty_view, EmptyPalette};
/// Tope por defecto de bytes del `.cbz` en disco (256 MB). Los cómics con
/// muchas páginas en alta resolución pesan; aplica al archivo, no a los bitmaps
/// RGBA8 decodificados (que se generan de a una página, con caché).
pub const DEFAULT_CBZ_BYTES_MAX: u64 = 256 * 1024 * 1024;
/// Extensiones de imagen que sabemos decodificar (features del crate `image`
/// habilitadas en el workspace). Una entrada del zip con otra extensión
/// (metadata `.xml`, `.txt`…) no es página y se ignora.
const EXTS_IMAGEN: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "tga", "qoi", "ico",
];
/// Estado del preview de CBZ, propiedad del caller.
pub enum CbzPreviewState {
Empty,
Loaded(CbzDoc),
TooBig(u64),
Error(String),
}
impl Default for CbzPreviewState {
fn default() -> Self {
CbzPreviewState::Empty
}
}
/// Cómic abierto: el `ZipArchive` vivo + la lista ordenada de páginas (nombres
/// de entrada) + la página visible + una caché de páginas ya decodificadas
/// (índice 0-based → bitmap).
pub struct CbzDoc {
archive: zip::ZipArchive<Cursor<Vec<u8>>>,
paginas: Vec<String>,
actual: usize,
cache: HashMap<usize, Image>,
}
impl CbzDoc {
/// Página visible (0-based).
pub fn actual(&self) -> usize {
self.actual
}
/// Cantidad total de páginas (entradas de imagen).
pub fn paginas(&self) -> usize {
self.paginas.len()
}
/// Decodifica `self.actual` a la caché si aún no está. Un fallo de decode
/// (entrada corrupta o formato no soportado) deja la página sin entrada: el
/// `view` pinta el placeholder.
fn asegurar_actual(&mut self) {
if self.cache.contains_key(&self.actual) {
return;
}
let Some(nombre) = self.paginas.get(self.actual).cloned() else {
return;
};
let mut buf = Vec::new();
let leido = self
.archive
.by_name(&nombre)
.and_then(|mut f| f.read_to_end(&mut buf).map_err(zip::result::ZipError::Io));
if leido.is_err() {
return;
}
if let Ok(img) = decode_bytes(&buf) {
self.cache.insert(self.actual, img);
}
}
/// Avanza a la página siguiente (decodificándola). `true` si cambió.
pub fn siguiente(&mut self) -> bool {
if self.actual + 1 < self.paginas.len() {
self.actual += 1;
self.asegurar_actual();
true
} else {
false
}
}
/// Retrocede una página. `true` si cambió.
pub fn anterior(&mut self) -> bool {
if self.actual > 0 {
self.actual -= 1;
self.asegurar_actual();
true
} else {
false
}
}
/// Salta a la página `idx` (clampeada al rango). `true` si cambió.
pub fn ir_a(&mut self, idx: usize) -> bool {
let idx = idx.min(self.paginas.len().saturating_sub(1));
if idx != self.actual {
self.actual = idx;
self.asegurar_actual();
true
} else {
false
}
}
}
impl CbzPreviewState {
/// Avanza de página si es un cómic cargado. `true` si algo cambió (el caller
/// repinta). No-op en los demás estados.
pub fn siguiente(&mut self) -> bool {
if let CbzPreviewState::Loaded(d) = self {
d.siguiente()
} else {
false
}
}
/// Retrocede una página si es un cómic cargado. `true` si cambió.
pub fn anterior(&mut self) -> bool {
if let CbzPreviewState::Loaded(d) = self {
d.anterior()
} else {
false
}
}
/// Página visible (0-based) si hay cómic cargado.
pub fn pagina_actual(&self) -> Option<usize> {
match self {
CbzPreviewState::Loaded(d) => Some(d.actual()),
_ => None,
}
}
/// Total de páginas si hay cómic cargado.
pub fn total_paginas(&self) -> Option<usize> {
match self {
CbzPreviewState::Loaded(d) => Some(d.paginas()),
_ => None,
}
}
}
/// Abre un `.cbz` y decodifica la primera página. Sync. Respeta el cap de bytes
/// del archivo; si no se puede abrir, no tiene páginas de imagen, o el zip está
/// corrupto, devuelve un estado de error.
pub fn load_cbz(path: &Path, max_bytes: u64) -> CbzPreviewState {
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > max_bytes {
return CbzPreviewState::TooBig(meta.len());
}
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return CbzPreviewState::Error(e.to_string()),
};
cargar_de_bytes(bytes)
}
/// Núcleo testeable de [`load_cbz`]: arma el `CbzDoc` a partir de los bytes del
/// zip ya en memoria. Ordena las páginas naturalmente y decodifica la 0.
pub fn cargar_de_bytes(bytes: Vec<u8>) -> CbzPreviewState {
let archive = match zip::ZipArchive::new(Cursor::new(bytes)) {
Ok(a) => a,
Err(e) => return CbzPreviewState::Error(format!("CBZ: {e}")),
};
let mut paginas: Vec<String> = archive
.file_names()
.filter(|n| es_pagina(n))
.map(|n| n.to_string())
.collect();
if paginas.is_empty() {
return CbzPreviewState::Error("CBZ sin páginas de imagen".into());
}
paginas.sort_by(|a, b| clave_natural(a).cmp(&clave_natural(b)));
let mut doc = CbzDoc {
archive,
paginas,
actual: 0,
cache: HashMap::new(),
};
doc.asegurar_actual();
CbzPreviewState::Loaded(doc)
}
/// ¿La entrada del zip es una página de imagen? Descarta directorios, basura de
/// macOS (`__MACOSX/…`, `.DS_Store`), archivos ocultos y extensiones que no
/// sabemos decodificar.
fn es_pagina(nombre: &str) -> bool {
if nombre.ends_with('/') || nombre.contains("__MACOSX") {
return false;
}
let base = nombre.rsplit('/').next().unwrap_or(nombre);
if base.starts_with('.') {
return false;
}
let ext = base.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
EXTS_IMAGEN.contains(&ext.as_str())
}
/// Clave de orden *natural*: reconstruye el nombre pero rellena cada corrida de
/// dígitos a 20 caracteres, de modo que el orden lexicográfico resultante trate
/// los números como números (`2` < `10`). Case-insensitive en el resto.
fn clave_natural(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 16);
let mut digitos = String::new();
for c in s.chars() {
if c.is_ascii_digit() {
digitos.push(c);
} else {
if !digitos.is_empty() {
out.push_str(&format!("{:0>20}", digitos));
digitos.clear();
}
out.extend(c.to_lowercase());
}
}
if !digitos.is_empty() {
out.push_str(&format!("{:0>20}", digitos));
}
out
}
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct CbzViewerPalette {
pub bg: Color,
pub fg: Color,
pub fg_muted: Color,
pub fg_error: Color,
}
impl Default for CbzViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl CbzViewerPalette {
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,
}
}
}
/// Pinta header (nombre · página X/N + controles ) + body con la página
/// visible aspect-fit, o un placeholder de estado. Los mensajes de navegación
/// los provee el caller: `on_prev`/`on_next` se emiten al clickear / (el
/// caller llama a [`CbzDoc::anterior`]/[`CbzDoc::siguiente`] en su `update`).
pub fn cbz_viewer_view<Msg>(
state: &CbzPreviewState,
path: Option<&Path>,
palette: &CbzViewerPalette,
on_prev: Msg,
on_next: Msg,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let body = match state {
CbzPreviewState::Empty => empty_body(palette),
CbzPreviewState::Loaded(d) => match d.cache.get(&d.actual) {
Some(img) => page_body(img.clone(), key_of(path, d.actual)),
None => placeholder_body("no se pudo decodificar esta página", palette.fg_error),
},
CbzPreviewState::TooBig(n) => placeholder_body(
&format!("cómic demasiado grande ({} bytes)", n),
palette.fg_muted,
),
CbzPreviewState::Error(e) => placeholder_body(e, palette.fg_error),
};
outer(header_view(state, path, palette, on_prev, on_next), body, palette)
}
/// Header: + (nombre · página X/N) + . Los controles sólo aparecen y
/// disparan Msg con un cómic cargado de más de una página.
fn header_view<Msg>(
state: &CbzPreviewState,
path: Option<&Path>,
palette: &CbzViewerPalette,
on_prev: Msg,
on_next: Msg,
) -> 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(|| "cómic".to_string());
let (texto, multipagina, en_primera, en_ultima) = match state {
CbzPreviewState::Loaded(d) => (
format!("{name} · {}/{}", d.actual + 1, d.paginas()),
d.paginas() > 1,
d.actual == 0,
d.actual + 1 >= d.paginas(),
),
_ => (name, false, true, true),
};
let titulo = View::new(Style {
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(texto, 10.0, palette.fg_muted, Alignment::Start);
let mut hijos = vec![titulo];
if multipagina {
hijos.insert(0, nav_glyph("", !en_primera, palette, on_prev));
hijos.push(nav_glyph("", !en_ultima, palette, on_next));
}
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(22.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(hijos)
}
/// Un control de navegación / . Clickeable sólo si `activo`.
fn nav_glyph<Msg>(glifo: &str, activo: bool, palette: &CbzViewerPalette, msg: Msg) -> View<Msg>
where
Msg: Clone + 'static,
{
let color = if activo { palette.fg } else { dim(palette.fg_muted, alpha::DISABLED) };
let v = View::new(Style {
size: Size {
width: length(18.0_f32),
height: length(18.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text_aligned(glifo.to_string(), 14.0, color, Alignment::Center);
if activo {
v.on_click(msg)
} else {
v
}
}
/// Contenedor columna (header + body) con fondo y clip.
fn outer<Msg>(header: View<Msg>, body: View<Msg>, palette: &CbzViewerPalette) -> View<Msg>
where
Msg: Clone + 'static,
{
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])
}
/// Body con la página aspect-fit. Pop-in suave al cambiar de página.
fn page_body<Msg>(image: Image, key: u64) -> 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)
.animated_enter(key, motion::NORMAL)
}
fn empty_body<Msg>(palette: &CbzViewerPalette) -> 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),
},
..Default::default()
})
.children(vec![empty_view(
Icon::Image,
"Cómic".to_string(),
Some("Elige un .cbz para verlo página a página"),
&empty_palette(palette),
)])
}
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)
}
/// Deriva una [`EmptyPalette`] apagada desde la paleta del visor.
fn empty_palette(p: &CbzViewerPalette) -> EmptyPalette {
EmptyPalette {
fg_icon: dim(p.fg_muted, alpha::HINT),
fg_title: p.fg_muted,
fg_desc: dim(p.fg_muted, alpha::DISABLED),
}
}
/// Aplica un alfa (0..=255) a un color, preservando su RGB.
fn dim(c: Color, a: u8) -> Color {
let [r, g, b, _] = c.components;
AlphaColor::new([r, g, b, a as f32 / 255.0])
}
/// Hash estable path+página → `key` del pop-in de Llimphi.
fn key_of(path: Option<&Path>, pagina: usize) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
match path {
Some(p) => p.to_string_lossy().hash(&mut h),
None => 0u8.hash(&mut h),
}
pagina.hash(&mut h);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// Forja un CBZ en memoria: un zip con `nombres` como entradas, cada una un
/// PNG sólido del color dado (para certificar el decode real, no un stub).
fn cbz_de(nombres: &[(&str, [u8; 3])]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut zw = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts: zip::write::FileOptions<()> =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (nombre, [r, g, b] ) in nombres {
let png = png_solido(4, 4, [*r, *g, *b]);
zw.start_file(*nombre, opts).unwrap();
zw.write_all(&png).unwrap();
}
zw.finish().unwrap();
}
buf
}
/// PNG RGB sólido `w×h` del color dado, vía el crate `image` (dev-dep).
fn png_solido(w: u32, h: u32, [r, g, b]: [u8; 3]) -> Vec<u8> {
let img = image::RgbImage::from_pixel(w, h, image::Rgb([r, g, b]));
let mut out = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
#[test]
fn clave_natural_ordena_numeros_como_numeros() {
let mut v = vec!["p10.jpg", "p2.jpg", "p1.jpg"];
v.sort_by(|a, b| clave_natural(a).cmp(&clave_natural(b)));
assert_eq!(v, vec!["p1.jpg", "p2.jpg", "p10.jpg"]);
}
#[test]
fn es_pagina_filtra_basura() {
assert!(es_pagina("001.jpg"));
assert!(es_pagina("dir/002.PNG"));
assert!(!es_pagina("dir/")); // directorio
assert!(!es_pagina("__MACOSX/._001.jpg"));
assert!(!es_pagina(".DS_Store"));
assert!(!es_pagina("notas.txt")); // no imagen
}
#[test]
fn carga_ordena_y_decodifica_la_primera() {
// Entradas fuera de orden: la 3, la 1, la 2. La 1 es roja.
let bytes = cbz_de(&[
("p3.png", [0, 0, 255]),
("p1.png", [255, 0, 0]),
("p2.png", [0, 255, 0]),
("leeme.txt", [0, 0, 0]), // no es imagen: se ignora
]);
let CbzPreviewState::Loaded(mut doc) = cargar_de_bytes(bytes) else {
panic!("debería cargar");
};
assert_eq!(doc.paginas(), 3, "3 imágenes, el .txt se descarta");
assert_eq!(doc.actual(), 0);
// La página 0 (p1) ya está decodificada y es roja.
let img0 = doc.cache.get(&0).expect("página 0 decodificada");
assert!(img0.image.width == 4 && img0.image.height == 4);
// Navegar decodifica la siguiente.
assert!(doc.siguiente());
assert_eq!(doc.actual(), 1);
assert!(doc.cache.contains_key(&1));
// No se puede pasar de la última.
assert!(doc.siguiente());
assert!(!doc.siguiente());
assert_eq!(doc.actual(), 2);
}
#[test]
fn cbz_sin_imagenes_es_error() {
let bytes = cbz_de(&[("readme.txt", [0, 0, 0])]);
// El .txt igual se escribe como PNG bytes, pero la extensión .txt no es
// página, así que no hay imágenes válidas.
assert!(matches!(cargar_de_bytes(bytes), CbzPreviewState::Error(_)));
}
#[test]
fn zip_corrupto_es_error() {
assert!(matches!(cargar_de_bytes(b"no soy zip".to_vec()), CbzPreviewState::Error(_)));
}
}
@@ -0,0 +1,27 @@
[package]
name = "nahual-cotejo-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-cotejo-llimphi — visor de COTEJO del shell nahual: compara dos archivos como lienzos paralelos (el multilienzo de pluma), alineados párrafo-a-párrafo por similitud, con la diferencia por sección en el medio y el coloreado verde→rojo. No edita: compara. Parsea con pluma-md, coteja con pluma-cotejo y pinta con el widget canónico de pluma-editor-llimphi."
[dependencies]
llimphi-ui = { workspace = true }
# El motor de comparación y sus tipos viven en el dominio pluma; por path
# (no están en [workspace.dependencies]).
pluma-core = { workspace = true }
pluma-cuerpo = { workspace = true }
pluma-align = { workspace = true }
pluma-md = { workspace = true }
pluma-cotejo = { workspace = true }
# El merge 3-vías estructural (F5 de minga): fusiona dos ediciones de un archivo
# de código contra su base común, con conflictos como objetos, no marcadores de
# texto. Por path (no está en [workspace.dependencies]).
minga-merge = { workspace = true }
# El widget canónico del multilienzo (render de las cintas + coloreado por
# divergencia). Reusarlo evita reimplementar la vista (regla del repo).
pluma-editor-llimphi = { workspace = true }
uuid = { workspace = true }
@@ -0,0 +1,26 @@
# nahual-cotejo-llimphi
*Read this in English: [README.md](README.md).*
Comparar dos archivos como lienzos paralelos.
El visor de **cotejo** del shell nahual: toma dos archivos (dos versiones de
un texto, o dos archivos distintos) y los pinta como el *multilienzo* de
pluma —párrafo-átomo alineado por similitud, con la diferencia por sección
en el medio y el coloreado **verde** (coincide) → **rojo** (difiere)—.
No reimplementa nada: parsea con `pluma_md::parse_md`, coteja con
`pluma_cotejo::cotejar` (+ `columna_diferencias`)
y pinta con el widget canónico
`multilienzo_cotejo_view_reorderable`.
Es *read-only* sobre el contenido: compara, no edita —la edición que nahual
posee es la de la organización (el grafo de Mónadas), no la del texto—.
`CotejoArchivos` es el estado headless (parseo + cotejo + lienzo de
diferencias, todo recalculable); `cotejo_view` lo pinta. La app (shell o
el ejemplo `comparar_demo`) mantiene el `CotejoArchivos` y llama a
`cotejo_view` en su `view`.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,17 @@
# nahual-cotejo-llimphi
Compare two files as parallel canvases.
The **cotejo** (collation) viewer of the nahual shell: it takes two files (two
versions of a text, or two different files) and paints them as pluma's
*multilienzo* — paragraph-atoms aligned by similarity, with the per-section
difference in the middle and the **green** (matches) → **red** (differs)
colouring.
It reimplements nothing: it parses with `pluma_md::parse_md`, collates with
`pluma_cotejo::cotejar` (+ `columna_diferencias`) and paints with the canonical
widget. It is *read-only* over the content: it compares, it does not edit.
---
Part of **nahual** — see [nahual](../README.md).
@@ -0,0 +1,221 @@
//! Demo del visor de cotejo de nahual — comparar **dos archivos reales** como
//! lienzos paralelos, con la diferencia por sección en el medio y el coloreado
//! verde (coincide) → rojo (difiere).
//!
//! Uso:
//! cargo run -p nahual-cotejo-llimphi --example comparar_demo --release -- A B
//! donde `A` y `B` son rutas a dos archivos de texto. Sin argumentos, compara
//! dos versiones sembradas de ejemplo.
//!
//! Teclas:
//! - `i` — invierte izquierda↔derecha (los lienzos son intercambiables).
//! - `Esc` — sale.
use llimphi_ui::llimphi_layout::taffy;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Rect, Size, Style};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
use nahual_cotejo_llimphi::{cotejo_view, CotejoArchivos};
use pluma_editor_llimphi::multilienzo::MultilienzoConfig;
use pluma_editor_llimphi::Palette;
#[derive(Clone, Debug)]
enum Msg {
Invertir,
Reordenar(usize, usize),
}
struct Model {
cotejo: CotejoArchivos,
status: String,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"nahual · cotejar — dos archivos, la diferencia por sección (i: invertir · Esc: salir)"
}
fn initial_size() -> (u32, u32) {
(1360, 820)
}
fn init(_: &Handle<Msg>) -> Model {
let args: Vec<String> = std::env::args().skip(1).collect();
let cotejo = if args.len() >= 2 {
let a = std::fs::read(&args[0]).unwrap_or_else(|e| {
eprintln!("no pude leer {}: {e}", args[0]);
Vec::new()
});
let b = std::fs::read(&args[1]).unwrap_or_else(|e| {
eprintln!("no pude leer {}: {e}", args[1]);
Vec::new()
});
CotejoArchivos::nuevo(&a, &args[0], &b, &args[1])
} else {
let original = "\
Pluma es un editor de documentos como haz de cuerpos.
Cada cuerpo es un lienzo del mismo material bajo otra mirada.
Los párrafos se alinean uno a uno entre cuerpos.
El motor gráfico se llamaba GPUI en las primeras versiones.
La persistencia vive en una base sled embebida.";
let editado = "\
Pluma es un editor de documentos como haz de cuerpos.
Cada cuerpo es un lienzo del mismo material visto desde otra intención.
Los párrafos quedan alineados uno a uno entre los cuerpos del haz.
Hoy todo lo gráfico corre sobre Llimphi con wgpu y vello.
La persistencia vive en una base sled embebida.
Un cotejo compara dos versiones sección por sección.";
CotejoArchivos::nuevo(
original.as_bytes(),
"original.md",
editado.as_bytes(),
"editado.md",
)
};
let status = "i: invertir · arrastra una cabecera para reordenar".to_string();
Model { cotejo, status }
}
fn on_key(_model: &Model, event: &KeyEvent) -> Option<Msg> {
if event.state != KeyState::Pressed {
return None;
}
match &event.key {
Key::Named(NamedKey::Escape) => std::process::exit(0),
Key::Character(s) if s.eq_ignore_ascii_case("i") => Some(Msg::Invertir),
_ => None,
}
}
fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
match msg {
Msg::Invertir => {
model.cotejo.invertir();
model.status = format!(
"invertido — «{}» ↔ «{}»",
model.cotejo.izq.metadatos.nombre_legible,
model.cotejo.der.metadatos.nombre_legible
);
}
Msg::Reordenar(desde, hasta) => {
model.cotejo.reordenar(desde, hasta);
model.status = "columnas reordenadas".into();
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let palette = Palette::default();
let cfg = MultilienzoConfig {
altura_atom: 92.0,
gap_atom: 14.0,
ancho_cuerpo: 376.0,
ancho_carril: 86.0,
padding_top: 14.0,
..MultilienzoConfig::default()
};
let interior = cotejo_view::<Msg, _>(&model.cotejo, &cfg, &palette, |desde, hasta| {
Some(Msg::Reordenar(desde, hasta))
});
let titulo = View::<Msg>::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: length(40.0_f32),
},
..Default::default()
})
.children(vec![
View::<Msg>::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
..Default::default()
})
.text_aligned(
format!("Cotejo — {}", model.cotejo.resumen()),
14.0,
palette.fg_text,
Alignment::Start,
),
View::<Msg>::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
..Default::default()
})
.text_aligned(
format!("verde = coincide · rojo = difiere · {}", model.status),
11.0,
palette.fg_muted,
Alignment::Start,
),
]);
let header = View::<Msg>::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(48.0_f32),
},
padding: Rect {
left: length(18.0_f32),
right: length(18.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(palette.bg_panel)
.children(vec![titulo]);
let centro = View::<Msg>::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
justify_content: Some(taffy::JustifyContent::Center),
align_items: Some(taffy::AlignItems::Start),
..Default::default()
})
.children(vec![interior]);
View::<Msg>::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette.bg_app)
.clip(true)
.children(vec![header, centro])
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,298 @@
//! `nahual-cotejo-llimphi` — comparar dos archivos como lienzos paralelos.
//!
//! El visor de **cotejo** del shell nahual: toma dos archivos (dos versiones de
//! un texto, o dos archivos distintos) y los pinta como el *multilienzo* de
//! pluma —párrafo-átomo alineado por similitud, con la diferencia por sección
//! en el medio y el coloreado **verde** (coincide) → **rojo** (difiere)—.
//!
//! No reimplementa nada: parsea con [`pluma_md::parse_md`], coteja con
//! [`pluma_cotejo::cotejar`] (+ [`columna_diferencias`](pluma_cotejo::columna_diferencias))
//! y pinta con el widget canónico
//! [`multilienzo_cotejo_view_reorderable`](pluma_editor_llimphi::multilienzo::multilienzo_cotejo_view_reorderable).
//! Es *read-only* sobre el contenido: compara, no edita —la edición que nahual
//! posee es la de la organización (el grafo de Mónadas), no la del texto—.
//!
//! [`CotejoArchivos`] es el estado headless (parseo + cotejo + lienzo de
//! diferencias, todo recalculable); [`cotejo_view`] lo pinta. La app (shell o
//! el ejemplo `comparar_demo`) mantiene el `CotejoArchivos` y llama a
//! `cotejo_view` en su `view`.
#![forbid(unsafe_code)]
mod tresvias;
pub use tresvias::{tresvias_view, CotejoTresVias};
use std::collections::HashMap;
use uuid::Uuid;
use pluma_align::CartaHebras;
use pluma_cotejo::{
columna_diferencias, cotejar, Conteos, ParamsCotejo, ResumidorTextual, SeccionCotejo,
};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_md::parse_md;
use llimphi_ui::View;
use pluma_editor_llimphi::multilienzo::{
multilienzo_cotejo_view_reorderable, MultilienzoConfig, PaletaHebras,
};
use pluma_editor_llimphi::Palette;
/// El estado de un cotejo de dos archivos: los dos cuerpos fuente + todo lo
/// derivado (lienzo de diferencias, cartas de hebras, divergencias). Es
/// recalculable con [`CotejoArchivos::recotejar`] —el mismo patrón que el demo
/// `cotejar_demo` de pluma-editor-llimphi—.
pub struct CotejoArchivos {
/// Cuerpo del archivo de la izquierda, en su orientación actual.
pub izq: Cuerpo,
/// Cuerpo del archivo de la derecha.
pub der: Cuerpo,
/// Átomos de los dos fuentes (estables; el lienzo de diferencias se recrea).
base_atoms: HashMap<Uuid, NarrativeAtom>,
// --- derivado del cotejo (se recalcula en `recotejar`) ---
/// El cuerpo "diferencias" (columna del medio): un átomo por sección.
pub dif: Cuerpo,
/// Átomos completos (fuentes + lienzo de diferencias) para el índice.
atoms: HashMap<Uuid, NarrativeAtom>,
/// Hebras `izq ↔ diferencias`.
pub carta_izq: CartaHebras,
/// Hebras `diferencias ↔ der`.
pub carta_der: CartaHebras,
/// Divergencia `∈ [0,1]` por átomo (verde 0 → rojo 1).
pub divergencias: HashMap<Uuid, f32>,
/// Las secciones del cotejo, en orden de lectura.
pub secciones: Vec<SeccionCotejo>,
/// Recuento por clase (idénticas/similares/…).
pub conteo: Conteos,
/// Orden de display de las 3 columnas, índices del canónico `[izq, dif, der]`.
/// El drag-to-swap lo permuta; `recotejar` lo resetea.
pub orden: Vec<usize>,
}
impl CotejoArchivos {
/// Coteja dos archivos por sus bytes. El contenido se decodifica como UTF-8
/// con reemplazo (los bytes inválidos no rompen el visor) y se parte en
/// párrafo-átomos con [`pluma_md::parse_md`] —que trata texto plano como
/// bloques, y markdown como sus bloques nativos—.
pub fn nuevo(bytes_izq: &[u8], nombre_izq: &str, bytes_der: &[u8], nombre_der: &str) -> Self {
let (izq, atoms_izq) = cuerpo_de_bytes(bytes_izq, "izq", nombre_izq);
let (der, atoms_der) = cuerpo_de_bytes(bytes_der, "der", nombre_der);
let mut base_atoms = HashMap::new();
for a in atoms_izq {
base_atoms.insert(a.id, a);
}
for a in atoms_der {
base_atoms.insert(a.id, a);
}
let mut c = Self {
izq,
der,
base_atoms,
dif: Cuerpo::nuevo(
"dif",
"diferencias",
Intencion::Custom {
kind: "cotejo".into(),
},
0,
),
atoms: HashMap::new(),
carta_izq: CartaHebras::nueva(),
carta_der: CartaHebras::nueva(),
divergencias: HashMap::new(),
secciones: Vec::new(),
conteo: Conteos::default(),
orden: vec![0, 1, 2],
};
c.recotejar();
c
}
/// Recalcula el cotejo desde `izq`/`der`/`base_atoms` y repuebla lo derivado
/// (lienzo de diferencias textual, cartas, divergencias, conteos).
pub fn recotejar(&mut self) {
let idx: HashMap<Uuid, &NarrativeAtom> = self
.izq
.orden
.iter()
.chain(self.der.orden.iter())
.filter_map(|id| self.base_atoms.get(id).map(|a| (*id, a)))
.collect();
let cot = cotejar(&self.izq, &self.der, &idx, &ParamsCotejo::default(), 0);
let col = columna_diferencias(&cot, &self.izq, &self.der, &idx, &ResumidorTextual, 0);
self.conteo = cot.conteos();
// Átomos completos: fuentes + lienzo de diferencias.
let mut atoms = self.base_atoms.clone();
for a in &col.atoms {
atoms.insert(a.id, a.clone());
}
let mut divergencias = cot.divergencias;
divergencias.extend(col.divergencias);
self.dif = col.cuerpo;
self.atoms = atoms;
self.carta_izq = col.carta_izq;
self.carta_der = col.carta_der;
self.divergencias = divergencias;
self.secciones = cot.secciones;
self.orden = vec![0, 1, 2];
}
/// Invierte izquierda↔derecha (los lienzos son intercambiables) y recoteja.
pub fn invertir(&mut self) {
std::mem::swap(&mut self.izq, &mut self.der);
self.recotejar();
}
/// Permuta dos columnas del display (drag-to-swap de las cabeceras). No
/// recoteja: sólo cambia el orden de presentación de `[izq, dif, der]`.
pub fn reordenar(&mut self, desde: usize, hasta: usize) {
let n = self.orden.len();
if desde < n && hasta < n && desde != hasta {
self.orden.swap(desde, hasta);
}
}
/// Línea de resumen legible del cotejo.
pub fn resumen(&self) -> String {
let c = &self.conteo;
format!(
"{} idénticas · {} reformuladas · {} reescritas · {} agregadas · {} eliminadas",
c.identicas, c.similares, c.divergentes, c.agregadas, c.eliminadas
)
}
/// `true` si los dos archivos difieren en algo (hay secciones no idénticas).
pub fn hay_diferencias(&self) -> bool {
let c = &self.conteo;
c.similares + c.divergentes + c.agregadas + c.eliminadas > 0
}
}
/// Pinta el cotejo con el widget canónico del multilienzo. `on_reorder(desde,
/// hasta)` lo emite el drag-to-swap de las cabeceras (el caller llama
/// [`CotejoArchivos::reordenar`] y re-renderiza).
pub fn cotejo_view<Msg, F>(
c: &CotejoArchivos,
cfg: &MultilienzoConfig,
palette: &Palette,
on_reorder: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(usize, usize) -> Option<Msg> + Send + Sync + 'static,
{
let paleta_hebras = PaletaHebras::default();
let index: HashMap<Uuid, &NarrativeAtom> = c.atoms.iter().map(|(id, a)| (*id, a)).collect();
// Canónico [izq, dif, der]; el display sigue `orden` (drag-to-swap).
let canon: [&Cuerpo; 3] = [&c.izq, &c.dif, &c.der];
let cuerpos_ref: Vec<&Cuerpo> = c.orden.iter().map(|&i| canon[i]).collect();
// El carril de cada par adyacente = la carta que conecta esos dos cuerpos
// (no por posición), así reordenar mueve las hebras con las columnas.
let pool: [&CartaHebras; 2] = [&c.carta_izq, &c.carta_der];
let cartas_ref: Vec<Option<&CartaHebras>> = cuerpos_ref
.windows(2)
.map(|w| carta_par(&pool, w[0].id, w[1].id))
.collect();
multilienzo_cotejo_view_reorderable::<Msg, _>(
&cuerpos_ref,
&index,
&cartas_ref,
&c.divergencias,
cfg,
&paleta_hebras,
palette,
"",
on_reorder,
)
}
/// Decodifica bytes a texto (UTF-8 con reemplazo) y lo parte en párrafo-átomos.
pub(crate) fn cuerpo_de_bytes(bytes: &[u8], branch: &str, nombre: &str) -> (Cuerpo, Vec<NarrativeAtom>) {
let texto = String::from_utf8_lossy(bytes);
let d = parse_md(&texto, branch, nombre, 0);
(d.cuerpo, d.atoms)
}
/// Busca en el pool la carta que conecta los cuerpos `a` y `b` (en cualquier
/// orden). `None` si ese par no tiene carta —caso normal tras reordenar—.
pub(crate) fn carta_par<'a>(pool: &[&'a CartaHebras], a: Uuid, b: Uuid) -> Option<&'a CartaHebras> {
pool.iter().copied().find(|c| {
(c.cuerpo_a == Some(a) && c.cuerpo_b == Some(b))
|| (c.cuerpo_a == Some(b) && c.cuerpo_b == Some(a))
})
}
#[cfg(test)]
mod pruebas {
use super::*;
#[test]
fn cotejo_de_dos_versiones_cuenta_sensato() {
let a = "Uno dos tres.\n\nCuatro cinco seis.";
let b = "Uno dos tres.\n\nCuatro cinco seis siete.";
let c = CotejoArchivos::nuevo(a.as_bytes(), "a.md", b.as_bytes(), "b.md");
// Primer párrafo idéntico; segundo emparejado pero con menos en común.
assert_eq!(c.conteo.identicas, 1);
assert!(c.conteo.similares + c.conteo.divergentes >= 1);
assert!(c.hay_diferencias());
// El lienzo de diferencias tiene un átomo por sección.
assert_eq!(c.dif.orden.len(), c.secciones.len());
}
#[test]
fn archivos_identicos_no_reportan_diferencias() {
let t = "Alfa beta.\n\nGamma delta.";
let c = CotejoArchivos::nuevo(t.as_bytes(), "a", t.as_bytes(), "b");
assert_eq!(c.conteo.identicas, 2);
assert!(!c.hay_diferencias());
}
#[test]
fn agregado_y_eliminado_se_detectan() {
let a = "alfa beta.\n\ngamma delta.\n\nsolo izquierda.";
let b = "alfa beta.\n\nnuevo intermedio.\n\ngamma delta.";
let c = CotejoArchivos::nuevo(a.as_bytes(), "a", b.as_bytes(), "b");
assert_eq!(c.conteo.agregadas, 1, "nuevo intermedio es agregado");
assert_eq!(c.conteo.eliminadas, 1, "solo izquierda es eliminado");
}
#[test]
fn invertir_intercambia_los_lados() {
let a = "Uno.\n\nDos.";
let b = "Uno.\n\nDos cambiado.";
let mut c = CotejoArchivos::nuevo(a.as_bytes(), "a.md", b.as_bytes(), "b.md");
let nombre_izq = c.izq.metadatos.nombre_legible.clone();
let nombre_der = c.der.metadatos.nombre_legible.clone();
c.invertir();
assert_eq!(c.izq.metadatos.nombre_legible, nombre_der);
assert_eq!(c.der.metadatos.nombre_legible, nombre_izq);
// El cotejo sigue siendo válido tras invertir.
assert_eq!(c.dif.orden.len(), c.secciones.len());
}
#[test]
fn bytes_no_utf8_no_panica() {
let c = CotejoArchivos::nuevo(&[0xff, 0xfe, 0x00, b'h', b'i'], "raro.bin", b"hola", "b");
// No debe entrar en pánico; produce algún cotejo.
let _ = c.resumen();
}
#[test]
fn reordenar_permuta_columnas() {
let c0 = "Uno.\n\nDos.";
let c1 = "Uno.\n\nTres.";
let mut c = CotejoArchivos::nuevo(c0.as_bytes(), "a", c1.as_bytes(), "b");
assert_eq!(c.orden, vec![0, 1, 2]);
c.reordenar(0, 2);
assert_eq!(c.orden, vec![2, 1, 0]);
// Fuera de rango o al mismo índice = no-op.
c.reordenar(0, 9);
assert_eq!(c.orden, vec![2, 1, 0]);
}
}
@@ -0,0 +1,305 @@
//! Merge **3-vías** de nahual-cotejo: `ours` y `theirs` contra una `base` común.
//!
//! Donde [`CotejoArchivos`](crate::CotejoArchivos) compara dos archivos, esto
//! **fusiona** dos ediciones divergentes de un mismo archivo usando el merge
//! **estructural** de minga ([`minga_merge::merge3`]): parsea las tres versiones
//! a AST, alinea subárboles y produce una fusión + una lista de [`Conflicto`]s
//! como objetos de primera clase (no marcadores `<<<<<<<` en el texto). Su
//! ventaja sobre el merge textual: dos ediciones a subárboles **disjuntos** del
//! mismo archivo (aunque en líneas adyacentes) se fusionan limpio.
//!
//! El merge estructural es de **código** (dialectos Rust/Python/TS/JS/Go); no
//! hay merge de prosa aquí. Para que el multilienzo resalte los cambios
//! *estructurales* y no los de formato, los tres lados se **normalizan** por el
//! mismo pretty-print del AST (`a_fuente`) antes de alinearlos — así dos textos
//! que sólo difieren en espaciado se ven idénticos.
//!
//! La vista pinta tres columnas `[ours, fusión, theirs]` con el widget canónico
//! del multilienzo (el mismo que el cotejo 2-vías): `ours↔fusión` y
//! `fusión↔theirs` como carriles, y el coloreado por divergencia. Es headless y
//! recalculable; la resolución de un conflicto es de la app (aquí se muestran).
use std::collections::HashMap;
use std::path::Path;
use uuid::Uuid;
use pluma_align::CartaHebras;
use pluma_cotejo::{cotejar, ParamsCotejo};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::Cuerpo;
use llimphi_ui::View;
use pluma_editor_llimphi::multilienzo::{
multilienzo_cotejo_view_reorderable, MultilienzoConfig, PaletaHebras,
};
use pluma_editor_llimphi::Palette;
use minga_merge::{detect_by_extension, merge3, Conflicto, Dialect};
use crate::{carta_par, cuerpo_de_bytes};
/// Estado de un merge 3-vías: los tres lados ya normalizados como cuerpos +
/// la fusión + los conflictos estructurales. Todo lo derivado (cartas,
/// divergencias) se recalcula con [`CotejoTresVias::realinear`].
pub struct CotejoTresVias {
/// El dialecto con que se parsearon las tres versiones.
pub dialecto: Dialect,
/// Rama `ours` (izquierda), normalizada.
pub ours: Cuerpo,
/// El resultado de la fusión (columna del medio).
pub merged: Cuerpo,
/// Rama `theirs` (derecha), normalizada.
pub theirs: Cuerpo,
/// Átomos de las tres columnas, para el índice del render.
atoms: HashMap<Uuid, NarrativeAtom>,
/// Hebras `ours ↔ fusión`.
pub carta_ours: CartaHebras,
/// Hebras `fusión ↔ theirs`.
pub carta_theirs: CartaHebras,
/// Divergencia `∈ [0,1]` por átomo (verde 0 → rojo 1). Para un átomo de la
/// fusión es el **máximo** de su divergencia contra `ours` y contra
/// `theirs`: se pinta caliente si difiere de cualquiera de los dos lados.
pub divergencias: HashMap<Uuid, f32>,
/// Conflictos estructurales irreconciliables (vacío = fusión limpia). En la
/// fusión, el lado `ours` quedó como marcador de posición en cada conflicto.
pub conflictos: Vec<Conflicto>,
/// Orden de display de las 3 columnas sobre el canónico `[ours, fusión,
/// theirs]`. El drag-to-swap lo permuta; `realinear` lo resetea.
pub orden: Vec<usize>,
}
impl CotejoTresVias {
/// Fusiona `ours` y `theirs` contra `base`, los tres como código del
/// `dialecto` dado. `nombre` rotula las columnas laterales. Devuelve `Err`
/// con el mensaje del parser si alguna de las tres versiones no parsea.
pub fn nuevo(
dialecto: Dialect,
base: &str,
ours: &str,
theirs: &str,
nombre: &str,
) -> Result<Self, String> {
let b = dialecto.parse(base).map_err(|e| e.to_string())?;
let o = dialecto.parse(ours).map_err(|e| e.to_string())?;
let t = dialecto.parse(theirs).map_err(|e| e.to_string())?;
let r = merge3(&b, &o, &t);
// Normalización: los tres lados por el mismo pretty-print del AST, para
// que el multilienzo muestre diferencias estructurales, no de formato.
let (ours_c, atoms_o) = cuerpo_de_bytes(o.a_fuente().as_bytes(), "ours", nombre);
let (merged_c, atoms_m) = cuerpo_de_bytes(r.arbol.a_fuente().as_bytes(), "merged", "fusión");
let (theirs_c, atoms_t) = cuerpo_de_bytes(t.a_fuente().as_bytes(), "theirs", nombre);
let mut atoms = HashMap::new();
for a in atoms_o.into_iter().chain(atoms_m).chain(atoms_t) {
atoms.insert(a.id, a);
}
let mut c = Self {
dialecto,
ours: ours_c,
merged: merged_c,
theirs: theirs_c,
atoms,
carta_ours: CartaHebras::nueva(),
carta_theirs: CartaHebras::nueva(),
divergencias: HashMap::new(),
conflictos: r.conflictos,
orden: vec![0, 1, 2],
};
c.realinear();
Ok(c)
}
/// Igual que [`CotejoTresVias::nuevo`] pero **detecta el dialecto** por la
/// extensión de `nombre`. `Err` si la extensión no la cubre el merge
/// estructural (hoy: `.rs`/`.py`/`.ts`/`.js`/`.go`).
pub fn desde_nombre(
nombre: &str,
base: &str,
ours: &str,
theirs: &str,
) -> Result<Self, String> {
let ext = Path::new(nombre)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let dialecto = detect_by_extension(ext)
.ok_or_else(|| format!("extensión sin merge estructural: «{ext}»"))?;
Self::nuevo(dialecto, base, ours, theirs, nombre)
}
/// Recalcula las cartas y divergencias alineando `ours↔fusión` y
/// `fusión↔theirs` con el mismo alineador del cotejo 2-vías.
pub fn realinear(&mut self) {
let idx: HashMap<Uuid, &NarrativeAtom> =
self.atoms.iter().map(|(id, a)| (*id, a)).collect();
let cot_om = cotejar(&self.ours, &self.merged, &idx, &ParamsCotejo::default(), 0);
let cot_mt = cotejar(&self.merged, &self.theirs, &idx, &ParamsCotejo::default(), 0);
let mut div = cot_om.divergencias;
// El átomo de la fusión aparece en ambos cotejos: se queda con el
// máximo (difiere de ours O de theirs ⇒ caliente).
for (id, d) in cot_mt.divergencias {
let e = div.entry(id).or_insert(0.0);
*e = e.max(d);
}
self.carta_ours = cot_om.carta;
self.carta_theirs = cot_mt.carta;
self.divergencias = div;
self.orden = vec![0, 1, 2];
}
/// Permuta dos columnas del display (drag-to-swap). No realinea.
pub fn reordenar(&mut self, desde: usize, hasta: usize) {
let n = self.orden.len();
if desde < n && hasta < n && desde != hasta {
self.orden.swap(desde, hasta);
}
}
/// `true` si la fusión fue limpia (sin conflictos estructurales).
pub fn limpio(&self) -> bool {
self.conflictos.is_empty()
}
/// Línea de resumen legible del merge.
pub fn resumen(&self) -> String {
if self.conflictos.is_empty() {
"fusión limpia · sin conflictos".to_string()
} else if self.conflictos.len() == 1 {
"1 conflicto estructural a resolver".to_string()
} else {
format!("{} conflictos estructurales a resolver", self.conflictos.len())
}
}
}
/// Pinta el merge 3-vías `[ours, fusión, theirs]` con el widget canónico del
/// multilienzo. `on_reorder(desde, hasta)` lo emite el drag-to-swap de las
/// cabeceras (el caller llama [`CotejoTresVias::reordenar`] y re-renderiza).
pub fn tresvias_view<Msg, F>(
c: &CotejoTresVias,
cfg: &MultilienzoConfig,
palette: &Palette,
on_reorder: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(usize, usize) -> Option<Msg> + Send + Sync + 'static,
{
let paleta_hebras = PaletaHebras::default();
let index: HashMap<Uuid, &NarrativeAtom> = c.atoms.iter().map(|(id, a)| (*id, a)).collect();
// Canónico [ours, fusión, theirs]; el display sigue `orden` (drag-to-swap).
let canon: [&Cuerpo; 3] = [&c.ours, &c.merged, &c.theirs];
let cuerpos_ref: Vec<&Cuerpo> = c.orden.iter().map(|&i| canon[i]).collect();
// El carril de cada par adyacente = la carta que conecta esos dos cuerpos,
// por id (no por posición) — así reordenar mueve las hebras con las columnas.
let pool: [&CartaHebras; 2] = [&c.carta_ours, &c.carta_theirs];
let cartas_ref: Vec<Option<&CartaHebras>> = cuerpos_ref
.windows(2)
.map(|w| carta_par(&pool, w[0].id, w[1].id))
.collect();
multilienzo_cotejo_view_reorderable::<Msg, _>(
&cuerpos_ref,
&index,
&cartas_ref,
&c.divergencias,
cfg,
&paleta_hebras,
palette,
"",
on_reorder,
)
}
#[cfg(test)]
mod pruebas {
use super::*;
/// Dos ediciones a subárboles **disjuntos** (cuerpos de funciones distintas)
/// se fusionan limpio aunque estén en líneas adyacentes — el caso donde el
/// merge estructural gana al textual.
#[test]
fn ediciones_disjuntas_fusionan_limpio() {
let base = "fn a() { 1 }\nfn b() { 2 }";
let ours = "fn a() { 9 }\nfn b() { 2 }"; // cambia a
let theirs = "fn a() { 1 }\nfn b() { 8 }"; // cambia b
let c = CotejoTresVias::nuevo(Dialect::Rust, base, ours, theirs, "m.rs").unwrap();
assert!(c.limpio(), "subárboles disjuntos ⇒ sin conflicto");
// La fusión incorpora ambos cambios.
let texto: String = c
.merged
.orden
.iter()
.filter_map(|id| c.atoms.get(id))
.map(|a| a.content.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(texto.contains('9'), "trae el cambio de ours");
assert!(texto.contains('8'), "trae el cambio de theirs");
}
/// Dos ediciones al **mismo** subárbol (el cuerpo de la misma función) sí
/// conflictúan.
#[test]
fn ediciones_al_mismo_nodo_conflictuan() {
let base = "fn a() { 1 }";
let ours = "fn a() { 2 }";
let theirs = "fn a() { 3 }";
let c = CotejoTresVias::nuevo(Dialect::Rust, base, ours, theirs, "m.rs").unwrap();
assert!(!c.limpio(), "mismo nodo, dos cambios ⇒ conflicto");
assert!(!c.conflictos.is_empty());
assert!(c.resumen().contains("conflicto"));
}
/// Los tres iguales ⇒ fusión limpia idéntica.
#[test]
fn sin_cambios_fusion_limpia() {
let t = "fn a() { 1 }\nfn b() { 2 }";
let c = CotejoTresVias::nuevo(Dialect::Rust, t, t, t, "m.rs").unwrap();
assert!(c.limpio());
assert_eq!(c.resumen(), "fusión limpia · sin conflictos");
}
#[test]
fn desde_nombre_detecta_dialecto() {
let base = "def a():\n return 1\n";
let ours = "def a():\n return 2\n";
let theirs = "def a():\n return 1\n";
// .py ⇒ Python; ours cambia, theirs no ⇒ toma ours, limpio.
let c = CotejoTresVias::desde_nombre("s.py", base, ours, theirs).unwrap();
assert!(c.limpio());
}
#[test]
fn desde_nombre_extension_desconocida_es_error() {
let r = CotejoTresVias::desde_nombre("notas.md", "a", "b", "c");
assert!(r.is_err(), "markdown no tiene merge estructural");
}
#[test]
fn codigo_que_no_parsea_da_error() {
// Rust con paréntesis sin cerrar: tree-sitter igual produce árbol con
// nodos ERROR, así que el merge no necesariamente falla — pero la API
// debe devolver un Cuerpo válido, no entrar en pánico.
let base = "fn a() { 1 }";
let ours = "fn a( { 1 }";
let theirs = "fn a() { 2 }";
let _ = CotejoTresVias::nuevo(Dialect::Rust, base, ours, theirs, "m.rs");
}
#[test]
fn reordenar_permuta_columnas() {
let base = "fn a() { 1 }";
let mut c = CotejoTresVias::nuevo(Dialect::Rust, base, base, base, "m.rs").unwrap();
assert_eq!(c.orden, vec![0, 1, 2]);
c.reordenar(0, 2);
assert_eq!(c.orden, vec![2, 1, 0]);
c.reordenar(0, 9); // fuera de rango = no-op
assert_eq!(c.orden, vec![2, 1, 0]);
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-dbf-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-dbf-viewer-llimphi — visor de tablas DBF/xBase (dBase III/IV, FoxPro) sobre Llimphi. Rescata el `.dbf` con `foreign-dbf` a una `Tabla` genérica y pinta la grilla read-only con los nombres de columna reales y un marcador de registros borrados (la doctrina de rescate: los borrados se muestran, no se descartan). Decimoctavo visor del shell nahual."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
foreign-dbf = { workspace = true }
@@ -0,0 +1,23 @@
# nahual-dbf-viewer-llimphi
*Read this in English: [README.md](README.md).*
Visor de tablas DBF/xBase.
Decimoctavo visor del shell meta-app. Un `.dbf` (dBase III/IV, FoxPro) es
un binario de ancho fijo: hasta ahora caía al volcado hex/binario. Este
visor lo **rescata**: `foreign-dbf` lo lee a una `foreign_dbf::Tabla`
genérica (campos + registros de texto, tolerante a truncados) y aquí se
pinta la grilla read-only con los **nombres de columna reales** del DBF y
un marcador `*` para los registros **borrados** — la doctrina de rescate es
mostrarlos, no descartarlos silenciosamente.
El render es texto monoespaciado alineado por columna (mismo enfoque que
los visores de tabla/hoja/hex). Editar/importar de verdad es asunto de la
app de destino (hampi mapea la `Tabla` a sus entidades).
Patrón fino: carga sync en `load_dbf`, render en `dbf_viewer_view`.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,17 @@
# nahual-dbf-viewer-llimphi
DBF/xBase table viewer.
The eighteenth viewer of the meta-app shell. A `.dbf` (dBase III/IV, FoxPro) is a
fixed-width binary: until now it fell to the hex/binary dump. This viewer
**rescues** it: `foreign-dbf` reads it into a generic `foreign_dbf::Tabla`
(fields + text records, tolerant of truncation) and here the read-only grid is
painted with the DBF's **real column names** and a `*` marker for **deleted**
records — the rescue doctrine is to show them, not to discard them silently.
The render is monospaced text aligned by column (the same approach as the
table/sheet/hex viewers). Real editing and importing are another crate's job.
---
Part of **nahual** — see [nahual](../README.md).
@@ -0,0 +1,402 @@
//! `nahual-dbf-viewer-llimphi` — visor de tablas DBF/xBase.
//!
//! Decimoctavo visor del shell meta-app. Un `.dbf` (dBase III/IV, FoxPro) es
//! un binario de ancho fijo: hasta ahora caía al volcado hex/binario. Este
//! visor lo **rescata**: `foreign-dbf` lo lee a una [`foreign_dbf::Tabla`]
//! genérica (campos + registros de texto, tolerante a truncados) y aquí se
//! pinta la grilla read-only con los **nombres de columna reales** del DBF y
//! un marcador `*` para los registros **borrados** — la doctrina de rescate es
//! mostrarlos, no descartarlos silenciosamente.
//!
//! El render es texto monoespaciado alineado por columna (mismo enfoque que
//! los visores de tabla/hoja/hex). Editar/importar de verdad es asunto de la
//! app de destino (hampi mapea la `Tabla` a sus entidades).
//!
//! Patrón fino: carga sync en [`load_dbf`], render en [`dbf_viewer_view`].
#![forbid(unsafe_code)]
use std::path::Path;
use foreign_dbf::Tabla;
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 de bytes a leer (8 MiB). Un DBF de rescate típico pesa menos; el cap
/// evita descomprimir una base gigante en un preview.
pub const DEFAULT_DBF_BYTES_MAX: u64 = 8 * 1024 * 1024;
/// Límites del render: filas/columnas mostradas y ancho de celda (chars).
const MAX_ROWS: usize = 200;
const MAX_COLS: usize = 32;
const MAX_CELL_W: usize = 32;
/// Estado del visor.
#[derive(Debug, Clone)]
pub enum DbfPreview {
/// Sin archivo seleccionado.
Empty,
/// Grilla renderizada + conteos reales para el header.
Grid {
text: String,
/// Registros totales (activos + borrados), sin capar.
filas: usize,
/// Registros marcados como borrados en el origen.
borrados: usize,
/// Campos (columnas) del DBF.
columnas: usize,
},
/// Excede el tope de tamaño.
TooBig(u64),
/// E/S o parseo del DBF falló.
Error(String),
}
impl Default for DbfPreview {
fn default() -> Self {
DbfPreview::Empty
}
}
/// Carga sync: lee el `.dbf` (acotado por `max_bytes`), lo rescata a una
/// `Tabla` con `foreign-dbf` y renderiza la grilla. Cualquier fallo cae a
/// [`DbfPreview::Error`], nunca paniquea.
pub fn load_dbf(path: &Path, max_bytes: u64) -> DbfPreview {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > max_bytes => return DbfPreview::TooBig(meta.len()),
Err(e) => return DbfPreview::Error(e.to_string()),
_ => {}
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return DbfPreview::Error(e.to_string()),
};
match foreign_dbf::leer_dbf(&bytes) {
Ok(tabla) => render(&tabla),
Err(e) => DbfPreview::Error(format!("no se pudo leer el DBF: {e}")),
}
}
/// Arma la grilla monoespaciada: gutter con marcador de borrado + número de
/// fila, encabezado con los nombres de columna del DBF, y los valores. Ancho
/// de cada columna = máximo entre su nombre y el contenido mostrado (capado a
/// `MAX_CELL_W`).
fn render(tabla: &Tabla) -> DbfPreview {
let columnas = tabla.columnas();
let filas_total = tabla.registros.len();
let borrados = tabla.registros.iter().filter(|r| r.borrado).count();
if columnas.is_empty() {
return DbfPreview::Grid {
text: "(DBF sin columnas)".to_string(),
filas: 0,
borrados: 0,
columnas: 0,
};
}
let n_cols = columnas.len().min(MAX_COLS);
let n_rows = filas_total.min(MAX_ROWS);
let recorta = |s: &str| -> String {
if s.chars().count() > MAX_CELL_W {
let mut t: String = s.chars().take(MAX_CELL_W - 1).collect();
t.push('…');
t
} else {
s.to_string()
}
};
// Ancho por columna: máx entre el nombre y el contenido de las filas
// mostradas.
let mut widths = vec![0usize; n_cols];
for (c, w) in widths.iter_mut().enumerate() {
let col = columnas[c];
let mut m = col.chars().count();
for r in tabla.registros.iter().take(n_rows) {
m = m.max(recorta(r.texto(col)).chars().count());
}
*w = m.min(MAX_CELL_W);
}
// Gutter: 2 chars de marcador+espacio + dígitos del mayor nº de fila.
let num_w = format!("{n_rows}").len().max(2);
let gutter_w = 2 + num_w; // "* " o " " + número
let pad = |s: &str, w: usize| -> String {
let len = s.chars().count();
if len >= w {
s.to_string()
} else {
format!("{s}{}", " ".repeat(w - len))
}
};
let mut out = String::new();
// Encabezado.
out.push_str(&" ".repeat(gutter_w));
out.push_str("");
for (c, w) in widths.iter().enumerate() {
out.push(' ');
out.push_str(&pad(columnas[c], *w));
}
out.push('\n');
// Regla.
out.push_str(&"".repeat(gutter_w));
out.push_str("─┼");
for w in &widths {
out.push_str(&"".repeat(*w + 1));
}
out.push('\n');
// Filas.
for (r, reg) in tabla.registros.iter().take(n_rows).enumerate() {
// Marcador de borrado + número de fila 1-based, alineado a la derecha.
let marca = if reg.borrado { '*' } else { ' ' };
let rn = format!("{}", r + 1);
out.push(marca);
out.push(' ');
out.push_str(&" ".repeat(num_w.saturating_sub(rn.chars().count())));
out.push_str(&rn);
out.push_str("");
for (c, w) in widths.iter().enumerate() {
out.push(' ');
out.push_str(&pad(&recorta(reg.texto(columnas[c])), *w));
}
out.push('\n');
}
DbfPreview::Grid {
text: out,
filas: filas_total,
borrados,
columnas: columnas.len(),
}
}
/// Paleta del viewer.
#[derive(Debug, Clone, Copy)]
pub struct DbfViewerPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_error: Color,
}
impl Default for DbfViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl DbfViewerPalette {
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 · N registros, M borrados · C campos) + la grilla en
/// monoespaciada.
pub fn dbf_viewer_view<Msg>(
state: &DbfPreview,
path: Option<&Path>,
palette: &DbfViewerPalette,
) -> 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 => "(selecciona un DBF)".to_string(),
};
let header_text = match state {
DbfPreview::Grid { filas, borrados, columnas, .. } => {
let capado = *filas > MAX_ROWS || *columnas > MAX_COLS;
let base = if *borrados > 0 {
format!("dbf · {name} · {filas} registros ({borrados} borrados) · {columnas} campos")
} else {
format!("dbf · {name} · {filas} registros · {columnas} campos")
};
if capado {
format!("{base} · muestra {}×{}", (*filas).min(MAX_ROWS), (*columnas).min(MAX_COLS))
} else {
base
}
}
DbfPreview::TooBig(n) => format!("dbf · {name} · {n} B (demasiado grande)"),
_ => format!("dbf · {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 {
DbfPreview::Empty => ("".to_string(), palette.fg_muted),
DbfPreview::Grid { text, .. } => (text.clone(), palette.fg_text),
DbfPreview::TooBig(n) => {
(format!("(DBF demasiado grande para previsualizar: {n} B)"), palette.fg_muted)
}
DbfPreview::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])
}
#[cfg(test)]
mod tests {
use super::*;
use foreign_dbf::{Campo, Registro};
use std::collections::BTreeMap;
fn tabla_demo() -> Tabla {
let campos = vec![
Campo { nombre: "NOMBRE".into(), tipo: 'C', largo: 10 },
Campo { nombre: "EDAD".into(), tipo: 'N', largo: 3 },
];
let mut fila = |nombre: &str, edad: &str, borrado: bool| {
let mut valores = BTreeMap::new();
valores.insert("NOMBRE".to_string(), nombre.to_string());
valores.insert("EDAD".to_string(), edad.to_string());
Registro { valores, borrado }
};
Tabla {
campos,
registros: vec![
fila("Juan", "30", false),
fila("Ana", "25", true),
fila("Rosa", "41", false),
],
}
}
#[test]
fn render_muestra_columnas_reales_y_marca_borrado() {
let DbfPreview::Grid { text, filas, borrados, columnas } = render(&tabla_demo()) else {
panic!("esperaba Grid");
};
assert_eq!((filas, borrados, columnas), (3, 1, 2));
// Nombres de columna REALES (no letras A/B).
assert!(text.contains("NOMBRE") && text.contains("EDAD"));
assert!(text.contains("Juan") && text.contains("Ana"));
// El registro borrado (Ana, fila 2) lleva el marcador `*`.
let linea_ana = text.lines().find(|l| l.contains("Ana")).expect("fila Ana");
assert!(linea_ana.trim_start().starts_with('*'), "la fila borrada debe marcarse: {linea_ana:?}");
// Una fila activa NO lleva `*`.
let linea_juan = text.lines().find(|l| l.contains("Juan")).expect("fila Juan");
assert!(!linea_juan.trim_start().starts_with('*'));
}
#[test]
fn dbf_sin_columnas() {
let DbfPreview::Grid { columnas, .. } = render(&Tabla::default()) else {
panic!("esperaba Grid vacía");
};
assert_eq!(columnas, 0);
}
/// Construye un DBF dBase III mínimo en bytes (dos campos, una fila activa
/// y una borrada) — el mismo layout que `foreign-dbf` sabe leer.
fn dbf_bytes() -> Vec<u8> {
let campos: [(&str, u8, u8); 2] = [("NOMBRE", b'C', 10), ("EDAD", b'N', 3)];
let tam_registro = 1 + campos.iter().map(|c| c.2 as usize).sum::<usize>();
let tam_cabecera = 32 + campos.len() * 32 + 1;
let mut b = vec![0u8; 32];
b[0] = 0x03;
b[8..10].copy_from_slice(&(tam_cabecera as u16).to_le_bytes());
b[10..12].copy_from_slice(&(tam_registro as u16).to_le_bytes());
for (nombre, tipo, largo) in campos {
let mut desc = vec![0u8; 32];
desc[..nombre.len()].copy_from_slice(nombre.as_bytes());
desc[11] = tipo;
desc[16] = largo;
b.extend_from_slice(&desc);
}
b.push(0x0D);
let mut activa = vec![b' '; tam_registro];
activa[1..6].copy_from_slice(b"Juana");
activa[11..13].copy_from_slice(b"34");
b.extend_from_slice(&activa);
let mut borrada = vec![b' '; tam_registro];
borrada[0] = b'*';
borrada[1..6].copy_from_slice(b"Pedro");
borrada[11..13].copy_from_slice(b"50");
b.extend_from_slice(&borrada);
b.push(0x1A);
b
}
/// End-to-end por el camino del shell: escribe un `.dbf` real a disco y lo
/// abre con `load_dbf` (leer_dbf + render).
#[test]
fn e2e_dbf_real_desde_disco() {
use std::io::Write;
let mut p = std::env::temp_dir();
p.push("nahual-dbf-e2e.dbf");
std::fs::File::create(&p).unwrap().write_all(&dbf_bytes()).unwrap();
let pane = load_dbf(&p, DEFAULT_DBF_BYTES_MAX);
let _ = std::fs::remove_file(&p);
let DbfPreview::Grid { text, filas, borrados, columnas } = pane else {
panic!("esperaba Grid tras load_dbf, no {pane:?}");
};
assert_eq!((filas, borrados, columnas), (2, 1, 2));
assert!(text.contains("NOMBRE") && text.contains("Juana") && text.contains("Pedro"));
eprintln!("--- DBF renderizado ---\n{text}");
}
}
@@ -0,0 +1,26 @@
[package]
name = "nahual-deck-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-deck-viewer-llimphi — visor read-only de presentaciones (.pptx) montado en el shell nahual. Pasa los bytes por `foreign-pptx` (un slide → un Marco) y pinta el Recorrido resultante como vista general estática (encuadre-total del deck espacial), reusando el pintor canónico de `pluma-deck-recorrido-llimphi`."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
# Puente de formato ajeno (pptx → Recorrido) y modelo del deck: viven en el
# dominio pluma, referenciados por path (no en [workspace.dependencies]).
foreign-pptx = { workspace = true }
pluma-deck-core = { workspace = true }
# Render canónico del deck: reusamos `recorrido_overview_view` (regla del repo:
# un término = un artefacto; no se reimplementa el pintor del deck).
pluma-deck-recorrido-llimphi = { workspace = true }
[dev-dependencies]
# El e2e forja un .pptx y lo hace pasar por el discernidor del shell.
shuma-discern = { workspace = true }
zip = { version = "2", default-features = false, features = ["deflate"] }
tempfile = { workspace = true }
@@ -0,0 +1,26 @@
# nahual-deck-viewer-llimphi
*Read this in English: [README.md](README.md).*
Visor read-only de presentaciones `.pptx`.
Decimocuarto visor del shell meta-app. `shuma-discern` marca los `.pptx`
(zip OOXML + extensión) con lens `pptx`; sin este visor caían al visor de
archivos (Archive), que los listaba como un zip cualquiera. Este visor los
**previsualiza como deck**: pasa los bytes por `foreign-pptx`
(`parse_pptx`: un slide `<p:sld>` → un `Marco`) y pinta el `Recorrido`
resultante como **vista general estática** — el zoom-out narrativo que
muestra todos los slides en el lienzo espacial de una vez.
No reimplementa el render del deck: reusa
`pluma_deck_recorrido_llimphi::recorrido_overview_view`, el pintor
canónico (regla del repo: un término nombrado = un artefacto que ya existe).
Abrir de verdad la presentación (Enter) sigue siendo asunto de la app
`pluma-deck` (AppBus), como el `.docx` es de la app pluma.
Patrón fino de los otros visores: carga sync en `load_pptx`, render en
`deck_viewer_view`. No conoce el AppBus: el caller pasa el path.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,18 @@
# nahual-deck-viewer-llimphi
Read-only viewer of `.pptx` presentations.
The fourteenth viewer of the meta-app shell. `shuma-discern` marks `.pptx` files
(OOXML zip + extension) with the `pptx` lens; without this viewer they fell to
the Archive viewer, which listed them as any other zip. This viewer **previews
them as a deck**: it passes the bytes through `foreign-pptx` (`parse_pptx`: one
`<p:sld>` slide → one `Marco`) and paints the resulting `Recorrido` as a **static
overview** — the narrative zoom-out showing every slide on the spatial canvas at
once.
It does not reimplement the deck render: it reuses
`pluma_deck_recorrido_llimphi::recorrido_overview_view`, the canonical painter.
---
Part of **nahual** — see [nahual](../README.md).
@@ -0,0 +1,241 @@
//! `nahual-deck-viewer-llimphi` — visor read-only de presentaciones `.pptx`.
//!
//! Decimocuarto visor del shell meta-app. `shuma-discern` marca los `.pptx`
//! (zip OOXML + extensión) con lens `pptx`; sin este visor caían al visor de
//! archivos (Archive), que los listaba como un zip cualquiera. Este visor los
//! **previsualiza como deck**: pasa los bytes por `foreign-pptx`
//! (`parse_pptx`: un slide `<p:sld>` → un `Marco`) y pinta el `Recorrido`
//! resultante como **vista general estática** — el zoom-out narrativo que
//! muestra todos los slides en el lienzo espacial de una vez.
//!
//! No reimplementa el render del deck: reusa
//! [`pluma_deck_recorrido_llimphi::recorrido_overview_view`], el pintor
//! canónico (regla del repo: un término nombrado = un artefacto que ya existe).
//! Abrir de verdad la presentación (Enter) sigue siendo asunto de la app
//! `pluma-deck` (AppBus), como el `.docx` es de la app pluma.
//!
//! Patrón fino de los otros visores: carga sync en [`load_pptx`], render en
//! [`deck_viewer_view`]. No conoce el AppBus: el caller pasa el path.
#![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;
use pluma_deck_core::Recorrido;
use pluma_deck_recorrido_llimphi::recorrido_overview_view;
/// Tope de bytes del archivo: por encima no se previsualiza (evita cargar un
/// `.pptx` de decenas de MB en el panel). Mismo espíritu que el visor docx.
pub const DEFAULT_PPTX_BYTES_MAX: u64 = 16 * 1024 * 1024;
/// Estado del preview: mismo espíritu que `PlumaPreview`/`MarkdownPreview`,
/// para que el shell lo trate igual que los demás visores.
#[derive(Debug, Clone, Default)]
pub enum DeckPreview {
/// Nada seleccionado todavía.
#[default]
Empty,
/// Archivo por encima de [`DEFAULT_PPTX_BYTES_MAX`] (bytes reales).
TooBig(u64),
/// No se pudo leer o parsear (no-zip, falta `ppt/presentation.xml`, XML roto).
Error(String),
/// Presentación importada: nombre del archivo + el recorrido con un marco
/// por slide, y el conteo de slides para el header.
Deck {
titulo: String,
rec: Recorrido,
n_slides: usize,
},
}
/// Lee un `.pptx` del disco y lo importa a un [`Recorrido`]. Carga sync (como
/// los demás visores). Cualquier fallo —tamaño, IO o parseo— cae a una
/// variante explícita, nunca paniquea.
pub fn load_pptx(path: &Path, max_bytes: u64) -> DeckPreview {
let size = match std::fs::metadata(path) {
Ok(m) => m.len(),
Err(e) => return DeckPreview::Error(e.to_string()),
};
if size > max_bytes {
return DeckPreview::TooBig(size);
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return DeckPreview::Error(e.to_string()),
};
let titulo = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
match foreign_pptx::parse_pptx(&bytes) {
Ok(rec) => {
let n_slides = rec.marcos.len();
DeckPreview::Deck {
titulo,
rec,
n_slides,
}
}
Err(e) => DeckPreview::Error(e.to_string()),
}
}
/// Paleta del viewer (sólo el chrome; el lienzo del deck trae sus colores).
#[derive(Debug, Clone, Copy)]
pub struct DeckViewerPalette {
pub bg: Color,
pub fg_muted: Color,
pub fg_error: Color,
}
impl Default for DeckViewerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl DeckViewerPalette {
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 del archivo + conteo de slides) + el deck en vista
/// general estática.
pub fn deck_viewer_view<Msg>(
state: &DeckPreview,
path: Option<&Path>,
palette: &DeckViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let header_text = match (path, state) {
(Some(p), DeckPreview::Deck { n_slides, rec, .. }) => {
let nombre = p
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string());
let con_notas = rec
.marcos
.iter()
.filter(|m| m.notas.as_deref().is_some_and(|s| !s.trim().is_empty()))
.count();
let sufijo = if con_notas > 0 {
format!(" · {con_notas} con notas")
} else {
String::new()
};
format!(
"deck · {nombre} · {n_slides} slide{}{sufijo}",
if *n_slides == 1 { "" } else { "s" }
)
}
(Some(p), _) => format!(
"deck · {}",
p.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
(None, _) => "(selecciona un .pptx)".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 {
DeckPreview::Empty => simple_body("", palette.fg_muted),
DeckPreview::TooBig(n) => simple_body(
&format!("(presentación muy grande: {n} bytes — sin preview)"),
palette.fg_muted,
),
DeckPreview::Error(e) => {
simple_body(&format!("(no se pudo abrir: {e})"), palette.fg_error)
}
DeckPreview::Deck { rec, n_slides, .. } => {
if *n_slides == 0 {
simple_body("(presentación vacía)", palette.fg_muted)
} else {
// Deck a lo ancho/alto del panel: el pintor canónico encuadra
// TODOS los slides (vista general) computando la cámara contra
// el panel real. Read-only: sin drag ni navegación.
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![recorrido_overview_view::<Msg>(rec)])
}
}
};
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])
}
/// Body de una sola línea (estados Empty/TooBig/Error/vacío).
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),
}
}
@@ -0,0 +1,111 @@
//! Prueba e2e: genera un `.pptx` real (zip OOXML forjado a mano), lo escribe a
//! disco, lo hace pasar por `shuma-discern` + el ruteo del shell y confirma que
//! (a) discern lo marca lens `pptx`, (b) `load_pptx` recupera un Recorrido con
//! un marco por slide en orden. Cierra el lazo puente→discern→viewer sin GPU.
use std::io::Write;
use nahual_deck_viewer_llimphi::{load_pptx, DeckPreview, DEFAULT_PPTX_BYTES_MAX};
use pluma_deck_core::ContenidoMarco;
/// Construye un `.pptx` mínimo: `ppt/presentation.xml` + rels con un rId por
/// slide, y un `ppt/slides/slideN.xml` con título + un cuerpo de bullets.
fn pptx_con_slides(slides: &[(&str, &[&str])]) -> Vec<u8> {
use zip::write::SimpleFileOptions;
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = SimpleFileOptions::default();
let mut sld_ids = String::new();
let mut pres_rels = String::from(
r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
);
for (i, _) in slides.iter().enumerate() {
let rid = format!("rId{}", i + 1);
sld_ids.push_str(&format!(r#"<p:sldId id="{}" r:id="{}"/>"#, 256 + i, rid));
pres_rels.push_str(&format!(
r#"<Relationship Id="{rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide{}.xml"/>"#,
i + 1
));
}
pres_rels.push_str("</Relationships>");
zip.start_file("ppt/presentation.xml", opts).unwrap();
zip.write_all(format!(
r#"<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst>{sld_ids}</p:sldIdLst></p:presentation>"#
).as_bytes()).unwrap();
zip.start_file("ppt/_rels/presentation.xml.rels", opts).unwrap();
zip.write_all(pres_rels.as_bytes()).unwrap();
for (i, (titulo, parrafos)) in slides.iter().enumerate() {
let n = i + 1;
let mut body = String::new();
for p in *parrafos {
body.push_str(&format!(r#"<a:p><a:r><a:t>{p}</a:t></a:r></a:p>"#));
}
let slide = format!(
r#"<?xml version="1.0"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:txBody><a:p><a:r><a:t>{titulo}</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:nvSpPr><p:nvPr><p:ph type="body"/></p:nvPr></p:nvSpPr><p:txBody>{body}</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#
);
zip.start_file(format!("ppt/slides/slide{n}.xml"), opts).unwrap();
zip.write_all(slide.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
buf
}
#[test]
fn pptx_real_se_rutea_y_extrae_slides() {
let bytes = pptx_con_slides(&[
("Portada", &["Subtítulo"][..]),
("Agenda", &["Uno", "Dos"][..]),
]);
// (a) discern lo marca lens `pptx` (con el path como hint).
let pipeline = shuma_discern::DiscernPipeline::default();
let hint = shuma_discern::Hint {
path: Some("charla.pptx"),
size_total: Some(bytes.len() as u64),
};
let d = pipeline.discern(&bytes, &hint).expect("discernido");
assert_eq!(d.lens.as_deref(), Some("pptx"), "discern debe dar lens pptx");
// (b) load_pptx recupera un Recorrido con un marco por slide, en orden.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("charla.pptx");
std::fs::write(&path, &bytes).unwrap();
match load_pptx(&path, DEFAULT_PPTX_BYTES_MAX) {
DeckPreview::Deck { rec, n_slides, .. } => {
assert_eq!(n_slides, 2);
assert_eq!(rec.pasos, vec![1, 2]);
match &rec.marcos[0].contenido {
ContenidoMarco::Texto { titulo, parrafos } => {
assert_eq!(titulo.as_deref(), Some("Portada"));
assert_eq!(parrafos, &["Subtítulo".to_string()]);
}
otro => panic!("esperaba Texto, salió {otro:?}"),
}
match &rec.marcos[1].contenido {
ContenidoMarco::Texto { titulo, parrafos } => {
assert_eq!(titulo.as_deref(), Some("Agenda"));
assert_eq!(parrafos, &["Uno".to_string(), "Dos".to_string()]);
}
otro => panic!("esperaba Texto, salió {otro:?}"),
}
}
otro => panic!("esperaba Deck, salió {otro:?}"),
}
}
#[test]
fn archivo_no_pptx_da_error_no_panic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("roto.pptx");
std::fs::write(&path, b"esto no es un zip").unwrap();
assert!(matches!(
load_pptx(&path, DEFAULT_PPTX_BYTES_MAX),
DeckPreview::Error(_)
));
}
@@ -9,6 +9,9 @@ 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]
rimay-localize = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-widget-empty = { workspace = true }
llimphi-widget-list = { workspace = true }
@@ -22,12 +22,44 @@
#![forbid(unsafe_code)]
use std::cmp::min;
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use llimphi_icons::Icon;
use llimphi_theme::{alpha, motion};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::View;
use llimphi_widget_empty::{empty_view, EmptyPalette};
use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
/// Hash estable de una cadena → `key` para las animaciones implícitas de
/// Llimphi. El mismo `cwd` produce siempre la misma key entre rebuilds,
/// así el pop-in corre sólo al cambiar de carpeta (no en cada repintado
/// por selección o scroll).
fn key_of(s: &str) -> u64 {
let mut h = DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
/// Deriva una [`EmptyPalette`] desde la [`ListPalette`] (que no acarrea un
/// `Theme`). Mismo criterio que `EmptyPalette::from_theme`: ícono y
/// descripción apagados sobre `fg_muted`.
fn empty_palette(p: &ListPalette) -> EmptyPalette {
use llimphi_ui::llimphi_raster::peniko::color::AlphaColor;
let dim = |a: u8| {
let [r, g, b, _] = p.fg_muted.components;
AlphaColor::new([r, g, b, a as f32 / 255.0])
};
EmptyPalette {
fg_icon: dim(alpha::HINT),
fg_title: p.fg_muted,
fg_desc: dim(alpha::DISABLED),
}
}
/// Una entrada del directorio actual.
#[derive(Clone, Debug)]
pub struct Entry {
@@ -147,7 +179,7 @@ impl FileExplorerState {
}
/// Sube al directorio padre. Si estaba parado sobre un subdir, lo
/// re-selecciona al subir (UX típica: mantenés contexto).
/// re-selecciona al subir (UX típica: mantienes contexto).
pub fn parent(&mut self) -> bool {
let Some(parent) = self.cwd.parent().map(Path::to_path_buf) else {
return false;
@@ -244,6 +276,33 @@ where
Msg: Clone + 'static,
F: Fn(usize) -> Msg,
{
let scene_key = key_of(&state.cwd.to_string_lossy());
// Carpeta vacía (o ilegible): empty-state con orientación en vez de un
// panel en blanco. Entra con el mismo pop-in que la lista.
if state.entries.is_empty() {
let pal = empty_palette(&palette);
let desc = rimay_localize::t_args(
"nahual-fe-no-entries",
&[("path", state.cwd.display().to_string().into())],
);
return View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette.bg_panel)
.children(vec![empty_view(
Icon::Folder,
rimay_localize::t("nahual-fe-empty"),
Some(&desc),
&pal,
)])
.animated_enter(scene_key, motion::NORMAL);
}
let start = state.visible_offset;
let end = min(state.entries.len(), start + state.visible_rows);
let rows: Vec<ListRow<Msg>> = (start..end)
@@ -263,19 +322,22 @@ where
})
.collect();
let caption = format!(
"{} entradas · ↑↓ navega · Enter entra · ⌫ sube",
state.entries.len()
let caption = rimay_localize::t_args(
"nahual-fe-caption",
&[("n", state.entries.len().to_string().into())],
);
let truncated_hint = if state.entries.len() > end {
Some(format!(
"… y {} más (rueda o ↓ para ver más)",
state.entries.len() - end
Some(rimay_localize::t_args(
"nahual-fe-more",
&[("n", (state.entries.len() - end).to_string().into())],
))
} else {
None
};
// Pop-in de la lista al navegar: la `scene_key` cambia con el `cwd`, así
// la lista entra con un fade suave al entrar a una carpeta nueva y queda
// estable mientras sólo cambian selección o scroll dentro de la misma.
list_view(ListSpec {
rows,
total: state.entries.len(),
@@ -284,4 +346,5 @@ where
row_height: DEFAULT_ROW_HEIGHT,
palette,
})
.animated_enter(scene_key, motion::NORMAL)
}
@@ -9,6 +9,9 @@ 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]
rimay-localize = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
ttf-parser = { workspace = true }
# Metadatos escalares de la fuente (familia, glifos, u/em…): núcleo agnóstico.
nahual-viewer-core = { workspace = true }
@@ -0,0 +1,27 @@
# nahual-font-viewer-llimphi
*Read this in English: [README.md](README.md).*
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.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,22 @@
# nahual-font-viewer-llimphi
TTF/OTF font viewer.
The eleventh viewer of the meta-app shell. No rich viewer covered a `.ttf`/`.otf`:
they fell to the text viewer as "(binary — no preview)". But a font is meant to be
*seen*. This viewer parses the file with `ttf-parser`, shows its metadata (family,
style, glyph count, units per em) and — the interesting part — **renders a sample
drawn with the file's own font**: it extracts each glyph's outlines into a
`kurbo::BezPath` and fills them into the vello scene through `paint_with`.
It does not go through parley (which only knows the system's fonts): the glyphs
are painted straight from the file's outlines, so you see exactly the font you are
inspecting even if it is not installed.
The thin pattern of the other viewers: sync loading in `load_font`, rendering in
`font_viewer_view`. It knows nothing about the AppBus: the caller passes the path.
Ugly-first MVP: a fixed sample (pangram + digits), with no size or text picker yet.
---
Part of **nahual** — see [nahual](../README.md).
@@ -30,6 +30,9 @@ 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;
// Los metadatos escalares de la fuente (familia, glifos, u/em…) viven en el core
// agnóstico `nahual-viewer-core::font`; aquí sólo se arman los contornos (render).
use nahual_viewer_core::font::FontMeta;
/// Tope de bytes a leer (32 MiB). Una fuente más grande es rara; el
/// caller puede subirlo.
@@ -51,15 +54,11 @@ pub struct SampleLine {
pub width: f64,
}
/// Metadatos + muestras renderizables de una fuente abierta.
/// Metadatos + muestras renderizables de una fuente abierta. Los escalares
/// viven en [`FontMeta`] (core agnóstico); las `lines` son contornos (render).
#[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 meta: FontMeta,
pub lines: Vec<SampleLine>,
}
@@ -115,42 +114,29 @@ pub fn load_font(path: &Path, max_bytes: u64) -> FontPreview {
};
let face = match ttf_parser::Face::parse(&bytes, 0) {
Ok(f) => f,
Err(e) => return FontPreview::Error(format!("no parsea como fuente: {e}")),
Err(e) => {
return FontPreview::Error(rimay_localize::t_args(
"nahual-font-parse-error",
&[("err", e.to_string().into())],
))
}
};
FontPreview::Font(Box::new(build_info(&face)))
}
/// Extrae metadatos y arma las líneas de muestra a partir de una `Face`.
/// Arma las líneas de muestra (contornos) a partir de una `Face`; los
/// metadatos escalares salen del core agnóstico (`nahual_viewer_core::font`).
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(),
meta: nahual_viewer_core::font::meta_from_face(face),
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 {
@@ -221,7 +207,7 @@ where
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
None => "(seleccioná una fuente TTF/OTF)".to_string(),
None => rimay_localize::t("nahual-font-select"),
};
let header = View::new(Style {
@@ -239,20 +225,20 @@ where
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),
info_line(&rimay_localize::t_args("nahual-font-toobig", &[("bytes", n.to_string().into())]), palette.fg_muted),
],
FontPreview::Error(e) => {
vec![header, info_line(&format!("(no se pudo abrir: {e})"), palette.fg_error)]
vec![header, info_line(&rimay_localize::t_args("nahual-font-error", &[("err", e.to_string().into())]), 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,
info.meta.family,
info.meta.subfamily,
info.meta.num_glyphs,
info.meta.units_per_em,
info.meta.ascender,
info.meta.descender,
);
vec![
header,
@@ -283,16 +269,16 @@ where
}
/// Lienzo que dibuja las líneas de muestra rellenando los contornos de
/// glifo. Los paths vienen en unidades de fuente; a los escalamos para
/// glifo. Los paths vienen en unidades de fuente; aquí 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 em = info.meta.units_per_em.max(1) as f64;
let ascender = info.meta.ascender as f64;
let descender = info.meta.descender as f64;
let glyph_color = palette.glyph;
View::new(Style {
@@ -12,6 +12,7 @@ name = "nahual-gallery"
path = "src/main.rs"
[dependencies]
rimay-localize = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-grid = { workspace = true }
@@ -20,4 +21,7 @@ nahual-thumb-core = { workspace = true }
nahual-image-viewer-llimphi = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-widget-skeleton = { workspace = true }
llimphi-widget-empty = { workspace = true }
llimphi-icons = { workspace = true }
app-bus = { workspace = true }
@@ -0,0 +1,38 @@
# nahual-gallery-llimphi
*Read this in English: [README.md](README.md).*
Galería de miniaturas tipo gThumb / FastStone.
Cose las dos piezas del pipeline de galería:
- `llimphi_widget_grid` → virtualización 2D (sólo monta la ventana
visible, aunque la carpeta tenga miles de imágenes).
- `nahual_thumb_core` → genera las miniaturas (decode + downscale),
las cachea en RAM y planifica la cola priorizada al viewport.
La concurrencia la pone `Handle::spawn`: por cada path que el
planificador entrega, lanzamos un thread que decodifica y reentra con
`Msg::ThumbListo`. Mientras tanto la celda muestra un placeholder.
Navegación: la grilla mezcla subcarpetas (ícono 📁, primero) e
imágenes. Un clic en carpeta entra; en imagen selecciona (⏎/espacio
abre el preview). `⌫` sube al padre y el breadcrumb salta a cualquier
ancestro. `o` cicla el orden de las imágenes (nombre/tamaño/fecha).
Uso: `cargo run -p nahual-gallery-llimphi --release -- <carpeta>`
(sin argumento usa el directorio actual).
Limitación MVP: el tamaño del viewport se asume fijo (= `initial_size`)
porque el trait `App` de Llimphi todavía no expone un hook de resize —
mismo atajo que `nahual-file-explorer`. Al achicar/agrandar la ventana
las columnas no se recalculan hasta que eso exista.
## Uso
```sh
cargo run --release -p nahual-gallery-llimphi --bin nahual-gallery
```
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,34 @@
# nahual-gallery-llimphi
A thumbnail gallery in the gThumb / FastStone vein.
It stitches together the two pieces of the gallery pipeline:
- `llimphi_widget_grid` → 2D virtualization (it only mounts the visible window,
even if the folder holds thousands of images).
- `nahual_thumb_core` → generates the thumbnails (decode + downscale), caches
them in RAM and schedules the queue prioritized to the viewport.
Concurrency comes from `Handle::spawn`: for every path the scheduler hands over,
a thread decodes and re-enters with `Msg::ThumbReady`. Meanwhile the cell shows a
placeholder.
Navigation: the grid mixes subfolders (📁 icon, first) and images. A click on a
folder enters it; on an image it selects (⏎/space opens the preview). `⌫` goes up
to the parent and the breadcrumb jumps to any ancestor. `o` cycles the images'
ordering (name/size/date).
MVP limitation: the viewport size is assumed fixed (= `initial_size`) because
Llimphi's `App` trait does not yet expose a resize hook — the same shortcut as
`nahual-file-explorer`. Shrinking or growing the window does not recompute the
columns until that exists.
## Use
```sh
cargo run --release -p nahual-gallery-llimphi --bin nahual-gallery
```
---
Part of **nahual** — see [nahual](../README.md).
@@ -23,19 +23,27 @@
//! mismo atajo que `nahual-file-explorer`. Al achicar/agrandar la ventana
//! las columnas no se recalculan hasta que eso exista.
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_theme::{motion, Theme};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::{Blob, Image, ImageFormat};
use llimphi_ui::llimphi_raster::kurbo::Affine;
use llimphi_ui::llimphi_raster::peniko::{
Blob, ImageAlphaType, ImageBrush as Image, ImageData, ImageFormat,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
use llimphi_icons::Icon;
use llimphi_widget_empty::{empty_view, EmptyPalette};
use llimphi_widget_skeleton::{skeleton_view, SkeletonPalette};
use llimphi_widget_breadcrumb::{breadcrumb_view, BreadcrumbPalette};
use llimphi_widget_grid::{grid_view, ventana_visible, GridCell, GridMetrics, GridPalette};
use nahual_image_viewer_llimphi::{
@@ -99,6 +107,19 @@ fn humano(n: u64) -> String {
}
}
/// Hash estable de una cadena → `key` para animaciones implícitas (la misma
/// ruta/escena produce siempre la misma key entre rebuilds, así el fade-in
/// corre sólo la primera vez que el nodo aparece).
fn key_of(s: &str) -> u64 {
let mut h = DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
/// Intervalo del tick de animación: fuerza repaint para que el shimmer del
/// skeleton corra mientras haya miniaturas decodificándose.
const TICK_MS: u64 = 50;
/// Extensiones que tratamos como imagen (alineadas con las features del
/// crate `image` en el workspace: png/jpeg/webp).
const EXTS: &[&str] = &["png", "jpg", "jpeg", "webp"];
@@ -133,12 +154,12 @@ impl Orden {
Orden::Fecha => Orden::Nombre,
}
}
fn etiqueta(self) -> &'static str {
match self {
Orden::Nombre => "nombre",
Orden::Tamano => "tamaño",
Orden::Fecha => "fecha",
}
fn etiqueta(self) -> String {
rimay_localize::t(match self {
Orden::Nombre => "nahual-gallery-sort-name",
Orden::Tamano => "nahual-gallery-sort-size",
Orden::Fecha => "nahual-gallery-sort-date",
})
}
}
@@ -186,6 +207,9 @@ enum Msg {
ContextMenuOpen(f32, f32),
/// Cicla la paleta de tema.
CiclarTema,
/// Tick de animación: fuerza repaint para el shimmer del skeleton mientras
/// haya miniaturas en vuelo. Se auto-rearma sólo si siguen faltando.
Tick,
}
struct Model {
@@ -218,6 +242,8 @@ struct Model {
menu_open: Option<usize>,
/// Menú contextual abierto en (x, y) de ventana, si lo hay.
context_menu: Option<(f32, f32)>,
/// Hay una cadena de `Msg::Tick` en vuelo (evita rearmar dos).
ticking: bool,
}
struct Gallery;
@@ -235,6 +261,7 @@ impl App for Gallery {
}
fn init(handle: &Handle<Msg>) -> Model {
rimay_localize::init();
let dir = std::env::args()
.nth(1)
.map(PathBuf::from)
@@ -248,7 +275,7 @@ impl App for Gallery {
gap: 10.0,
pad: 12.0,
};
let estado = format!("{} ítems", entries.len());
let estado = rimay_localize::t_args("nahual-gallery-items", &[("n", entries.len().to_string().into())]);
let mut m = Model {
dir,
entries,
@@ -268,8 +295,10 @@ impl App for Gallery {
theme: Theme::dark(),
menu_open: None,
context_menu: None,
ticking: false,
};
bombear(&mut m, handle);
ensure_tick(&mut m, handle);
m
}
@@ -330,14 +359,23 @@ impl App for Gallery {
}
Msg::ThumbListo(path, t) => {
m.plan.completar(&path);
let img = Image::new(Blob::from(t.rgba), ImageFormat::Rgba8, t.w, t.h);
let img = Image::new(ImageData {
data: Blob::from(t.rgba),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: t.w,
height: t.h,
});
m.thumbs.insert(path, img);
// Encadenar: liberado un cupo, pedir el próximo lote.
bombear(&mut m, handle);
}
Msg::ThumbFallo(path, e) => {
m.plan.completar(&path);
m.estado = format!("falló {}: {e}", nombre(&path));
m.estado = rimay_localize::t_args(
"nahual-gallery-thumb-fail",
&[("name", nombre(&path).into()), ("err", e.to_string().into())],
);
m.fallidos.insert(path);
bombear(&mut m, handle);
}
@@ -398,7 +436,7 @@ impl App for Gallery {
m.entries = listar(&m.dir, m.orden);
m.seleccionado =
sel_path.and_then(|p| m.entries.iter().position(|e| e.path() == p));
m.estado = format!("orden: {}", m.orden.etiqueta());
m.estado = rimay_localize::t_args("nahual-gallery-order", &[("order", m.orden.etiqueta().into())]);
bombear(&mut m, handle);
}
Msg::Activar(i) => {
@@ -440,7 +478,14 @@ impl App for Gallery {
Msg::CiclarTema => {
m.theme = Theme::next_after(m.theme.name);
}
Msg::Tick => {
// El thread durmió TICK_MS; sólo rearmamos si siguen faltando
// miniaturas (lo hace `ensure_tick` más abajo).
m.ticking = false;
}
}
// Si quedaron miniaturas decodificándose, mantén el shimmer animado.
ensure_tick(&mut m, handle);
m
}
@@ -471,26 +516,28 @@ impl App for Gallery {
let sel = model.seleccionado.and_then(|i| model.entries.get(i));
let header = sel
.map(|e| nombre(e.path()))
.unwrap_or_else(|| "galería".to_string());
.unwrap_or_else(|| rimay_localize::t("nahual-gallery-header-default"));
let es_carpeta = sel.map(|e| e.es_carpeta()).unwrap_or(false);
// Acciones reales según la entrada: carpeta ⇒ entrar; imagen ⇒
// abrir preview. Más reset de zoom / ciclar orden, siempre útiles.
let zoom_reset = rimay_localize::t("nahual-gallery-zoom-reset");
let cycle_order = rimay_localize::t("nahual-gallery-cycle-order");
let items = if es_carpeta {
vec![
ContextMenuItem::action("Entrar a la carpeta"),
ContextMenuItem::action("Reiniciar zoom"),
ContextMenuItem::action("Ciclar orden"),
ContextMenuItem::action(rimay_localize::t("nahual-gallery-enter-folder")),
ContextMenuItem::action(zoom_reset.clone()),
ContextMenuItem::action(cycle_order.clone()),
]
} else if sel.is_some() {
vec![
ContextMenuItem::action("Abrir imagen"),
ContextMenuItem::action("Reiniciar zoom"),
ContextMenuItem::action("Ciclar orden"),
ContextMenuItem::action(rimay_localize::t("nahual-gallery-open-image")),
ContextMenuItem::action(zoom_reset.clone()),
ContextMenuItem::action(cycle_order.clone()),
]
} else {
vec![
ContextMenuItem::action("Reiniciar zoom"),
ContextMenuItem::action("Ciclar orden"),
ContextMenuItem::action(zoom_reset),
ContextMenuItem::action(cycle_order),
]
};
let idx = model.seleccionado;
@@ -544,21 +591,21 @@ impl App for Gallery {
let ruta = barra_ruta(model);
let cuerpo: View<Msg> = if model.entries.is_empty() {
// Carpeta vacía: empty-state con orientación en vez de un hueco.
let pal = EmptyPalette::from_theme(&theme);
let desc = rimay_localize::t_args(
"nahual-gallery-empty-desc",
&[("path", model.dir.display().to_string().into())],
);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_panel)
.text(
format!("sin imágenes ni subcarpetas en {}", model.dir.display()),
14.0,
theme.fg_muted,
)
.children(vec![empty_view(Icon::Image, rimay_localize::t("nahual-fe-empty"), Some(&desc), &pal)])
} else {
let cells: Vec<GridCell<Msg>> = (v.first..v.first + v.count)
.map(|i| {
@@ -589,12 +636,23 @@ impl App for Gallery {
cols: v.cols,
metrics: model.metrics,
caption: None,
truncated_hint: (mostrados < model.entries.len())
.then(|| format!("… y {} más abajo", model.entries.len() - mostrados)),
truncated_hint: (mostrados < model.entries.len()).then(|| {
rimay_localize::t_args(
"nahual-gallery-more",
&[("n", (model.entries.len() - mostrados).to_string().into())],
)
}),
palette: GridPalette::from_theme(&theme),
})
};
// Transición de escena: al navegar de carpeta o cambiar el orden, la
// `scene_key` cambia y el cuerpo entra con un fade + slide-up suave en
// vez de saltar. Estable durante la carga de miniaturas de la misma
// escena, así no refadea por cada thumb que llega.
let scene_key = key_of(&format!("{}|{}", model.dir.display(), model.orden.etiqueta()));
let cuerpo = cuerpo.animated_enter_from(scene_key, motion::SLOW, Affine::translate((0.0, 24.0)));
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
@@ -633,15 +691,23 @@ fn celda_contenido(model: &Model, e: &Entrada) -> View<Msg> {
}
let path = e.path();
if let Some(img) = model.thumbs.get(path) {
View::new(base()).image(img.clone())
// La miniatura entra con fade-in la primera vez que aparece su key —
// no salta de golpe sobre el skeleton.
View::new(base())
.image(img.clone())
.animated_enter(key_of(&path.to_string_lossy()), motion::NORMAL)
} else if model.fallidos.contains(path) {
View::new(base())
.fill(theme.bg_panel_alt)
.text("".to_string(), 20.0, theme.fg_muted)
} else {
// Placeholder: rectángulo tenue mientras decodifica (MVP — sin
// animación; el widget-skeleton se puede enchufar acá luego).
View::new(base()).fill(theme.bg_panel_alt)
// Placeholder con shimmer mientras decodifica: el usuario ve la forma
// de la miniatura que viene, no un rectángulo muerto.
let pal = SkeletonPalette::from_theme(theme);
View::new(base())
.radius(6.0)
.clip(true)
.children(vec![skeleton_view(&pal)])
}
}
@@ -700,13 +766,15 @@ fn navegar_a(m: &mut Model, dir: PathBuf, handle: &Handle<Msg>) {
/// Barra superior: carpeta, conteo, fila actual y estado.
fn encabezado(model: &Model, v: &llimphi_widget_grid::VisibleWindow) -> View<Msg> {
let theme = &model.theme;
let texto = format!(
"{} ítems · fila {}/{} · orden:{} (o) · ⏎ abrir · ⌫ subir · +/ zoom · s slideshow · {}",
model.entries.len(),
v.first_row + 1,
v.total_rows.max(1),
model.orden.etiqueta(),
model.estado,
let texto = rimay_localize::t_args(
"nahual-gallery-header",
&[
("items", model.entries.len().to_string().into()),
("row", (v.first_row + 1).to_string().into()),
("total", v.total_rows.max(1).to_string().into()),
("order", model.orden.etiqueta().into()),
("estado", model.estado.clone().into()),
],
);
View::new(Style {
size: Size {
@@ -847,6 +915,36 @@ fn ordenar_paths(paths: &mut [PathBuf], orden: Orden) {
}
}
/// ¿Hay miniaturas visibles aún sin decodificar (ni fallidas)? Es decir,
/// celdas que ahora mismo pintan un skeleton — mientras sea cierto, el
/// shimmer necesita repaints periódicos.
fn hay_pendientes(m: &Model) -> bool {
if m.entries.is_empty() {
return false;
}
let v = ventana_visible(m.entries.len(), m.vw, m.vh, m.scroll_fila, &m.metrics);
(v.first..v.first + v.count).any(|i| match &m.entries[i] {
Entrada::Imagen { path, .. } => {
!m.thumbs.contains_key(path) && !m.fallidos.contains(path)
}
Entrada::Carpeta(_) => false,
})
}
/// Arranca la cadena de ticks de animación si hay miniaturas en vuelo y no
/// hay ya una corriendo. La cadena se auto-detiene cuando todo lo visible
/// quedó decodificado, así no queda un loop de repaint ocioso.
fn ensure_tick(m: &mut Model, handle: &Handle<Msg>) {
if m.ticking || !hay_pendientes(m) {
return;
}
m.ticking = true;
handle.spawn(move || {
std::thread::sleep(Duration::from_millis(TICK_MS));
Msg::Tick
});
}
/// Recalcula la ventana visible, encola los thumbs que falten, olvida los
/// que se fueron de pantalla y lanza el próximo lote de generación.
fn bombear(m: &mut Model, handle: &Handle<Msg>) {
@@ -969,21 +1067,22 @@ fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuB
/// Menú principal de la galería. Archivo / Ver / Ayuda — sólo comandos que
/// mapean a `Msg` reales. Sin "Editar": no hay campos de texto editables.
fn app_menu() -> AppMenu {
use rimay_localize::t;
AppMenu::new()
.menu(
Menu::new("Archivo")
.item(MenuItem::new("Subir a carpeta padre", "file.up").shortcut("Backspace"))
.item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q").separated()),
Menu::new(t("nahual-gallery-menu-file"))
.item(MenuItem::new(t("nahual-gallery-up"), "file.up").shortcut("Backspace"))
.item(MenuItem::new(t("exit"), "file.quit").shortcut("Ctrl+Q").separated()),
)
.menu(
Menu::new("Ver")
.item(MenuItem::new("Acercar (zoom +)", "view.zoom_in").shortcut("+"))
.item(MenuItem::new("Alejar (zoom )", "view.zoom_out").shortcut("-"))
.item(MenuItem::new("Reiniciar zoom", "view.zoom_reset"))
.item(MenuItem::new("Ciclar orden", "view.orden").shortcut("o").separated())
.item(MenuItem::new("Cambiar tema", "view.theme")),
Menu::new(t("nahual-gallery-menu-view"))
.item(MenuItem::new(t("nahual-gallery-zoom-in"), "view.zoom_in").shortcut("+"))
.item(MenuItem::new(t("nahual-gallery-zoom-out"), "view.zoom_out").shortcut("-"))
.item(MenuItem::new(t("nahual-gallery-zoom-reset"), "view.zoom_reset"))
.item(MenuItem::new(t("nahual-gallery-cycle-order"), "view.orden").shortcut("o").separated())
.item(MenuItem::new(t("cycle-theme"), "view.theme")),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
.menu(Menu::new(t("help")).item(MenuItem::new(t("about"), "help.about")))
}
/// Traduce un command id del menú principal a su efecto real.
+17
View File
@@ -0,0 +1,17 @@
# nahual-geo-core
*Read this in English: [README.md](README.md).*
Núcleo geoespacial del visor de mapas de nahual, **agnóstico de GUI**.
Parsers (GeoJSON/GPX/KML, PMTiles v3, MVT), modelo del mundo
(`MapData`/`BBox`/`FeatureProps`/`MapView`), proyección equirectangular
con cámara, hit-test, búsqueda, ruteo A* y basemap por viewport. Sin
render: el frontend (`nahual-map-viewer-llimphi` u otro) sólo lo pinta.
Antes vivía dentro del crate `*-llimphi`; extraído para cumplir la
regla #2 del repo (UIs intercambiables sobre cores agnósticos).
---
Parte de **nahual** — ver [nahual](../LEEME.md).
+15
View File
@@ -0,0 +1,15 @@
# nahual-geo-core
The geospatial core of nahual's map viewer, **GUI-agnostic**.
Parsers (GeoJSON/GPX/KML, PMTiles v3, MVT), the world model
(`MapData`/`BBox`/`FeatureProps`/`MapView`), equirectangular projection with a
camera, hit-testing, search, A* routing and a per-viewport basemap. No render:
the frontend (`nahual-map-viewer-llimphi` or another) merely paints it.
It used to live inside the `*-llimphi` crate; extracted to honour the repo's rule
#2 (interchangeable UIs over agnostic cores).
---
Part of **nahual** — see [nahual](../README.md).
@@ -0,0 +1,329 @@
//! Basemap PMTiles: carga de vista general, basemap vivo con streaming por
//! viewport y caché LRU, mapa-base mundial embebido y estadísticas.
use std::collections::HashMap;
use crate::parsers::parse_into;
use crate::tipos::{BBox, MapData, MapPreview, MapView};
use crate::vt;
// ─── Magic PMTiles ────────────────────────────────────────────────────────────
/// Magic de un archivo PMTiles v3.
pub const PMTILES_MAGIC: &[u8] = b"PMTiles";
// ─── Decodificación MVT ───────────────────────────────────────────────────────
/// Decodifica un tile vectorial MVT (`bytes` en `z/x/y`) a un [`MapData`]
/// renderizable, reusando toda la maquinaria del visor: cada feature del tile
/// queda con su capa de origen como nombre (calle/agua/edificio…). Es la
/// costura entre el decoder soberano de [`vt`] y el render existente; sobre
/// esto se monta el basemap PMTiles cuando exista el lector del contenedor.
pub fn mvt_tile_to_mapdata(bytes: &[u8], z: u32, x: u32, y: u32) -> MapData {
use crate::geom::{push_line, push_points, push_polygon};
use crate::geom::make_feature;
use crate::tipos::MAX_VERTICES;
let mut data = MapData::default();
let mut budget = MAX_VERTICES;
for tf in vt::decode_mvt_tile(bytes, z, x, y) {
if budget == 0 {
break;
}
let fi = make_feature(&mut data, Some(&tf.layer));
match tf.geom {
vt::TileGeom::Point(c) => {
push_points(&mut data, std::slice::from_ref(&c), &mut budget, fi)
}
vt::TileGeom::Line(l) => push_line(&mut data, l, &mut budget, fi),
vt::TileGeom::Polygon(rings) => push_polygon(&mut data, rings, &mut budget, fi),
}
}
data
}
// ─── Vista general ────────────────────────────────────────────────────────────
/// Zoom de vista general: el más alto cuyos tiles no superen ~64 (pocos tiles
/// que igual cubren el contenido). Lo comparten el overview y el cálculo de
/// extensión, para que coincidan.
fn overview_zoom(h: &crate::pmtiles::Header) -> u32 {
let mut chosen = h.min_zoom as u32;
for z in h.min_zoom as u32..=h.max_zoom as u32 {
let span = 1u32 << z;
if span.saturating_mul(span) <= 64 {
chosen = z;
} else {
break;
}
}
chosen
}
/// Extensión geográfica del basemap, para anclar la proyección. Usa los bounds
/// del header si son **sanos**; si están rotos (algunos generadores dejan
/// `max_lon=0` u otros campos en cero — visto en exportes bbbike/tilemaker),
/// los deriva de la geometría real del zoom mínimo; en último caso, mundo.
pub fn pmtiles_extent(pm: &crate::pmtiles::PmTiles) -> BBox {
const WORLD: BBox = BBox {
min_lon: -180.0,
min_lat: -85.05,
max_lon: 180.0,
max_lat: 85.05,
};
let h = &pm.header;
let header_ok = h.max_lon > h.min_lon
&& h.max_lat > h.min_lat
&& h.min_lon >= -180.5
&& h.max_lon <= 180.5
&& h.min_lat >= -85.5
&& h.max_lat <= 85.5
// Campo de longitud/latitud faltante (queda en 0 mientras el otro no).
&& !(h.min_lon != 0.0 && h.max_lon == 0.0)
&& !(h.min_lat != 0.0 && h.max_lat == 0.0);
if header_ok {
return BBox {
min_lon: h.min_lon,
min_lat: h.min_lat,
max_lon: h.max_lon,
max_lat: h.max_lat,
};
}
// Header roto: derivar de la geometría a la vista general (mismo zoom que
// el overview, para que la bbox cubra todo lo que se muestra).
let z = overview_zoom(h);
let span = 1u32 << z;
let mut bb = BBox::empty();
let mut n = 0;
'outer: for x in 0..span {
for y in 0..span {
if n >= 64 {
break 'outer;
}
n += 1;
if let Some(bytes) = pm.tile(z, x, y) {
if let Some(b) = mvt_tile_to_mapdata(&bytes, z, x, y).bbox() {
bb.expand([b.min_lon, b.min_lat]);
bb.expand([b.max_lon, b.max_lat]);
}
}
}
}
if bb.is_empty() {
WORLD
} else {
bb
}
}
/// Carga una **vista general** de un `.pmtiles`: decodifica los tiles del zoom
/// más bajo que cubra el contenido (pocos tiles) y los funde en un [`MapData`].
/// Es el basemap soberano en su forma MVP: muestra el mapa completo a baja
/// resolución, reutilizando todo el render. El streaming por viewport (más
/// detalle al hacer zoom) es el paso siguiente.
pub fn load_pmtiles_overview(bytes: Vec<u8>) -> MapPreview {
let pm = match crate::pmtiles::PmTiles::from_bytes(bytes) {
Ok(p) => p,
Err(e) => return MapPreview::Error(e),
};
if pm.header.tile_type != 1 {
return MapPreview::Error("pmtiles: sólo se soportan tiles MVT".into());
}
let chosen = overview_zoom(&pm.header);
let span = 1u32 << chosen;
let mut data = MapData::default();
// Ancla la proyección a los bounds del archivo (marco estable al streamear).
data.bbox_override = Some(pmtiles_extent(&pm));
for x in 0..span {
for y in 0..span {
if let Some(tile) = pm.tile(chosen, x, y) {
data.append(mvt_tile_to_mapdata(&tile, chosen, x, y));
}
}
}
if data.total_features() == 0 {
MapPreview::NoGeometry
} else {
MapPreview::Map {
data,
truncated: false,
}
}
}
// ─── Mapa-base mundial embebido ───────────────────────────────────────────────
/// El mapa-base mundial (Natural Earth admin-0, 177 países) embebido en el
/// binario y parseado una sola vez. Da contexto geográfico a cualquier dato
/// — offline, sin red ni tiles. Si por algo no parseara, queda vacío y el
/// visor simplemente no pinta fondo.
pub fn world_base() -> &'static MapData {
use std::sync::OnceLock;
static WORLD: OnceLock<MapData> = OnceLock::new();
WORLD.get_or_init(|| {
const SRC: &str = include_str!("../assets/world-countries.geojson");
// Tope amplio: el dataset tiene decenas de miles de vértices y no
// queremos recortarlo como a un documento de usuario.
parse_into(SRC, 4_000_000)
.map(|(d, _)| d)
.unwrap_or_default()
})
}
/// `(polígonos, vértices, países)` del mapa-base embebido. Diagnóstico para
/// herramientas/ejemplos (verificar que el asset cargó sin abrir ventana).
pub fn world_base_stats() -> (usize, usize, usize) {
let w = world_base();
(w.polygons.len(), w.vertex_count(), w.labels.len())
}
// ─── Basemap vivo ─────────────────────────────────────────────────────────────
/// Entrada de caché: tile decodificado + último reloj en que se usó.
pub struct CacheEntry {
pub used: u64,
pub data: MapData,
}
/// Basemap PMTiles **vivo**: mantiene el contenedor abierto y una caché de
/// tiles decodificados, y entrega el [`MapData`] visible para la cámara actual
/// (streaming por viewport). Sin red: todo sale del archivo local.
///
/// El host lo guarda mientras un `.pmtiles` esté abierto y llama a
/// [`Basemap::viewport`] cuando la cámara cambia.
pub struct Basemap {
pm: crate::pmtiles::PmTiles,
bounds: BBox,
/// Tiles ya decodificados (`(z,x,y)` → geometrías), con marca de uso para
/// el desalojo LRU.
cache: HashMap<(u32, u32, u32), CacheEntry>,
/// Reloj lógico monótono: cada viewport lo incrementa y marca los tiles
/// que toca, para saber cuáles son los menos usados.
clock: u64,
}
impl Basemap {
/// Abre un `.pmtiles` ya en memoria como basemap vivo.
pub fn open(bytes: Vec<u8>) -> Result<Self, String> {
let pm = crate::pmtiles::PmTiles::from_bytes(bytes)?;
if pm.header.tile_type != 1 {
return Err("pmtiles: sólo se soportan tiles MVT".into());
}
let bounds = pmtiles_extent(&pm);
Ok(Basemap {
pm,
bounds,
cache: HashMap::new(),
clock: 0,
})
}
/// Tope de tiles a fundir por viewport (evita explosiones de memoria).
const MAX_TILES: usize = 48;
/// Tope de tiles decodificados en caché (desalojo LRU al excederlo).
const CACHE_CAP: usize = 256;
/// Tiles actualmente en caché (diagnóstico/tests).
pub fn cache_len(&self) -> usize {
self.cache.len()
}
/// Devuelve el [`MapData`] visible para `view`: elige el zoom de tiles
/// según el span visible y el ancho del panel, enumera los tiles que
/// tocan el viewport, los decodifica (cacheando) y los funde. La bbox
/// queda anclada a los bounds del archivo.
pub fn viewport(&mut self, view: &MapView) -> MapData {
use crate::camara::Projection;
let mut out = MapData::default();
out.bbox_override = Some(self.bounds);
let Some((rx, ry, rw, rh)) = view.rect() else {
return out;
};
let proj = Projection::fit(
self.bounds,
(rx as f64, ry as f64, rw as f64, rh as f64),
view.zoom,
view.pan,
);
// Esquinas del panel → lon/lat (región visible).
let a = proj.inverse(rx as f64, ry as f64);
let b = proj.inverse((rx + rw) as f64, (ry + rh) as f64);
let west = a[0].min(b[0]).max(-180.0);
let east = a[0].max(b[0]).min(180.0);
let south = a[1].min(b[1]).max(-85.05);
let north = a[1].max(b[1]).min(85.05);
let zmin = self.pm.header.min_zoom as u32;
let zmax = self.pm.header.max_zoom as u32;
let z = vt::zoom_for_span(west, east, rw as f64).clamp(zmin, zmax);
// Rango de tiles visibles (Y crece hacia el sur).
let (x0, y0) = vt::lonlat_to_tile(z, west, north);
let (x1, y1) = vt::lonlat_to_tile(z, east, south);
let (x0, x1) = (x0.min(x1), x0.max(x1));
let (y0, y1) = (y0.min(y1), y0.max(y1));
// Reloj nuevo para este viewport: los tiles que toquemos quedan como
// los más recientes (a salvo del desalojo de este frame).
self.clock += 1;
let now = self.clock;
// Asegura los tiles en caché (decodificando los nuevos), tocando su
// marca de uso, respetando el tope por viewport.
let mut count = 0usize;
'outer: for x in x0..=x1 {
for y in y0..=y1 {
if count >= Self::MAX_TILES {
break 'outer;
}
count += 1;
let key = (z, x, y);
match self.cache.get_mut(&key) {
Some(entry) => entry.used = now,
None => {
let data = self
.pm
.tile(z, x, y)
.map(|bytes| mvt_tile_to_mapdata(&bytes, z, x, y))
.unwrap_or_default();
self.cache.insert(key, CacheEntry { used: now, data });
}
}
}
}
evict_lru(&mut self.cache, Self::CACHE_CAP);
// Funde lo cacheado en el viewport.
let mut merged = 0usize;
for x in x0..=x1 {
for y in y0..=y1 {
if merged >= Self::MAX_TILES {
break;
}
merged += 1;
if let Some(entry) = self.cache.get(&(z, x, y)) {
out.append(entry.data.clone());
}
}
}
out
}
}
/// Desaloja las entradas menos usadas hasta que la caché entre en `cap`.
/// Las tocadas en el viewport actual tienen el reloj más alto, así que el
/// desalojo nunca pisa lo que se está por usar.
pub fn evict_lru(cache: &mut HashMap<(u32, u32, u32), CacheEntry>, cap: usize) {
while cache.len() > cap {
// Encuentra la entrada de menor `used` (la más vieja).
let oldest = cache.iter().min_by_key(|(_, e)| e.used).map(|(k, _)| *k);
match oldest {
Some(k) => {
cache.remove(&k);
}
None => break,
}
}
}
@@ -0,0 +1,69 @@
//! Búsqueda local y soberana sobre `MapData`: ranking por nombre/propiedades,
//! tolerante a acentos y mayúsculas.
use crate::tipos::MapData;
/// Busca features cuyo nombre o propiedades casen con `query` (sin distinción
/// de mayúsculas). Ranking: igualdad > prefijo > substring; el nombre pesa
/// sobre las propiedades. Devuelve hasta `limit` índices de `data.features`.
///
/// Geocodificación local y soberana: no consulta ningún servicio externo —
/// busca dentro de lo que ya cargaste. Para buscar direcciones de medio mundo
/// alcanza con cargar un dataset (un archivo), no una API.
pub fn search(data: &MapData, query: &str, limit: usize) -> Vec<usize> {
let q = fold(&query.trim().to_lowercase());
if q.is_empty() {
return Vec::new();
}
let mut scored: Vec<(u8, usize)> = Vec::new();
for (fi, f) in data.features.iter().enumerate() {
// El nombre cuenta doble (peso 2×); las propiedades, simple.
let mut best = f
.name
.as_deref()
.map(|n| match_score(n, &q) * 2)
.unwrap_or(0);
for (_, v) in &f.props {
best = best.max(match_score(v, &q));
}
if best > 0 {
scored.push((best, fi));
}
}
// Mayor puntaje primero; a igual puntaje, orden estable por índice.
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.into_iter().take(limit).map(|(_, fi)| fi).collect()
}
/// Puntaje de coincidencia de `q` (ya en minúsculas y sin acentos) en `s`:
/// 3 igual, 2 prefijo, 1 substring, 0 nada. Plega acentos de `s` para que
/// "peru" encuentre "Perú".
fn match_score(s: &str, q: &str) -> u8 {
let l = fold(&s.to_lowercase());
if l == q {
3
} else if l.starts_with(q) {
2
} else if l.contains(q) {
1
} else {
0
}
}
/// Plega acentos latinos comunes (es/pt) a su vocal base, para búsqueda
/// tolerante a tildes. No es Unicode-completo, sólo lo usual en topónimos.
fn fold(s: &str) -> String {
s.chars()
.map(|c| match c {
'á' | 'à' | 'ä' | 'â' | 'ã' => 'a',
'é' | 'è' | 'ë' | 'ê' => 'e',
'í' | 'ì' | 'ï' | 'î' => 'i',
'ó' | 'ò' | 'ö' | 'ô' | 'õ' => 'o',
'ú' | 'ù' | 'ü' | 'û' => 'u',
'ñ' => 'n',
'ç' => 'c',
other => other,
})
.collect()
}
@@ -0,0 +1,214 @@
//! Proyección equirectangular + cámara: `Projection`, hit-test, desenfoque y
//! vuelo a feature. Todo lo que convierte lon/lat ↔ píxeles de pantalla.
use crate::geom::{dist_point_seg, point_in_ring_screen};
use crate::tipos::{BBox, Coord, MapData, MapView};
/// Proyección equirectangular fit-to-bounds + cámara (zoom/pan). Encapsula la
/// matemática para que el render (canvas) y el hit-test (clic) coincidan
/// exactamente — si difirieran, el clic seleccionaría la feature equivocada.
pub struct Projection {
pub kx: f64,
pub scale: f64,
pub ox: f64,
pub oy: f64,
pub pmin_x: f64,
pub max_lat: f64,
pub pivot_x: f64,
pub pivot_y: f64,
pub zoom: f64,
pub pan: (f64, f64),
}
impl Projection {
/// Encaja `bb` en `rect` (`x, y, w, h`, físicos) con escala uniforme y la
/// cámara dada.
pub fn fit(bb: BBox, rect: (f64, f64, f64, f64), zoom: f64, pan: (f64, f64)) -> Self {
let (rx, ry, rw, rh) = rect;
let lat0 = (bb.min_lat + bb.max_lat) * 0.5;
let kx = lat0.to_radians().cos().abs().max(0.05);
let pmin_x = bb.min_lon * kx;
let pw = (bb.max_lon * kx - pmin_x).max(0.0);
let ph = (bb.max_lat - bb.min_lat).max(0.0);
let inset = 6.0_f64;
let aw = (rw - 2.0 * inset).max(1.0);
let ah = (rh - 2.0 * inset).max(1.0);
let sx = if pw > 1e-12 { aw / pw } else { f64::INFINITY };
let sy = if ph > 1e-12 { ah / ph } else { f64::INFINITY };
let scale = sx.min(sy).min(1.0e6);
let scale = if scale.is_finite() { scale } else { 1.0 };
Projection {
kx,
scale,
ox: rx + inset + (aw - pw * scale) * 0.5,
oy: ry + inset + (ah - ph * scale) * 0.5,
pmin_x,
max_lat: bb.max_lat,
pivot_x: rx + rw * 0.5,
pivot_y: ry + rh * 0.5,
zoom,
pan,
}
}
/// lon/lat → coordenadas de pantalla **antes** de la cámara (fit puro).
/// Independiente de zoom/pan, base para centrar/encuadrar.
pub fn base(&self, [lon, lat]: Coord) -> (f64, f64) {
(
self.ox + (lon * self.kx - self.pmin_x) * self.scale,
self.oy + (self.max_lat - lat) * self.scale,
)
}
/// lon/lat → pantalla (Y invertida), pasando por la cámara.
pub fn to_screen(&self, c: Coord) -> (f64, f64) {
let (bx, by) = self.base(c);
(
self.pivot_x + (bx - self.pivot_x) * self.zoom + self.pan.0,
self.pivot_y + (by - self.pivot_y) * self.zoom + self.pan.1,
)
}
/// pantalla → lon/lat (inverso exacto de [`to_screen`]).
pub fn inverse(&self, sx: f64, sy: f64) -> Coord {
let bx = self.pivot_x + (sx - self.pivot_x - self.pan.0) / self.zoom;
let by = self.pivot_y + (sy - self.pivot_y - self.pan.1) / self.zoom;
let lon = ((bx - self.ox) / self.scale + self.pmin_x) / self.kx;
let lat = self.max_lat - (by - self.oy) / self.scale;
[lon, lat]
}
}
// ─── Hit-test ────────────────────────────────────────────────────────────────
/// Resuelve qué feature cae bajo un clic. `(fx, fy)` es la posición del clic
/// como fracción `[0, 1]` del rect del canvas (DPI-independiente). Devuelve el
/// índice en `data.features`, o `None` si el clic no toca ninguna geometría.
///
/// Prioridad: puntos > líneas > polígonos (lo más específico primero). Todo
/// en espacio de pantalla con la misma [`Projection`] que el render, así el
/// hit coincide con lo que se ve.
pub fn hit_test(data: &MapData, view: &MapView, fx: f64, fy: f64) -> Option<usize> {
let (rx, ry, rw, rh) = view.rect.lock().ok().and_then(|g| *g)?;
if rw <= 0.0 || rh <= 0.0 {
return None;
}
let bb = data.bbox()?;
let proj = Projection::fit(
bb,
(rx as f64, ry as f64, rw as f64, rh as f64),
view.zoom,
view.pan,
);
let cx = rx as f64 + fx * rw as f64;
let cy = ry as f64 + fy * rh as f64;
let tol = 7.0_f64;
for (i, p) in data.points.iter().enumerate() {
let (sx, sy) = proj.to_screen(*p);
if (sx - cx).hypot(sy - cy) <= tol + 3.0 {
return data.point_feat.get(i).copied();
}
}
for (li, line) in data.lines.iter().enumerate() {
for w in line.windows(2) {
if dist_point_seg(cx, cy, proj.to_screen(w[0]), proj.to_screen(w[1])) <= tol {
return data.line_feat.get(li).copied();
}
}
}
for (pi, poly) in data.polygons.iter().enumerate() {
if let Some(outer) = poly.first() {
if point_in_ring_screen(cx, cy, outer, &proj) {
return data.polygon_feat.get(pi).copied();
}
}
}
None
}
// ─── Utilidades de cámara ────────────────────────────────────────────────────
/// Convierte un clic (fracción `[0,1]` del rect) a lon/lat, invirtiendo la
/// proyección actual. `None` si todavía no se pintó o no hay datos.
pub fn unproject(data: &MapData, view: &MapView, fx: f64, fy: f64) -> Option<Coord> {
let (rx, ry, rw, rh) = view.rect.lock().ok().and_then(|g| *g)?;
if rw <= 0.0 || rh <= 0.0 {
return None;
}
let bb = data.bbox()?;
let proj = Projection::fit(
bb,
(rx as f64, ry as f64, rw as f64, rh as f64),
view.zoom,
view.pan,
);
Some(proj.inverse(rx as f64 + fx * rw as f64, ry as f64 + fy * rh as f64))
}
/// Caja envolvente de las geometrías de una feature (por su índice).
pub fn feature_bbox(data: &MapData, fi: usize) -> Option<BBox> {
let mut bb = BBox::empty();
for (i, p) in data.points.iter().enumerate() {
if data.point_feat.get(i) == Some(&fi) {
bb.expand(*p);
}
}
for (i, l) in data.lines.iter().enumerate() {
if data.line_feat.get(i) == Some(&fi) {
for c in l {
bb.expand(*c);
}
}
}
for (i, poly) in data.polygons.iter().enumerate() {
if data.polygon_feat.get(i) == Some(&fi) {
for ring in poly {
for c in ring {
bb.expand(*c);
}
}
}
}
(!bb.is_empty()).then_some(bb)
}
/// Centra y encuadra la cámara sobre una feature (vuelo a resultado de
/// búsqueda), y la deja seleccionada. La feature ocupa ~60% del panel; un
/// punto suelto usa un zoom fijo cómodo. No-op si no hay rect/datos.
pub fn focus_on(data: &MapData, view: &mut MapView, fi: usize) {
let Some((rx, ry, rw, rh)) = view.rect.lock().ok().and_then(|g| *g) else {
view.selected = Some(fi);
return;
};
let (Some(bb), Some(fbb)) = (data.bbox(), feature_bbox(data, fi)) else {
view.selected = Some(fi);
return;
};
let proj = Projection::fit(
bb,
(rx as f64, ry as f64, rw as f64, rh as f64),
1.0,
(0.0, 0.0),
);
let (x0, y0) = proj.base([fbb.min_lon, fbb.max_lat]);
let (x1, y1) = proj.base([fbb.max_lon, fbb.min_lat]);
let fw = (x1 - x0).abs();
let fh = (y1 - y0).abs();
let degenerate = fw < 1e-6 && fh < 1e-6;
let zoom = if degenerate {
8.0
} else {
(0.6 * (rw as f64 / fw.max(1e-6)).min(rh as f64 / fh.max(1e-6)))
.clamp(MapView::ZOOM_MIN, MapView::ZOOM_MAX)
};
let target = [
(fbb.min_lon + fbb.max_lon) * 0.5,
(fbb.min_lat + fbb.max_lat) * 0.5,
];
let (bx, by) = proj.base(target);
view.zoom = zoom;
// pan que lleva el centro de la feature al centro del panel.
view.pan = (-(bx - proj.pivot_x) * zoom, -(by - proj.pivot_y) * zoom);
view.selected = Some(fi);
}
+209
View File
@@ -0,0 +1,209 @@
//! Helpers geométricos de bajo nivel: push de primitivas, etiquetado,
//! constructores de features, distancias en pantalla y tests de inclusión.
//!
//! Son funciones internas — no forman parte de la API pública del crate.
use crate::tipos::{Coord, FeatureProps, Label, MapData, Ring};
use crate::tipos::{MAX_LABELS, MAX_PROPS};
// ─── Empuje de primitivas ────────────────────────────────────────────────────
pub fn push_points(data: &mut MapData, pts: &[Coord], budget: &mut usize, feat: usize) {
for p in pts {
if *budget == 0 {
return;
}
data.points.push(*p);
data.point_feat.push(feat);
*budget -= 1;
}
}
pub fn push_line(data: &mut MapData, mut line: Ring, budget: &mut usize, feat: usize) {
if line.len() < 2 {
return;
}
line.truncate(*budget);
if line.len() < 2 {
return;
}
*budget -= line.len();
data.lines.push(line);
data.line_feat.push(feat);
}
pub fn push_polygon(data: &mut MapData, rings: Vec<Ring>, budget: &mut usize, feat: usize) {
let mut kept: Vec<Ring> = Vec::new();
for mut ring in rings {
if *budget == 0 {
break;
}
ring.truncate(*budget);
if ring.len() < 3 {
continue;
}
*budget -= ring.len();
kept.push(ring);
}
if !kept.is_empty() {
data.polygons.push(kept);
data.polygon_feat.push(feat);
}
}
// ─── Features y etiquetas ────────────────────────────────────────────────────
/// Crea una feature con un nombre opcional (para formatos sin propiedades
/// ricas: GPX/KML, o geometrías sueltas) y devuelve su índice.
pub fn make_feature(data: &mut MapData, name: Option<&str>) -> usize {
let mut fp = FeatureProps::default();
if let Some(n) = name {
fp.name = Some(n.to_string());
fp.props.push(("name".to_string(), n.to_string()));
}
data.features.push(fp);
data.features.len() - 1
}
/// Construye [`FeatureProps`] desde el objeto `properties` de una Feature
/// GeoJSON: conserva escalares (número/string/bool) en orden, y los números
/// también en `numbers` para choropleth. Omite null/array/objeto.
pub fn feature_props(props: Option<&serde_json::Value>) -> FeatureProps {
let mut fp = FeatureProps::default();
let Some(obj) = props.and_then(|p| p.as_object()) else {
return fp;
};
for (k, v) in obj {
if fp.props.len() >= MAX_PROPS {
break;
}
match v {
serde_json::Value::Number(n) => {
if let Some(f) = n.as_f64() {
fp.numbers.push((k.clone(), f));
fp.props.push((k.clone(), n.to_string()));
}
}
serde_json::Value::String(s) => fp.props.push((k.clone(), s.clone())),
serde_json::Value::Bool(b) => fp.props.push((k.clone(), b.to_string())),
_ => {}
}
}
fp
}
/// Ancla una etiqueta a `at` si hay nombre y punto, respetando [`MAX_LABELS`].
pub fn label_at(data: &mut MapData, name: Option<&str>, at: Option<Coord>) {
if let (Some(text), Some(at)) = (name, at) {
if data.labels.len() < MAX_LABELS {
data.labels.push(Label {
at,
text: text.to_string(),
});
}
}
}
// ─── Geometría analítica ─────────────────────────────────────────────────────
/// Vértice central de una polilínea (rótulo de líneas).
pub fn midpoint(line: &[Coord]) -> Option<Coord> {
if line.is_empty() {
None
} else {
Some(line[line.len() / 2])
}
}
/// Centroide simple (promedio de vértices) de un anillo, ignorando el último
/// si repite el primero (anillos GeoJSON cerrados).
pub fn centroid(ring: &[Coord]) -> Option<Coord> {
let pts: &[Coord] = match ring.split_last() {
Some((last, head)) if !head.is_empty() && last == &head[0] => head,
_ => ring,
};
if pts.is_empty() {
return None;
}
let (mut sx, mut sy) = (0.0, 0.0);
for [lon, lat] in pts {
sx += lon;
sy += lat;
}
let n = pts.len() as f64;
Some([sx / n, sy / n])
}
// ─── Helpers de parseo GeoJSON ───────────────────────────────────────────────
/// Lee una coordenada `[lon, lat(, z)]` de un valor JSON. `None` si no es un
/// array de al menos dos números finitos.
pub fn coord(v: Option<&serde_json::Value>) -> Option<Coord> {
let arr = v?.as_array()?;
let lon = arr.first()?.as_f64()?;
let lat = arr.get(1)?.as_f64()?;
if lon.is_finite() && lat.is_finite() {
Some([lon, lat])
} else {
None
}
}
/// Lee una lista de coordenadas `[[lon,lat], ...]`.
pub fn coord_list(v: Option<&serde_json::Value>) -> Vec<Coord> {
let Some(arr) = v.and_then(|x| x.as_array()) else {
return Vec::new();
};
arr.iter().filter_map(|c| coord(Some(c))).collect()
}
/// Lee una lista de anillos `[[[lon,lat], ...], ...]`.
pub fn coord_rings(v: Option<&serde_json::Value>) -> Vec<Ring> {
let Some(arr) = v.and_then(|x| x.as_array()) else {
return Vec::new();
};
arr.iter().map(|r| coord_list(Some(r))).collect()
}
// ─── Geometría en espacio de pantalla ────────────────────────────────────────
/// Distancia de un punto `(px, py)` al segmento `ab`, en pantalla.
pub fn dist_point_seg(px: f64, py: f64, a: (f64, f64), b: (f64, f64)) -> f64 {
let (ax, ay) = a;
let (bx, by) = b;
let (dx, dy) = (bx - ax, by - ay);
let len2 = dx * dx + dy * dy;
if len2 <= 1e-12 {
return (px - ax).hypot(py - ay);
}
let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0);
let (qx, qy) = (ax + t * dx, ay + t * dy);
(px - qx).hypot(py - qy)
}
/// Test punto-en-anillo (even-odd / ray casting) en espacio de pantalla.
pub fn point_in_ring_screen(
px: f64,
py: f64,
ring: &[Coord],
proj: &crate::camara::Projection,
) -> bool {
let n = ring.len();
if n < 3 {
return false;
}
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (xi, yi) = proj.to_screen(ring[i]);
let (xj, yj) = proj.to_screen(ring[j]);
if (yi > py) != (yj > py) {
let x_cross = (xj - xi) * (py - yi) / (yj - yi) + xi;
if px < x_cross {
inside = !inside;
}
}
j = i;
}
inside
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,518 @@
//! Parsers de formatos geoespaciales: GeoJSON, GPX (XML) y KML (XML).
//! Entrada → [`MapData`] plano, tolerante a errores, con presupuesto de
//! vértices para mantener el panel instantáneo.
use std::path::Path;
use crate::geom::{
centroid, coord, coord_list, coord_rings, feature_props, label_at, make_feature, midpoint,
push_line, push_points, push_polygon,
};
use crate::tipos::{MapData, MapPreview, MAX_VERTICES};
// ─── Topes ───────────────────────────────────────────────────────────────────
/// Tope de bytes a leer (128 MiB). Holgado para extractos PMTiles de ciudad;
/// el caller puede subirlo. (Un planeta entero pide streaming, no leer todo.)
pub const DEFAULT_MAP_BYTES_MAX: u64 = 128 * 1024 * 1024;
/// Magic de un archivo PMTiles v3.
const PMTILES_MAGIC: &[u8] = b"PMTiles";
// ─── Despacho principal ──────────────────────────────────────────────────────
/// Lee el archivo y lo parsea a geometrías, desambiguando el formato por
/// contenido: PMTiles (binario), GPX/KML (XML), GeoJSON (JSON).
pub fn load_map(path: &Path, max_bytes: u64) -> MapPreview {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > max_bytes => return MapPreview::TooBig(meta.len()),
Err(e) => return MapPreview::Error(e.to_string()),
_ => {}
}
let raw = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return MapPreview::Error(e.to_string()),
};
// PMTiles: contenedor binario de vector tiles (magic "PMTiles").
if raw.starts_with(PMTILES_MAGIC) {
return crate::basemap::load_pmtiles_overview(raw);
}
// El resto es texto.
let src = match String::from_utf8(raw) {
Ok(s) => s,
Err(_) => return MapPreview::Error("archivo binario no reconocido".into()),
};
// GPX/KML son XML (arrancan con `<`); GeoJSON es JSON (`{`/`[`). El shell
// rutea todos al lens `map`, así que el visor desambigua por contenido.
if src.trim_start().starts_with('<') {
let head = &src[..src.len().min(2048)];
if head.contains("<kml") {
parse_kml(&src)
} else {
parse_gpx(&src)
}
} else {
parse_geojson(&src)
}
}
// ─── GeoJSON ─────────────────────────────────────────────────────────────────
/// Parsea una cadena GeoJSON a [`MapPreview`]. Tolerante: ignora geometrías
/// malformadas en vez de abortar, y recorta al llegar a [`MAX_VERTICES`].
pub fn parse_geojson(src: &str) -> MapPreview {
match parse_into(src, MAX_VERTICES) {
Err(e) => MapPreview::Error(e),
Ok((data, truncated)) => {
if data.total_features() == 0 {
MapPreview::NoGeometry
} else {
MapPreview::Map { data, truncated }
}
}
}
}
/// Núcleo del parseo con presupuesto de vértices explícito. Devuelve la
/// geometría aplanada y si se truncó. Separado para reusarlo con el
/// mapa-base (que necesita un tope mucho mayor que un documento a ojo).
pub fn parse_into(src: &str, cap: usize) -> Result<(MapData, bool), String> {
let value: serde_json::Value = serde_json::from_str(src).map_err(|e| e.to_string())?;
let mut data = MapData::default();
let mut budget = cap;
collect(&value, &mut data, &mut budget, None, None);
Ok((data, budget == 0))
}
/// Nombres de campos numéricos presentes en las features, en orden de primera
/// aparición y sin repetir. Para que el host cicle el campo de choropleth.
pub fn numeric_fields(data: &MapData) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for f in &data.features {
for (k, _) in &f.numbers {
if !out.iter().any(|o| o == k) {
out.push(k.clone());
}
}
}
out
}
/// Extrae un nombre legible de `properties`, probando claves usuales en
/// español/inglés. `None` si no hay propiedades o ninguna clave aplica.
fn feature_name(props: Option<&serde_json::Value>) -> Option<String> {
let obj = props?.as_object()?;
for key in [
"nombre", "name", "título", "titulo", "title", "label", "Name", "NAME",
] {
if let Some(s) = obj.get(key).and_then(|v| v.as_str()) {
let s = s.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
}
None
}
/// Recorre recursivamente un valor GeoJSON (FeatureCollection / Feature /
/// geometría / GeometryCollection) acumulando geometrías en `data`. `budget`
/// es el presupuesto de vértices restante: al agotarse, deja de agregar.
/// `name` es el rótulo heredado de la `Feature` contenedora (si la hay), que
/// se ancla a un punto representativo de cada geometría hoja.
fn collect(
v: &serde_json::Value,
data: &mut MapData,
budget: &mut usize,
name: Option<&str>,
feat: Option<usize>,
) {
if *budget == 0 {
return;
}
let Some(ty) = v.get("type").and_then(|t| t.as_str()) else {
return;
};
// Índice de feature para las geometrías hoja: el heredado, o uno nuevo
// (vacío) para geometría suelta sin Feature contenedora.
let leaf_feat = |data: &mut MapData| match feat {
Some(f) => f,
None => make_feature(data, name),
};
match ty {
"FeatureCollection" => {
if let Some(arr) = v.get("features").and_then(|f| f.as_array()) {
for f in arr {
collect(f, data, budget, None, None);
}
}
}
"Feature" => {
// Una Feature crea su registro de propiedades una vez; toda su
// geometría (incluso multi-) comparte ese índice.
let mut fp = feature_props(v.get("properties"));
let fname = feature_name(v.get("properties"));
fp.name = fname.clone();
data.features.push(fp);
let fi = data.features.len() - 1;
if let Some(g) = v.get("geometry") {
collect(g, data, budget, fname.as_deref().or(name), Some(fi));
}
}
"GeometryCollection" => {
if let Some(arr) = v.get("geometries").and_then(|g| g.as_array()) {
for g in arr {
collect(g, data, budget, name, feat);
}
}
}
"Point" => {
if let Some(c) = coord(v.get("coordinates")) {
let fi = leaf_feat(data);
push_points(data, std::slice::from_ref(&c), budget, fi);
label_at(data, name, Some(c));
}
}
"MultiPoint" => {
let cs = coord_list(v.get("coordinates"));
let rep = cs.first().copied();
let fi = leaf_feat(data);
push_points(data, &cs, budget, fi);
label_at(data, name, rep);
}
"LineString" => {
let line = coord_list(v.get("coordinates"));
let rep = midpoint(&line);
let fi = leaf_feat(data);
push_line(data, line, budget, fi);
label_at(data, name, rep);
}
"MultiLineString" => {
let lines = coord_rings(v.get("coordinates"));
let rep = lines.first().and_then(|l| midpoint(l));
let fi = leaf_feat(data);
for line in lines {
push_line(data, line, budget, fi);
}
label_at(data, name, rep);
}
"Polygon" => {
let rings = coord_rings(v.get("coordinates"));
let rep = rings.first().and_then(|r| centroid(r));
let fi = leaf_feat(data);
push_polygon(data, rings, budget, fi);
label_at(data, name, rep);
}
"MultiPolygon" => {
// coordinates: [ [ ring, ring... ], ... ]
if let Some(arr) = v.get("coordinates").and_then(|c| c.as_array()) {
let fi = leaf_feat(data);
let mut rep = None;
for poly in arr {
let rings = coord_rings(Some(poly));
if rep.is_none() {
rep = rings.first().and_then(|r| centroid(r));
}
push_polygon(data, rings, budget, fi);
}
label_at(data, name, rep);
}
}
_ => {}
}
}
// ─── GPX ─────────────────────────────────────────────────────────────────────
/// rutas y tracks se vuelven etiquetas. Tolerante: ignora lo que no entiende.
pub fn parse_gpx(src: &str) -> MapPreview {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
/// A quién se asigna el próximo `<name>` de texto.
#[derive(Clone, Copy, PartialEq)]
enum NameTarget {
None,
Seg,
Wpt,
}
let mut reader = Reader::from_str(src);
reader.trim_text(true);
let mut buf = Vec::new();
let mut data = MapData::default();
let mut budget = MAX_VERTICES;
// Línea (track-seg o ruta) en curso + su nombre heredado del trk/rte.
let mut seg: Vec<crate::tipos::Coord> = Vec::new();
let mut seg_name: Option<String> = None;
// Waypoint en curso (con hijos, p. ej. `<name>`).
let mut wpt: Option<crate::tipos::Coord> = None;
let mut wpt_name: Option<String> = None;
let mut target = NameTarget::None;
// Cierra la línea en curso como polilínea con su etiqueta.
let flush_seg = |data: &mut MapData,
budget: &mut usize,
seg: &mut Vec<crate::tipos::Coord>,
name: &mut Option<String>| {
let rep = midpoint(seg);
let fi = make_feature(data, name.as_deref());
push_line(data, std::mem::take(seg), budget, fi);
label_at(data, name.as_deref(), rep);
*name = None;
};
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Eof) | Err(_) => break,
Ok(Event::Start(e)) => match e.local_name().as_ref() {
b"trk" | b"rte" => {
seg.clear();
seg_name = None;
target = NameTarget::Seg;
}
b"trkseg" => seg.clear(),
b"trkpt" | b"rtept" => {
if let Some(c) = gpx_latlon(&e) {
seg.push(c);
}
}
b"wpt" => {
wpt = gpx_latlon(&e);
wpt_name = None;
target = NameTarget::Wpt;
}
b"name" => {} // el texto siguiente va al `target` vigente
_ => {}
},
Ok(Event::Empty(e)) => match e.local_name().as_ref() {
b"trkpt" | b"rtept" => {
if let Some(c) = gpx_latlon(&e) {
seg.push(c);
}
}
b"wpt" => {
if let Some(c) = gpx_latlon(&e) {
let fi = make_feature(&mut data, None);
push_points(&mut data, std::slice::from_ref(&c), &mut budget, fi);
}
}
_ => {}
},
Ok(Event::Text(t)) => {
if target != NameTarget::None {
if let Ok(txt) = t.unescape() {
let txt = txt.trim().to_string();
if !txt.is_empty() {
match target {
NameTarget::Seg => seg_name.get_or_insert(txt),
NameTarget::Wpt => wpt_name.get_or_insert(txt),
NameTarget::None => unreachable!(),
};
}
}
}
}
Ok(Event::End(e)) => match e.local_name().as_ref() {
b"trkseg" => flush_seg(&mut data, &mut budget, &mut seg, &mut seg_name),
b"rte" => {
flush_seg(&mut data, &mut budget, &mut seg, &mut seg_name);
target = NameTarget::None;
}
b"trk" => target = NameTarget::None,
b"wpt" => {
if let Some(c) = wpt.take() {
let fi = make_feature(&mut data, wpt_name.as_deref());
push_points(&mut data, std::slice::from_ref(&c), &mut budget, fi);
label_at(&mut data, wpt_name.as_deref(), Some(c));
}
wpt_name = None;
target = NameTarget::None;
}
_ => {}
},
_ => {}
}
buf.clear();
if budget == 0 {
break;
}
}
if data.total_features() == 0 {
MapPreview::NoGeometry
} else {
MapPreview::Map {
data,
truncated: budget == 0,
}
}
}
/// Lee los atributos `lat`/`lon` de un elemento GPX a una [`Coord`]
/// `[lon, lat]`. `None` si falta alguno o no son números finitos.
fn gpx_latlon(e: &quick_xml::events::BytesStart) -> Option<crate::tipos::Coord> {
let mut lat = None;
let mut lon = None;
for a in e.attributes().flatten() {
match a.key.local_name().as_ref() {
b"lat" => {
lat = std::str::from_utf8(&a.value)
.ok()
.and_then(|s| s.parse::<f64>().ok())
}
b"lon" => {
lon = std::str::from_utf8(&a.value)
.ok()
.and_then(|s| s.parse::<f64>().ok())
}
_ => {}
}
}
match (lon, lat) {
(Some(lon), Some(lat)) if lon.is_finite() && lat.is_finite() => Some([lon, lat]),
_ => None,
}
}
// ─── KML ─────────────────────────────────────────────────────────────────────
/// Parsea KML (XML de Google Earth): cada `<Placemark>` con su `<name>` y su
/// geometría (`<Point>`/`<LineString>`/`<Polygon>` con `<coordinates>`). Las
/// coordenadas KML son `lon,lat[,alt]` separadas por espacios. Tolerante.
pub fn parse_kml(src: &str) -> MapPreview {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
#[derive(Clone, Copy, PartialEq)]
enum Geom {
None,
Point,
Line,
Ring,
}
let mut reader = Reader::from_str(src);
reader.trim_text(true);
let mut buf = Vec::new();
let mut data = MapData::default();
let mut budget = MAX_VERTICES;
let mut placemark_name: Option<String> = None;
let mut in_name = false; // dentro de <name> de un Placemark
let mut geom = Geom::None;
let mut in_polygon = false;
let mut poly_rings: Vec<crate::tipos::Ring> = Vec::new();
let mut reading_coords = false;
let mut coord_buf = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Eof) | Err(_) => break,
Ok(Event::Start(e)) => match e.local_name().as_ref() {
b"Placemark" => {
placemark_name = None;
geom = Geom::None;
}
b"name" => in_name = true,
b"Point" => geom = Geom::Point,
b"LineString" => geom = Geom::Line,
b"Polygon" => {
in_polygon = true;
poly_rings.clear();
}
b"LinearRing" => geom = Geom::Ring,
b"coordinates" => {
reading_coords = true;
coord_buf.clear();
}
_ => {}
},
Ok(Event::Text(t)) => {
if let Ok(txt) = t.unescape() {
if reading_coords {
coord_buf.push_str(&txt);
} else if in_name {
let txt = txt.trim();
if !txt.is_empty() {
placemark_name.get_or_insert_with(|| txt.to_string());
}
}
}
}
Ok(Event::End(e)) => match e.local_name().as_ref() {
b"name" => in_name = false,
b"coordinates" => {
reading_coords = false;
let coords = kml_coords(&coord_buf);
match geom {
Geom::Point => {
if let Some(c) = coords.first().copied() {
let fi = make_feature(&mut data, placemark_name.as_deref());
push_points(&mut data, std::slice::from_ref(&c), &mut budget, fi);
label_at(&mut data, placemark_name.as_deref(), Some(c));
}
}
Geom::Line => {
let rep = midpoint(&coords);
let fi = make_feature(&mut data, placemark_name.as_deref());
push_line(&mut data, coords, &mut budget, fi);
label_at(&mut data, placemark_name.as_deref(), rep);
}
Geom::Ring => {
if in_polygon {
poly_rings.push(coords);
} else {
// LinearRing suelto → polígono de un anillo.
let rep = centroid(&coords);
let fi = make_feature(&mut data, placemark_name.as_deref());
push_polygon(&mut data, vec![coords], &mut budget, fi);
label_at(&mut data, placemark_name.as_deref(), rep);
}
}
Geom::None => {}
}
}
b"Polygon" => {
let rep = poly_rings.first().and_then(|r| centroid(r));
let fi = make_feature(&mut data, placemark_name.as_deref());
push_polygon(&mut data, std::mem::take(&mut poly_rings), &mut budget, fi);
label_at(&mut data, placemark_name.as_deref(), rep);
in_polygon = false;
}
b"LinearRing" => geom = Geom::None,
b"Point" | b"LineString" => geom = Geom::None,
_ => {}
},
_ => {}
}
buf.clear();
if budget == 0 {
break;
}
}
if data.total_features() == 0 {
MapPreview::NoGeometry
} else {
MapPreview::Map {
data,
truncated: budget == 0,
}
}
}
/// Parsea un bloque de coordenadas KML (`lon,lat[,alt] lon,lat[,alt] …`).
pub fn kml_coords(s: &str) -> Vec<crate::tipos::Coord> {
s.split_whitespace()
.filter_map(|tok| {
let mut it = tok.split(',');
let lon = it.next()?.trim().parse::<f64>().ok()?;
let lat = it.next()?.trim().parse::<f64>().ok()?;
(lon.is_finite() && lat.is_finite()).then_some([lon, lat])
})
.collect()
}
@@ -1,5 +1,5 @@
//! 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
//! ubica los bytes de cada tile *sin red ni servidor*: un archivo que lees
//! local (o desde tu propio bucket). Es la pieza soberana que faltaba para el
//! basemap de calles, complementando el decoder MVT de [`super::vt`].
//!
@@ -0,0 +1,150 @@
//! Ruteo A* sobre la red de líneas del `MapData`: soberano, offline, sin
//! servicio externo. Los vértices se funden por cuantización para unir cruces.
use std::collections::{BinaryHeap, HashMap};
use crate::tipos::{Coord, MapData};
/// Resultado de un ruteo: la polilínea seguida y su longitud en metros.
#[derive(Debug, Clone, PartialEq)]
pub struct RouteResult {
pub path: Vec<Coord>,
pub meters: f64,
}
/// Calcula la ruta más corta entre `from` y `to` sobre la red de líneas
/// (`data.lines`), con A\* y heurística haversine. Soberano y offline: es
/// matemática de grafos sobre el dato cargado, sin OSRM ni servicio externo.
/// `None` si no hay red o los extremos quedan desconectados.
///
/// Los vértices se funden por proximidad (cuantización a ~0,1 m), así las
/// líneas que comparten un cruce quedan conectadas en el grafo.
pub fn route(data: &MapData, from: Coord, to: Coord) -> Option<RouteResult> {
if data.lines.is_empty() {
return None;
}
// Grafo no dirigido: nodos = vértices fundidos; aristas = tramos.
let mut ids: HashMap<(i64, i64), usize> = HashMap::new();
let mut coords: Vec<Coord> = Vec::new();
let mut adj: Vec<Vec<(usize, f64)>> = Vec::new();
for line in &data.lines {
for w in line.windows(2) {
let a = intern_node(w[0], &mut ids, &mut coords, &mut adj);
let b = intern_node(w[1], &mut ids, &mut coords, &mut adj);
if a == b {
continue;
}
let d = haversine(w[0], w[1]);
adj[a].push((b, d));
adj[b].push((a, d));
}
}
let src = nearest_node(&coords, from)?;
let dst = nearest_node(&coords, to)?;
// A* con heurística admisible (línea recta haversine al destino).
let n = coords.len();
let mut g = vec![f64::INFINITY; n];
let mut came = vec![usize::MAX; n];
g[src] = 0.0;
let mut heap = BinaryHeap::new();
heap.push(AStarNode {
f: haversine(coords[src], coords[dst]),
node: src,
});
while let Some(AStarNode { node, .. }) = heap.pop() {
if node == dst {
break;
}
for &(nb, w) in &adj[node] {
let tentative = g[node] + w;
if tentative < g[nb] {
g[nb] = tentative;
came[nb] = node;
heap.push(AStarNode {
f: tentative + haversine(coords[nb], coords[dst]),
node: nb,
});
}
}
}
if g[dst].is_infinite() {
return None;
}
// Reconstruir el camino de destino a origen y darlo vuelta.
let mut path = Vec::new();
let mut cur = dst;
while cur != usize::MAX {
path.push(coords[cur]);
if cur == src {
break;
}
cur = came[cur];
}
path.reverse();
Some(RouteResult {
path,
meters: g[dst],
})
}
/// Distancia geodésica entre dos coordenadas (haversine), en metros.
pub fn haversine(a: Coord, b: Coord) -> f64 {
const R: f64 = 6_371_000.0;
let (lat1, lat2) = (a[1].to_radians(), b[1].to_radians());
let dlat = (b[1] - a[1]).to_radians();
let dlon = (b[0] - a[0]).to_radians();
let h = (dlat * 0.5).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon * 0.5).sin().powi(2);
2.0 * R * h.sqrt().clamp(-1.0, 1.0).asin()
}
/// Inserta (o reusa) el nodo del grafo para una coordenada, fundiendo por
/// cuantización a ~1e-6° (~0,1 m) para unir cruces compartidos.
fn intern_node(
c: Coord,
ids: &mut HashMap<(i64, i64), usize>,
coords: &mut Vec<Coord>,
adj: &mut Vec<Vec<(usize, f64)>>,
) -> usize {
let k = ((c[0] * 1e6).round() as i64, (c[1] * 1e6).round() as i64);
if let Some(&i) = ids.get(&k) {
return i;
}
let i = coords.len();
ids.insert(k, i);
coords.push(c);
adj.push(Vec::new());
i
}
/// Nodo del grafo más cercano a una coordenada (snap del clic a la red).
fn nearest_node(coords: &[Coord], c: Coord) -> Option<usize> {
coords
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| haversine(**a, c).total_cmp(&haversine(**b, c)))
.map(|(i, _)| i)
}
/// Entrada de la cola de prioridad de A\*: min-heap por `f` (total order vía
/// `total_cmp`, invertido para que el menor quede en la cima).
struct AStarNode {
f: f64,
node: usize,
}
impl PartialEq for AStarNode {
fn eq(&self, o: &Self) -> bool {
self.f == o.f
}
}
impl Eq for AStarNode {}
impl Ord for AStarNode {
fn cmp(&self, o: &Self) -> std::cmp::Ordering {
o.f.total_cmp(&self.f)
}
}
impl PartialOrd for AStarNode {
fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(o))
}
}
@@ -0,0 +1,334 @@
//! Tipos base del núcleo geoespacial: coordenadas, cajas envolventes, etiquetas,
//! propiedades de features y el modelo de datos plano listo para proyectar.
use std::sync::{Arc, Mutex};
/// Una coordenada geográfica `[lon, lat]` en grados. La `z` (altitud) de
/// GeoJSON, si viene, se ignora.
pub type Coord = [f64; 2];
/// Un anillo o polilínea: secuencia de coordenadas.
pub type Ring = Vec<Coord>;
/// Caja envolvente en grados: `(min_lon, min_lat, max_lon, max_lat)`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BBox {
pub min_lon: f64,
pub min_lat: f64,
pub max_lon: f64,
pub max_lat: f64,
}
impl BBox {
/// Caja vacía/invertida: lista para acumular con [`expand`](Self::expand).
pub fn empty() -> Self {
BBox {
min_lon: f64::INFINITY,
min_lat: f64::INFINITY,
max_lon: f64::NEG_INFINITY,
max_lat: f64::NEG_INFINITY,
}
}
pub fn expand(&mut self, [lon, lat]: Coord) {
self.min_lon = self.min_lon.min(lon);
self.min_lat = self.min_lat.min(lat);
self.max_lon = self.max_lon.max(lon);
self.max_lat = self.max_lat.max(lat);
}
/// `true` si nunca se expandió (no hubo coordenadas).
pub fn is_empty(&self) -> bool {
self.min_lon > self.max_lon || self.min_lat > self.max_lat
}
}
/// Una etiqueta: el nombre de una feature anclado a un punto representativo
/// (el punto mismo, el medio de una línea, el centroide de un polígono).
#[derive(Debug, Clone, PartialEq)]
pub struct Label {
pub at: Coord,
pub text: String,
}
/// Tope de etiquetas a retener — más que esto satura el panel de texto.
pub const MAX_LABELS: usize = 200;
/// Tope de propiedades retenidas por feature (para inspección/choropleth).
pub const MAX_PROPS: usize = 80;
/// Tope de vértices a retener. Cortar datasets enormes mantiene el panel
/// instantáneo (vello rebuild es barato hasta ~500 K primitivos/frame).
pub const MAX_VERTICES: usize = 200_000;
/// Propiedades de una feature, retenidas para inspección (clic) y estilo por
/// valor (choropleth). `props` son pares clave→valor ya stringificados (orden
/// de aparición); `numbers` son sólo las numéricas, para escalas de color.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FeatureProps {
pub name: Option<String>,
pub props: Vec<(String, String)>,
pub numbers: Vec<(String, f64)>,
}
impl FeatureProps {
/// Valor numérico de una propiedad por nombre, si existe.
pub fn number(&self, key: &str) -> Option<f64> {
self.numbers.iter().find(|(k, _)| k == key).map(|(_, v)| *v)
}
}
/// Geometrías aplanadas listas para proyectar y pintar. Las geometrías
/// GeoJSON anidadas (multi-, colecciones) se desarman a estas tres listas.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MapData {
/// Puntos sueltos (`Point`/`MultiPoint`).
pub points: Vec<Coord>,
/// Polilíneas (`LineString`/`MultiLineString`).
pub lines: Vec<Ring>,
/// Polígonos: cada uno es una lista de anillos; el primero es el
/// contorno exterior y los siguientes, huecos. (`Polygon`/`MultiPolygon`.)
pub polygons: Vec<Vec<Ring>>,
/// Nombres de features (de `properties.nombre`/`name`/…) anclados a un
/// punto representativo, para rotular el mapa.
pub labels: Vec<Label>,
/// Propiedades por feature. Los índices `*_feat` apuntan aquí.
pub features: Vec<FeatureProps>,
/// Índice de feature de cada punto (paralelo a `points`).
pub point_feat: Vec<usize>,
/// Índice de feature de cada línea (paralelo a `lines`).
pub line_feat: Vec<usize>,
/// Índice de feature de cada polígono (paralelo a `polygons`).
pub polygon_feat: Vec<usize>,
/// Caja envolvente fija (basemap PMTiles): ancla la proyección a un marco
/// estable para que el mapa no salte mientras llegan tiles. Si es `None`,
/// la bbox se calcula del contenido.
pub bbox_override: Option<BBox>,
}
impl MapData {
/// Cantidad total de vértices retenidos.
pub fn vertex_count(&self) -> usize {
self.points.len()
+ self.lines.iter().map(Vec::len).sum::<usize>()
+ self
.polygons
.iter()
.flat_map(|p| p.iter().map(Vec::len))
.sum::<usize>()
}
/// Caja envolvente: el override fijo si está, o la de todo el contenido.
pub fn bbox(&self) -> Option<BBox> {
if self.bbox_override.is_some() {
return self.bbox_override;
}
let mut bb = BBox::empty();
for p in &self.points {
bb.expand(*p);
}
for l in &self.lines {
for c in l {
bb.expand(*c);
}
}
for poly in &self.polygons {
for ring in poly {
for c in ring {
bb.expand(*c);
}
}
}
if bb.is_empty() {
None
} else {
Some(bb)
}
}
pub fn total_features(&self) -> usize {
self.points.len() + self.lines.len() + self.polygons.len()
}
/// Anexa otro `MapData`, reindexando sus features (para fusionar varios
/// tiles en un solo mapa).
pub fn append(&mut self, other: MapData) {
let base = self.features.len();
self.features.extend(other.features);
self.labels.extend(other.labels);
self.points.extend(other.points);
self.point_feat
.extend(other.point_feat.into_iter().map(|f| f + base));
self.lines.extend(other.lines);
self.line_feat
.extend(other.line_feat.into_iter().map(|f| f + base));
self.polygons.extend(other.polygons);
self.polygon_feat
.extend(other.polygon_feat.into_iter().map(|f| f + base));
}
}
/// Estado del visor. Replica la forma de los otros para que el shell lo
/// trate igual.
#[derive(Debug, Clone, Default, PartialEq)]
pub enum MapPreview {
/// Sin archivo seleccionado.
#[default]
Empty,
/// GeoJSON parseado a geometrías (posiblemente truncado por
/// [`MAX_VERTICES`]).
Map { data: MapData, truncated: bool },
/// Parseó como JSON pero no contiene ninguna geometría reconocible.
NoGeometry,
/// Excede el tope de tamaño.
TooBig(u64),
/// E/S o parseo falló.
Error(String),
}
/// Transformación de cámara del mapa: zoom (factor) + pan (desplazamiento en
/// píxeles físicos de pantalla). El host la guarda y la muta con la rueda y
/// el arrastre; el canvas la aplica al proyectar, anclando el zoom al centro
/// del panel.
///
/// La celda `rect` la **escribe el canvas** en cada paint con su rectángulo
/// físico, y la **lee el host** ([`MapView::contains`]) para acotar el
/// zoom-por-rueda al área del mapa (sin robarle el scroll a la lista).
#[derive(Clone)]
pub struct MapView {
pub zoom: f64,
pub pan: (f64, f64),
/// Dibujar el mapa-base mundial de fondo.
pub show_base: bool,
/// Índice de la feature seleccionada (clic) en `MapData.features`, si la hay.
pub selected: Option<usize>,
/// Campo numérico por el que colorear los polígonos (choropleth). `None`
/// = relleno uniforme.
pub color_field: Option<String>,
/// Modo búsqueda activo (captura el teclado para escribir la consulta).
pub searching: bool,
/// Consulta de búsqueda en curso.
pub query: String,
/// Modo ruteo activo (los clics fijan origen/destino).
pub routing: bool,
/// Puntos de ruta marcados por el usuario (0..2), en lon/lat.
pub route_pins: Vec<Coord>,
/// Ruta calculada (polilínea a dibujar), vacía si no hay.
pub route_path: Vec<Coord>,
/// Longitud de la ruta calculada, en metros.
pub route_meters: f64,
pub(crate) rect: Arc<Mutex<Option<(f32, f32, f32, f32)>>>,
}
impl Default for MapView {
fn default() -> Self {
Self {
zoom: 1.0,
pan: (0.0, 0.0),
show_base: true,
selected: None,
color_field: None,
searching: false,
query: String::new(),
routing: false,
route_pins: Vec::new(),
route_path: Vec::new(),
route_meters: 0.0,
rect: Arc::new(Mutex::new(None)),
}
}
}
impl MapView {
/// Límites de zoom: ni tan lejos que desaparezca, ni tan cerca que se
/// pierda en aritmética.
pub const ZOOM_MIN: f64 = 0.2;
pub const ZOOM_MAX: f64 = 64.0;
/// Vuelve al encuadre inicial (zoom 1, sin pan) y limpia la selección.
/// Conserva la celda del rect para no perder el gateo entre selecciones.
pub fn reset(&mut self) {
self.zoom = 1.0;
self.pan = (0.0, 0.0);
self.selected = None;
self.searching = false;
self.query.clear();
self.routing = false;
self.clear_route();
}
/// Limpia los puntos y la ruta calculada (no toca el modo).
pub fn clear_route(&mut self) {
self.route_pins.clear();
self.route_path.clear();
self.route_meters = 0.0;
}
/// Acumula un desplazamiento (de un arrastre), en píxeles físicos.
pub fn pan_by(&mut self, dx: f64, dy: f64) {
self.pan.0 += dx;
self.pan.1 += dy;
}
/// Multiplica el zoom (acotado). El pan no se toca: el zoom queda
/// anclado al centro del panel.
pub fn zoom_by(&mut self, factor: f64) {
if factor.is_finite() && factor > 0.0 {
self.zoom = (self.zoom * factor).clamp(Self::ZOOM_MIN, Self::ZOOM_MAX);
}
}
/// Zoom anclado a un punto de pantalla `(cx, cy)` (físicos): el lugar bajo
/// el cursor queda fijo. Si todavía no se pintó (sin rect), cae a
/// [`zoom_by`] (zoom al centro).
pub fn zoom_at(&mut self, factor: f64, cx: f32, cy: f32) {
if !(factor.is_finite() && factor > 0.0) {
return;
}
let Some((rx, ry, rw, rh)) = self.rect.lock().ok().and_then(|g| *g) else {
self.zoom_by(factor);
return;
};
let pivot_x = rx as f64 + rw as f64 * 0.5;
let pivot_y = ry as f64 + rh as f64 * 0.5;
let z0 = self.zoom;
let z1 = (z0 * factor).clamp(Self::ZOOM_MIN, Self::ZOOM_MAX);
if (z1 - z0).abs() < f64::EPSILON {
return;
}
// Mantener fijo el punto bajo el cursor:
// pan1 = pan0 - (c - pivot - pan0) * (z1 - z0) / z0
let k = (z1 - z0) / z0;
self.pan.0 -= (cx as f64 - pivot_x - self.pan.0) * k;
self.pan.1 -= (cy as f64 - pivot_y - self.pan.1) * k;
self.zoom = z1;
}
/// Alterna el mapa-base de fondo.
pub fn toggle_base(&mut self) {
self.show_base = !self.show_base;
}
/// `true` si `(x, y)` (físicos) cae dentro del último rect pintado por el
/// canvas. `false` si todavía no se pintó.
pub fn contains(&self, x: f32, y: f32) -> bool {
match self.rect.lock().ok().and_then(|g| *g) {
Some((rx, ry, rw, rh)) => x >= rx && x <= rx + rw && y >= ry && y <= ry + rh,
None => false,
}
}
/// Último rect físico pintado por el canvas (si ya se pintó alguna vez).
pub fn rect(&self) -> Option<(f32, f32, f32, f32)> {
self.rect.lock().ok().and_then(|g| *g)
}
/// Registra el rect físico del canvas. Lo llama el propio canvas en cada
/// paint; también lo usan herramientas/tests para dirigir el viewport
/// headless (sin un paint real).
pub fn record_rect(&self, r: (f32, f32, f32, f32)) {
if let Ok(mut g) = self.rect.lock() {
*g = Some(r);
}
}
}
@@ -0,0 +1,24 @@
[package]
name = "nahual-geo-voxel"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-geo-voxel — puente mapa real → mundo voxel. Toma el relieve (heightfield georreferenciado: DEM/PGM o plano) + los rasgos vectoriales del visor de mapas (nahual-geo-core: agua, calles, edificios en lon/lat) y los rasteriza en un VoxelGrid (llimphi-3d): terreno bandeado por altura, agua a su cota, calles pavimentadas y edificios EXTRUIDOS desde su huella. Sin render ni red: produce el grid; lo pinta llimphi-3d. La contraparte geoespacial de llimphi-voxel (world-gen procedural) — aquí el world-gen viene de un lugar real."
[dependencies]
# Modelo geoespacial: MapData/BBox/Coord + features de tiles vectoriales (MVT).
nahual-geo-core = { path = "../nahual-geo-core" }
# El grid voxel destino (mismo tipo que consume el motor 3D y llimphi-voxel).
llimphi-3d = { workspace = true }
# Relieve real desde DEM GeoTIFF (.tif directo, además del camino PGM).
foreign-geotiff = { workspace = true }
[dev-dependencies]
# Volcado headless a PNG del demo (sólo el example; los tests certifican con stats).
llimphi-hal = { workspace = true }
llimphi-raster = { workspace = true }
png = { workspace = true }
pollster = { workspace = true }
+38
View File
@@ -0,0 +1,38 @@
# nahual-geo-voxel
*Read this in English: [README.md](README.md).*
# nahual-geo-voxel — puente **mapa real → mundo voxel**
Toma el relieve de un lugar (`Heightfield`: un DEM/PGM georreferenciado, o una
lámina plana) y los rasgos vectoriales del visor de mapas de `nahual`
(`nahual-geo-core`: agua, calles, edificios en lon/lat) y los **rasteriza en un
`VoxelGrid`** de `llimphi-3d` — el mismo tipo que consume el motor 3D y
`llimphi-voxel`.
Es la contraparte geoespacial del world-gen procedural de `llimphi-voxel`:
allá el terreno sale de ruido fractal; aquí sale de un mapa. La API espeja a la
de `Bioma`: `MapaVoxel::generar_ventana` es a este crate lo que
`Bioma::generate_window` es a aquél, y es igual de *streamable* (misma columna de
mundo → mismo resultado, sin importar la ventana).
## Uso
```no_run
use nahual_geo_voxel::{Heightfield, MapaVoxel, Opciones};
use nahual_geo_core::{BBox, load_map};
let bbox = BBox { min_lon: -74.01, min_lat: 40.70, max_lon: -73.99, max_lat: 40.72 };
// Relieve real desde un PGM (DEM exportado con `gdal_translate -of PNM`)…
let dem = std::fs::read("dem.pgm").unwrap();
let height = Heightfield::desde_pgm(&dem, bbox, 0.0, 120.0).unwrap();
// …y los rasgos desde un GeoJSON del visor de mapas.
let md = load_map(&std::fs::read("ciudad.geojson").unwrap(), None).unwrap();
let mapa = MapaVoxel::desde_mapdata(bbox, height, Opciones::default(), &md);
let (grid, dim, recortado) = mapa.generar_recuadro(512);
eprintln!("mundo {dim:?} recortado={recortado}");
```
Sin render ni red: este crate **produce el grid**; lo pinta `llimphi-3d`.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,37 @@
# nahual-geo-voxel
The **real map → voxel world** bridge.
It takes a place's relief (a `Heightfield`: a georeferenced DEM/PGM, or a flat
sheet) and the vector features of nahual's map viewer (`nahual-geo-core`: water,
streets, buildings in lon/lat) and **rasterizes them into a `VoxelGrid`** of
`llimphi-3d` — the same type the 3D engine and `llimphi-voxel` consume.
It is the geospatial counterpart of `llimphi-voxel`'s procedural world-gen: there
the terrain comes from fractal noise; here it comes from a map. The API mirrors
`Bioma`'s: `MapaVoxel::generar_ventana` is to this crate what
`Bioma::generate_window` is to that one, and it is just as *streamable* (same
world column → same result, regardless of the window).
## Use
```no_run
use nahual_geo_voxel::{Heightfield, MapaVoxel, Opciones};
use nahual_geo_core::{BBox, load_map};
let bbox = BBox { min_lon: -74.01, min_lat: 40.70, max_lon: -73.99, max_lat: 40.72 };
// Real relief from a PGM (a DEM exported with `gdal_translate -of PNM`)…
let dem = std::fs::read("dem.pgm").unwrap();
let height = Heightfield::desde_pgm(&dem, bbox, 0.0, 120.0).unwrap();
// …and the features from the map viewer's GeoJSON.
let md = load_map(&std::fs::read("city.geojson").unwrap(), None).unwrap();
let mapa = MapaVoxel::desde_mapdata(bbox, height, Opciones::default(), &md);
let (grid, dim, clipped) = mapa.generar_recuadro(512);
eprintln!("world {dim:?} clipped={clipped}");
```
No render and no network: this crate **produces the grid**; `llimphi-3d` paints it.
---
Part of **nahual** — see [nahual](../README.md).
@@ -0,0 +1,272 @@
//! **Demo del puente mapa real → voxel.** Arma un mapa sintético *como si* viniera
//! del visor (`nahual-geo-core`): un GeoJSON con un lago, una traza de calles y una
//! grilla de edificios con altura por `building:levels`, más un relieve (heightfield)
//! con una loma. Lo pasa por [`MapaVoxel::desde_mapdata`], genera el `VoxelGrid`,
//! **imprime stats por material** (la certificación de rigor, regla #8) y **además**
//! vuelca un PNG headless — porque esto es una capacidad *visual nueva*.
//!
//! `cargo run -p nahual-geo-voxel --example mapa_voxel_demo --release [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use nahual_geo_core::{parse_into, BBox, MAX_VERTICES};
use nahual_geo_voxel::{Heightfield, MapaVoxel, Opciones};
const W: u32 = 1180;
const H: u32 = 720;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "/tmp/mapa_voxel.png".into());
// Caja del mapa: ~660 m × 660 m.
let bbox = BBox { min_lon: 0.0, min_lat: 0.0, max_lon: 0.006, max_lat: 0.006 };
// ── Relieve: una loma suave hacia el noreste (24×24 muestras, 0..70 m). ──
let (cols, rows) = (24u32, 24u32);
let mut alturas = Vec::with_capacity((cols * rows) as usize);
for r in 0..rows {
for c in 0..cols {
// r=0 es norte. Loma centrada en (norte, este).
let nx = c as f32 / (cols - 1) as f32; // 0=oeste..1=este
let nz = 1.0 - r as f32 / (rows - 1) as f32; // 0=sur..1=norte
let d = ((nx - 0.78).powi(2) + (nz - 0.8).powi(2)).sqrt();
let h = (70.0 * (1.0 - (d * 2.2).min(1.0))).max(0.0);
alturas.push(h);
}
}
let height = Heightfield::desde_muestras(bbox, cols, rows, alturas);
eprintln!("relieve: {}..{} m", height.min_m as i32, height.max_m as i32);
// ── Rasgos vectoriales como GeoJSON (la vía del visor de mapas). ──
let geojson = construir_geojson(&bbox);
let (md, truncado) = parse_into(&geojson, MAX_VERTICES).expect("geojson válido");
eprintln!(
"geojson: {} polígonos, {} líneas, {} features (truncado={truncado})",
md.polygons.len(),
md.lines.len(),
md.features.len()
);
// ── Puente → voxel. ──
let mut opts = Opciones::default();
opts.metros_por_voxel = 4.0;
opts.escala_v = 1.2; // realza un poco la loma
opts.nivel_mar_m = 1.0;
let mapa = MapaVoxel::desde_mapdata(bbox, height, opts, &md);
eprintln!(
"clasificado: {} agua, {} calles, {} edificios",
mapa.agua.len(),
mapa.calles.len(),
mapa.edificios.len()
);
let (g, dim, recortado) = mapa.generar_recuadro(200);
eprintln!("mundo voxel: {dim:?} (recortado={recortado})");
stats(&g, &mapa);
render_png(&g, dim, &out);
eprintln!("escrito {out}");
}
/// Cuenta voxels sólidos por material y lo imprime — la certificación textual.
fn stats(g: &VoxelGrid, mapa: &MapaVoxel) {
let [dx, dy, dz] = g.dim();
let mut solidos = 0usize;
let p = &mapa.paleta;
let etiquetas: [(&str, [u8; 3]); 8] = [
("pasto", p.pasto),
("arena", p.arena),
("roca", p.roca),
("nieve", p.nieve),
("agua", p.agua),
("calle", p.calle),
("muro", p.muro),
("techo", p.techo),
];
let mut cuenta = [0usize; 8];
for z in 0..dz {
for y in 0..dy {
for x in 0..dx {
if let Some([r, gg, b, a]) = g.get(x, y, z) {
if a > 127 {
solidos += 1;
for (i, (_, col)) in etiquetas.iter().enumerate() {
if [r, gg, b] == *col {
cuenta[i] += 1;
}
}
}
}
}
}
}
eprintln!("── stats voxel ── {solidos} sólidos de {}", dx * dy * dz);
for (i, (nombre, _)) in etiquetas.iter().enumerate() {
eprintln!(" {nombre:>6}: {}", cuenta[i]);
}
}
/// FeatureCollection sintético: un lago (natural=water), una traza de calles
/// (highway) y una grilla de edificios (building + building:levels).
fn construir_geojson(bbox: &BBox) -> String {
let (lon0, lat0) = (bbox.min_lon, bbox.min_lat);
let (lon1, lat1) = (bbox.max_lon, bbox.max_lat);
let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t;
let mut feats: Vec<String> = Vec::new();
// Lago en la esquina suroeste.
let lx0 = lerp(lon0, lon1, 0.06);
let lx1 = lerp(lon0, lon1, 0.34);
let lz0 = lerp(lat0, lat1, 0.06);
let lz1 = lerp(lat0, lat1, 0.30);
feats.push(format!(
r#"{{"type":"Feature","properties":{{"natural":"water","name":"laguna"}},"geometry":{{"type":"Polygon","coordinates":[[[{lx0},{lz0}],[{lx1},{lz0}],[{lx1},{lz1}],[{lx0},{lz1}],[{lx0},{lz0}]]]}}}}"#
));
// Traza de calles: 3 avenidas verticales + 3 horizontales.
for k in 1..=3 {
let t = k as f64 / 4.0;
let x = lerp(lon0, lon1, t);
let z = lerp(lat0, lat1, t);
feats.push(format!(
r#"{{"type":"Feature","properties":{{"highway":"residential"}},"geometry":{{"type":"LineString","coordinates":[[{x},{lat0}],[{x},{lat1}]]}}}}"#
));
feats.push(format!(
r#"{{"type":"Feature","properties":{{"highway":"residential"}},"geometry":{{"type":"LineString","coordinates":[[{lon0},{z}],[{lon1},{z}]]}}}}"#
));
}
// Grilla de edificios: en cada cuadra un bloque, altura por niveles crecientes.
let mut n = 0;
for gx in 0..3 {
for gz in 0..3 {
// Centro de cuadra entre avenidas (evita el lago SO).
let cx = lerp(lon0, lon1, 0.125 + gx as f64 * 0.25);
let cz = lerp(lat0, lat1, 0.125 + gz as f64 * 0.25);
if cx < lx1 && cz < lz1 {
continue; // dentro del lago
}
let r = (lon1 - lon0) * 0.05;
let niveles = 2 + (gx + gz) * 2; // 2..10 pisos
let (a, b, c, d) = (cx - r, cz - r, cx + r, cz + r);
feats.push(format!(
r#"{{"type":"Feature","properties":{{"building":"yes","building:levels":{niveles}}},"geometry":{{"type":"Polygon","coordinates":[[[{a},{b}],[{c},{b}],[{c},{d}],[{a},{d}],[{a},{b}]]]}}}}"#
));
n += 1;
}
}
eprintln!("geojson sintético: {n} edificios");
format!(r#"{{"type":"FeatureCollection","features":[{}]}}"#, feats.join(","))
}
// ── Render headless (mismo patrón que los examples de llimphi-voxel). ──
fn render_png(g: &VoxelGrid, dim: [u32; 3], out: &str) {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, g);
vr.sun_dir = [0.35, 0.72, -0.45];
vr.atmosphere = Atmosphere {
sky_zenith: [70, 108, 170],
sky_horizon: [176, 202, 232],
fog_density: 0.006,
god_rays: 0.0,
};
// Cámara elevada, mirando la ciudad desde el sur-oeste hacia el relieve NE.
let (dx, dy, dz) = (dim[0] as f32, dim[1] as f32, dim[2] as f32);
let eye = Vec3::new(-dx * 0.32, dy * 0.85, -dz * 0.55);
let camera = Camera3d::fly(eye, 0.55, -0.42);
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &vello::Scene::new(), &view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("base");
let mut scene = Scene3d::new();
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("mapa") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), &camera, Some(&mut vr), &[]);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(&readback(&hal, &inter), out);
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let srow = row * padded;
pixels.extend_from_slice(&data[srow..srow + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wtr = enc.write_header().unwrap();
wtr.write_image_data(pixels).unwrap();
}
@@ -0,0 +1,366 @@
//! Renderiza un **GeoJSON real** (p. ej. exportado de OpenStreetMap) como mundo
//! voxel, con relieve opcional desde un DEM GeoTIFF. Ajusta `metros_por_voxel` para
//! que la caja del mapa llene un mundo de lado `LADO`.
//!
//! `cargo run -p nahual-geo-voxel --example render_mapa --release -- <mapa.geojson> [dem.tif] [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use nahual_geo_core::{parse_into, BBox, MapData, MAX_VERTICES};
use nahual_geo_voxel::{Heightfield, MapaVoxel, Opciones};
const W: u32 = 1280;
const H: u32 = 800;
const LADO: u32 = 300; // lado del mundo voxel
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const M_POR_GRADO_LAT: f64 = 111_320.0;
fn main() {
// args: <mapa.geojson> [dem.tif] [out.png] [--bbox W S E N] [--tile z x y]
// --bbox: caja del mundo (recorta el overhang de OSM). --tile: el DEM está en un
// GeoTIFF Web Mercator de terrain-tiles (z/x/y); su caja lon/lat sale del slippy.
let argv: Vec<String> = std::env::args().skip(1).collect();
let geojson_path = argv.first().cloned().expect("uso: render_mapa <mapa.geojson> [dem.tif] [out.png] [--bbox W S E N] [--tile z x y]");
let mut dem_path = None;
let mut out = "/tmp/mapa_real.png".to_string();
let mut world_box: Option<BBox> = None;
let mut tile: Option<(u32, u32, u32)> = None;
// Mosaico: varios `--mtile z x y file.tif` (misma z) se cosen en un solo DEM.
let mut mtiles: Vec<(u32, u32, u32, String)> = Vec::new();
let mut vexag: Option<f32> = None; // exageración vertical explícita
let mut mar: Option<f32> = None; // nivel del mar en metros (costa)
let mut aerea = false; // forzar cámara aérea (transecto)
let mut lado: u32 = LADO;
let mut hitos: Vec<(f64, f64, f64)> = Vec::new(); // lon,lat,altura_m
let mut i = 1;
while i < argv.len() {
let a = &argv[i];
match a.as_str() {
"--bbox" => {
let n: Vec<f64> = argv[i + 1..i + 5].iter().map(|s| s.parse().unwrap()).collect();
world_box = Some(BBox { min_lon: n[0], min_lat: n[1], max_lon: n[2], max_lat: n[3] });
i += 5;
}
"--tile" => {
let n: Vec<u32> = argv[i + 1..i + 4].iter().map(|s| s.parse().unwrap()).collect();
tile = Some((n[0], n[1], n[2]));
i += 4;
}
"--mtile" => {
let n: Vec<u32> = argv[i + 1..i + 4].iter().map(|s| s.parse().unwrap()).collect();
mtiles.push((n[0], n[1], n[2], argv[i + 4].clone()));
i += 5;
}
"--vexag" => {
vexag = Some(argv[i + 1].parse().unwrap());
i += 2;
}
"--mar" => {
mar = Some(argv[i + 1].parse().unwrap());
i += 2;
}
"--aerea" => {
aerea = true;
i += 1;
}
"--lado" => {
lado = argv[i + 1].parse().unwrap();
i += 2;
}
"--hito" => {
let n: Vec<f64> = argv[i + 1..i + 4].iter().map(|s| s.parse().unwrap()).collect();
hitos.push((n[0], n[1], n[2]));
i += 4;
}
_ if a.ends_with(".tif") || a.ends_with(".tiff") => {
dem_path = Some(a.clone());
i += 1;
}
_ => {
out = a.clone();
i += 1;
}
}
}
let src = std::fs::read_to_string(&geojson_path).expect("leer geojson");
let (md, trunc) = parse_into(&src, MAX_VERTICES).expect("parsear geojson");
let bbox = world_box.unwrap_or_else(|| bbox_de(&md));
eprintln!(
"geojson: {} polígonos, {} líneas, {} features (truncado={trunc})",
md.polygons.len(),
md.lines.len(),
md.features.len()
);
// Encaje: metros_por_voxel para que el eje más largo llene LADO voxels.
let lat0 = (bbox.min_lat + bbox.max_lat) * 0.5;
let m_lon = M_POR_GRADO_LAT * lat0.to_radians().cos().abs();
let span_x = (bbox.max_lon - bbox.min_lon) * m_lon;
let span_z = (bbox.max_lat - bbox.min_lat) * M_POR_GRADO_LAT;
let mpv = (span_x.max(span_z) / (lado - 1) as f64).max(0.5);
eprintln!("caja ≈ {:.0}×{:.0} m → {:.1} m/voxel", span_x, span_z, mpv);
// Relieve: mosaico de tiles (--mtile), un solo tile (--tile), DEM suelto, o plano.
let height = if !mtiles.is_empty() {
mosaico(&mtiles)
} else { match &dem_path {
Some(p) => {
let bytes = std::fs::read(p).expect("leer dem");
let h = match tile {
Some((z, tx, ty)) => {
let tb = tile_bbox(z, tx, ty);
eprintln!("tile z{z}/{tx}/{ty} → lon[{:.4},{:.4}] lat[{:.4},{:.4}]", tb.min_lon, tb.max_lon, tb.min_lat, tb.max_lat);
Heightfield::desde_geotiff_en(&bytes, tb).expect("decodificar geotiff (tile)")
}
None => Heightfield::desde_geotiff(&bytes).expect("decodificar geotiff"),
};
eprintln!("DEM {p}: {}×{} px, {:.0}..{:.0} m", h.cols, h.rows, h.min_m, h.max_m);
h
}
None => Heightfield::plano(bbox, 0.0),
} };
let opts = Opciones {
metros_por_voxel: mpv,
escala_v: vexag.unwrap_or(if dem_path.is_some() || !mtiles.is_empty() { 1.0 } else { 3.2 }),
nivel_mar_m: mar.unwrap_or(height.min_m),
alto_edificio_m: 14.0,
..Opciones::default()
};
let mut mapa = MapaVoxel::desde_mapdata(bbox, height, opts, &md);
for (lon, lat, h) in &hitos {
mapa.hitos.push(([*lon, *lat], *h as f32));
}
eprintln!(
"clasificado: {} edificios · {} calles · {} agua · {} hitos",
mapa.edificios.len(),
mapa.calles.len(),
mapa.agua.len(),
mapa.hitos.len()
);
let (g, dim, recortado) = mapa.generar_recuadro(lado);
eprintln!("mundo voxel: {dim:?} (recortado={recortado})");
stats(&g, &mapa);
render_png(&g, dim, &out, aerea);
eprintln!("escrito {out}");
}
/// Cose varios *slippy tiles* GeoTIFF (misma z, en grilla) en un solo `Heightfield`:
/// coloca cada tile en su bloque de píxeles y toma la caja lon/lat de la unión. Los
/// tiles con `y` mayor son más al sur → van más abajo en la grilla (fila 0 = norte).
fn mosaico(tiles: &[(u32, u32, u32, String)]) -> Heightfield {
let z = tiles[0].0;
let (x0, x1) = (tiles.iter().map(|t| t.1).min().unwrap(), tiles.iter().map(|t| t.1).max().unwrap());
let (y0, y1) = (tiles.iter().map(|t| t.2).min().unwrap(), tiles.iter().map(|t| t.2).max().unwrap());
// Dimensiones de tile del primero (se asumen homogéneas).
let first = foreign_geotiff::leer_dem(&std::fs::read(&tiles[0].3).expect("leer tile")).expect("dem tile");
let (tw, th) = (first.cols as usize, first.rows as usize);
let across = (x1 - x0 + 1) as usize;
let down = (y1 - y0 + 1) as usize;
let (mcols, mrows) = (across * tw, down * th);
let mut data = vec![0f32; mcols * mrows];
for (tz, tx, ty, path) in tiles {
assert_eq!(*tz, z, "todos los tiles deben ser de la misma z");
let dem = foreign_geotiff::leer_dem(&std::fs::read(path).expect("leer tile")).expect("dem tile");
let ox = (tx - x0) as usize * tw;
let oy = (ty - y0) as usize * th;
for r in 0..th.min(dem.rows as usize) {
for c in 0..tw.min(dem.cols as usize) {
data[(oy + r) * mcols + ox + c] = dem.data[r * dem.cols as usize + c];
}
}
}
let nw = tile_bbox(z, x0, y0);
let se = tile_bbox(z, x1, y1);
let union = BBox { min_lon: nw.min_lon, max_lon: se.max_lon, min_lat: se.min_lat, max_lat: nw.max_lat };
eprintln!(
"mosaico {}×{} tiles z{z}{mcols}×{mrows} px, lon[{:.3},{:.3}] lat[{:.3},{:.3}]",
across, down, union.min_lon, union.max_lon, union.min_lat, union.max_lat
);
Heightfield::desde_muestras(union, mcols as u32, mrows as u32, data)
}
/// Caja lon/lat de un *slippy tile* `z/x/y` (esquema XYZ Web Mercator).
fn tile_bbox(z: u32, x: u32, y: u32) -> BBox {
let n = (1u64 << z) as f64;
let lon = |xt: f64| xt / n * 360.0 - 180.0;
let lat = |yt: f64| {
let a = std::f64::consts::PI * (1.0 - 2.0 * yt / n);
a.sinh().atan().to_degrees()
};
BBox {
min_lon: lon(x as f64),
max_lon: lon(x as f64 + 1.0),
min_lat: lat(y as f64 + 1.0), // y+1 = borde sur
max_lat: lat(y as f64), // y = borde norte
}
}
fn bbox_de(md: &MapData) -> BBox {
if let Some(b) = md.bbox_override {
return b;
}
let mut b = BBox::empty();
for &c in &md.points {
b.expand(c);
}
for l in &md.lines {
for &c in l {
b.expand(c);
}
}
for p in &md.polygons {
for r in p {
for &c in r {
b.expand(c);
}
}
}
b
}
fn stats(g: &VoxelGrid, mapa: &MapaVoxel) {
let [dx, dy, dz] = g.dim();
let p = &mapa.paleta;
let et: [(&str, [u8; 3]); 6] =
[("pasto", p.pasto), ("roca", p.roca), ("agua", p.agua), ("calle", p.calle), ("muro", p.muro), ("techo", p.techo)];
let mut c = [0usize; 6];
let mut sol = 0usize;
for z in 0..dz {
for y in 0..dy {
for x in 0..dx {
if let Some([r, gg, b, a]) = g.get(x, y, z) {
if a > 127 {
sol += 1;
for (i, (_, col)) in et.iter().enumerate() {
if [r, gg, b] == *col {
c[i] += 1;
}
}
}
}
}
}
}
eprintln!("── stats ── {sol} sólidos de {}", dx * dy * dz);
for (i, (n, _)) in et.iter().enumerate() {
eprintln!(" {n:>6}: {}", c[i]);
}
}
fn render_png(g: &VoxelGrid, dim: [u32; 3], out: &str, aerea: bool) {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, g);
vr.sun_dir = [0.38, 0.7, -0.42];
vr.atmosphere = Atmosphere {
sky_zenith: [66, 104, 168],
sky_horizon: [188, 210, 236],
fog_density: 0.0016,
god_rays: 0.0,
};
// Cámara adaptativa: si el mundo es ancho E-W (panorámica regional), una vista
// AÉREA oblicua alta que muestra la extensión; si es profundo N-S (postal de
// ciudad+cerro), una vista baja desde el sur. La geometría se pinta centrada.
let (dx, dy, dz) = (dim[0] as f32, dim[1] as f32, dim[2] as f32);
let camera = if aerea || dx > dz * 1.3 {
// Vista aérea alta y algo picada: muestra el transecto completo (valle,
// sierra, costa) de una. Órbita a ~40° desde el sur.
Camera3d::orbit(Vec3::new(0.0, -dy * 0.1, 0.0), 0.0, 0.72, dx.max(dz) * 0.72)
} else {
// Picado 3/4 desde el sur: se lee la trama urbana y el parque en el plano, con
// el Ávila como telón al fondo (no un muro que tapa todo).
Camera3d::orbit(Vec3::new(0.0, -dy * 0.15, 0.0), 0.0, 0.5, dz.max(dx) * 0.82)
};
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &vello::Scene::new(), &view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("base");
let mut scene = Scene3d::new();
let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("mapa") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), &camera, Some(&mut vr), &[]);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(&readback(&hal, &inter), out);
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let srow = row * padded;
pixels.extend_from_slice(&data[srow..srow + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wtr = enc.write_header().unwrap();
wtr.write_image_data(pixels).unwrap();
}
@@ -0,0 +1,325 @@
//! **Heightfield georreferenciado**: un raster de elevación (metros) anclado a una
//! `BBox` lon/lat, con muestreo bilineal. Es la fuente de *relieve real* del puente
//! — reemplaza al ruido fractal de `llimphi-voxel` por la topografía de un lugar.
//!
//! Rutas de entrada soberanas (sin red):
//! - [`Heightfield::plano`] — sin DEM: una lámina a nivel del mar. Sirve para
//! ciudades donde sólo importan agua/calles/edificios.
//! - [`Heightfield::desde_muestras`] — un `Vec<f32>` de metros ya en memoria
//! (por ejemplo lo que produzca un futuro `foreign-geotiff`).
//! - [`Heightfield::desde_pgm`] — un **PGM (P5)** en escala de grises mapeado a
//! `[min_elev, max_elev]`. Es el camino DEM práctico hoy: `gdal_translate -of
//! PNM dem.tif dem.pgm` (o cualquier export a graymap) y listo, sin arrastrar
//! un decoder de imágenes al núcleo.
//!
//! Convención de filas: **fila 0 = norte** (`max_lat`), como los rasters geo.
use nahual_geo_core::BBox;
/// Raster de elevación anclado a una caja geográfica.
#[derive(Debug, Clone)]
pub struct Heightfield {
pub bbox: BBox,
pub cols: u32,
pub rows: u32,
/// Elevación en metros, row-major, fila 0 = norte. `cols * rows` valores.
pub data: Vec<f32>,
pub min_m: f32,
pub max_m: f32,
}
impl Heightfield {
/// Lámina plana a `elev` metros sobre toda la caja (sin relieve).
pub fn plano(bbox: BBox, elev: f32) -> Self {
Self { bbox, cols: 1, rows: 1, data: vec![elev], min_m: elev, max_m: elev }
}
/// Construye desde metros ya en memoria (`cols * rows`, fila 0 = norte).
/// Recorta el vector si viene largo y lo rellena con el último valor si corto.
pub fn desde_muestras(bbox: BBox, cols: u32, rows: u32, mut data: Vec<f32>) -> Self {
let n = (cols.max(1) * rows.max(1)) as usize;
let relleno = data.last().copied().unwrap_or(0.0);
data.resize(n, relleno);
let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
for &v in &data {
if v.is_finite() {
lo = lo.min(v);
hi = hi.max(v);
}
}
if !lo.is_finite() {
lo = 0.0;
hi = 0.0;
}
Self { bbox, cols: cols.max(1), rows: rows.max(1), data, min_m: lo, max_m: hi }
}
/// Decodifica un **PGM binario (P5)** y lo mapea linealmente a
/// `[min_elev, max_elev]` metros. Soporta `maxval` de 8 y 16 bits (16 big-endian,
/// como manda la spec). Comentarios `#` y espacios en el header se saltan.
pub fn desde_pgm(bytes: &[u8], bbox: BBox, min_elev: f32, max_elev: f32) -> Result<Self, String> {
let mut it = PgmLexer { b: bytes, i: 0 };
let magic = it.token().ok_or("PGM: header vacío")?;
if magic != b"P5" {
return Err(format!("PGM: magic {:?}, se esperaba P5 (binario)", String::from_utf8_lossy(&magic)));
}
let cols: u32 = it.numero().ok_or("PGM: falta ancho")?;
let rows: u32 = it.numero().ok_or("PGM: falta alto")?;
let maxval: u32 = it.numero().ok_or("PGM: falta maxval")?;
if cols == 0 || rows == 0 || maxval == 0 {
return Err("PGM: dimensiones/maxval nulos".into());
}
// Tras el maxval hay EXACTAMENTE un byte de espaciado; luego el raster.
let inicio = it.i + 1;
let dieciseis = maxval > 255;
let n = (cols * rows) as usize;
let necesarios = if dieciseis { n * 2 } else { n };
if bytes.len() < inicio + necesarios {
return Err(format!(
"PGM: raster corto ({} bytes, faltan {})",
bytes.len().saturating_sub(inicio),
necesarios
));
}
let raster = &bytes[inicio..inicio + necesarios];
let mut data = Vec::with_capacity(n);
let escala = (max_elev - min_elev) / maxval as f32;
for k in 0..n {
let v = if dieciseis {
u16::from_be_bytes([raster[k * 2], raster[k * 2 + 1]]) as u32
} else {
raster[k] as u32
};
data.push(min_elev + v as f32 * escala);
}
Ok(Self::desde_muestras(bbox, cols, rows, data))
}
/// Desde un **DEM GeoTIFF** (`.tif`) vía [`foreign_geotiff`]: la caja y la grilla
/// salen del propio archivo (georreferenciado), así el relieve queda anclado a su
/// lugar real —no estirado a la caja del GeoJSON como en [`desde_pgm`]. Los
/// centinelas `nodata` se rebajan a la cota mínima válida (huecos al piso).
pub fn desde_geotiff(bytes: &[u8]) -> Result<Self, String> {
let dem = foreign_geotiff::leer_dem(bytes)?;
let bbox = BBox {
min_lon: dem.min_lon,
min_lat: dem.min_lat,
max_lon: dem.max_lon,
max_lat: dem.max_lat,
};
Ok(Self::dem_a_campo(dem, bbox))
}
/// Como [`desde_geotiff`] pero con la **caja lon/lat provista** en vez de la del
/// archivo. Útil cuando el GeoTIFF está en un CRS proyectado (p. ej. Web Mercator
/// de las *terrain tiles*): la caja lon/lat se computa aparte (del slippy tile) y
/// se pasa aquí; la grilla se asume lineal en lon/lat (error ínfimo por tile).
pub fn desde_geotiff_en(bytes: &[u8], bbox: BBox) -> Result<Self, String> {
let dem = foreign_geotiff::leer_dem(bytes)?;
Ok(Self::dem_a_campo(dem, bbox))
}
fn dem_a_campo(dem: foreign_geotiff::Dem, bbox: BBox) -> Self {
let piso = dem.min_m;
let data: Vec<f32> = dem
.data
.iter()
.map(|&v| if Some(v) == dem.nodata || !v.is_finite() { piso } else { v })
.collect();
Self::desde_muestras(bbox, dem.cols, dem.rows, data)
}
/// Muestreo **bilineal** en lon/lat. Fuera de la caja: clamp al borde.
pub fn muestrear(&self, lon: f64, lat: f64) -> f32 {
if self.cols == 1 && self.rows == 1 {
return self.data[0];
}
let bx = &self.bbox;
let u = if bx.max_lon > bx.min_lon {
(lon - bx.min_lon) / (bx.max_lon - bx.min_lon)
} else {
0.0
};
// fila 0 = norte (max_lat) → v crece hacia el sur.
let v = if bx.max_lat > bx.min_lat {
(bx.max_lat - lat) / (bx.max_lat - bx.min_lat)
} else {
0.0
};
let fc = (u.clamp(0.0, 1.0)) * (self.cols - 1) as f64;
let fr = (v.clamp(0.0, 1.0)) * (self.rows - 1) as f64;
let c0 = fc.floor() as u32;
let r0 = fr.floor() as u32;
let c1 = (c0 + 1).min(self.cols - 1);
let r1 = (r0 + 1).min(self.rows - 1);
let tx = (fc - c0 as f64) as f32;
let tz = (fr - r0 as f64) as f32;
let at = |c: u32, r: u32| self.data[(r * self.cols + c) as usize];
let top = at(c0, r0) + (at(c1, r0) - at(c0, r0)) * tx;
let bot = at(c0, r1) + (at(c1, r1) - at(c0, r1)) * tx;
top + (bot - top) * tz
}
}
/// Lexer mínimo de header PGM: tokens separados por whitespace, comentarios `#`.
struct PgmLexer<'a> {
b: &'a [u8],
i: usize,
}
impl<'a> PgmLexer<'a> {
fn skip_ws(&mut self) {
while self.i < self.b.len() {
let c = self.b[self.i];
if c == b'#' {
while self.i < self.b.len() && self.b[self.i] != b'\n' {
self.i += 1;
}
} else if c.is_ascii_whitespace() {
self.i += 1;
} else {
break;
}
}
}
fn token(&mut self) -> Option<Vec<u8>> {
self.skip_ws();
let start = self.i;
while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() {
self.i += 1;
}
if self.i > start {
Some(self.b[start..self.i].to_vec())
} else {
None
}
}
fn numero(&mut self) -> Option<u32> {
let t = self.token()?;
std::str::from_utf8(&t).ok()?.trim().parse().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn caja() -> BBox {
BBox { min_lon: 0.0, min_lat: 0.0, max_lon: 1.0, max_lat: 1.0 }
}
#[test]
fn plano_muestrea_constante() {
let h = Heightfield::plano(caja(), 12.0);
assert_eq!(h.muestrear(0.3, 0.7), 12.0);
assert_eq!(h.min_m, 12.0);
assert_eq!(h.max_m, 12.0);
}
#[test]
fn bilineal_interpola_una_rampa() {
// 2×2: oeste bajo, este alto (misma en ambas filas) → rampa en lon.
let h = Heightfield::desde_muestras(caja(), 2, 2, vec![0.0, 100.0, 0.0, 100.0]);
assert!((h.muestrear(0.0, 0.5) - 0.0).abs() < 1e-3);
assert!((h.muestrear(1.0, 0.5) - 100.0).abs() < 1e-3);
assert!((h.muestrear(0.5, 0.5) - 50.0).abs() < 1.0);
}
#[test]
fn fila_cero_es_norte() {
// fila 0 (norte) = 100, fila 1 (sur) = 0.
let h = Heightfield::desde_muestras(caja(), 2, 2, vec![100.0, 100.0, 0.0, 0.0]);
assert!(h.muestrear(0.5, 1.0) > 90.0, "lat alta (norte) debe ser alta");
assert!(h.muestrear(0.5, 0.0) < 10.0, "lat baja (sur) debe ser baja");
}
#[test]
fn pgm_p5_8bit_roundtrip() {
// 2×2, maxval 255. Header + 4 bytes de raster.
let mut bytes = b"P5 2 2 255 ".to_vec();
bytes.extend_from_slice(&[0u8, 255, 128, 64]);
let h = Heightfield::desde_pgm(&bytes, caja(), 0.0, 1000.0).unwrap();
assert_eq!(h.cols, 2);
assert_eq!(h.rows, 2);
assert!((h.min_m - 0.0).abs() < 1e-3);
assert!((h.max_m - 1000.0).abs() < 1e-3);
// esquina NE (fila0,col1) = 255 → 1000 m.
assert!((h.muestrear(1.0, 1.0) - 1000.0).abs() < 1.0);
}
/// GeoTIFF 2×2 float32 LE mínimo, con PixelScale+Tiepoint. Fila 0 (norte) alta.
fn geotiff_2x2(norte: [f32; 2], sur: [f32; 2], ox: f64, oy: f64, paso: f64) -> Vec<u8> {
let mut raster = Vec::new();
for v in [norte[0], norte[1], sur[0], sur[1]] {
raster.extend_from_slice(&v.to_le_bytes());
}
let raster_off = 8usize;
let ifd_off = raster_off + raster.len();
let n: u16 = 11;
let ps_off = ifd_off + 2 + n as usize * 12 + 4;
let tp_off = ps_off + 24;
let e: [(u16, u16, u32, u32); 11] = [
(256, 3, 1, 2),
(257, 3, 1, 2),
(258, 3, 1, 32),
(259, 3, 1, 1),
(273, 4, 1, raster_off as u32),
(277, 3, 1, 1),
(278, 3, 1, 2),
(279, 4, 1, raster.len() as u32),
(339, 3, 1, 3),
(33550, 12, 3, ps_off as u32),
(33922, 12, 6, tp_off as u32),
];
let mut o = Vec::new();
o.extend_from_slice(b"II");
o.extend_from_slice(&42u16.to_le_bytes());
o.extend_from_slice(&(ifd_off as u32).to_le_bytes());
o.extend_from_slice(&raster);
o.extend_from_slice(&n.to_le_bytes());
for (t, ty, c, v) in e {
o.extend_from_slice(&t.to_le_bytes());
o.extend_from_slice(&ty.to_le_bytes());
o.extend_from_slice(&c.to_le_bytes());
if ty == 3 {
o.extend_from_slice(&(v as u16).to_le_bytes());
o.extend_from_slice(&[0, 0]);
} else {
o.extend_from_slice(&v.to_le_bytes());
}
}
o.extend_from_slice(&0u32.to_le_bytes());
for d in [paso, paso, 0.0] {
o.extend_from_slice(&d.to_le_bytes());
}
for d in [0.0, 0.0, 0.0, ox, oy, 0.0] {
o.extend_from_slice(&d.to_le_bytes());
}
o
}
#[test]
fn desde_geotiff_georreferencia_y_muestrea() {
// Norte alto (200 m), sur bajo (0 m); origen oeste=10, norte=46, paso 0.01.
let bytes = geotiff_2x2([200.0, 200.0], [0.0, 0.0], 10.0, 46.0, 0.01);
let h = Heightfield::desde_geotiff(&bytes).unwrap();
assert_eq!((h.cols, h.rows), (2, 2));
assert!((h.min_m - 0.0).abs() < 1e-3 && (h.max_m - 200.0).abs() < 1e-3);
// Caja del propio DEM (no la del GeoJSON).
assert!((h.bbox.min_lon - 10.0).abs() < 1e-9, "min_lon {}", h.bbox.min_lon);
assert!((h.bbox.max_lat - 46.0).abs() < 1e-9, "max_lat {}", h.bbox.max_lat);
// Norte (lat alta) alto, sur (lat baja) bajo.
assert!(h.muestrear(10.01, 45.999) > 150.0, "norte alto");
assert!(h.muestrear(10.01, 45.981) < 50.0, "sur bajo");
}
#[test]
fn pgm_con_comentario() {
let mut bytes = b"P5\n# hecho a mano\n1 1\n255\n".to_vec();
bytes.push(200);
let h = Heightfield::desde_pgm(&bytes, caja(), 0.0, 100.0).unwrap();
assert!((h.muestrear(0.5, 0.5) - 200.0 / 255.0 * 100.0).abs() < 0.5);
}
}
@@ -0,0 +1,43 @@
//! # nahual-geo-voxel — puente **mapa real → mundo voxel**
//!
//! Toma el relieve de un lugar ([`Heightfield`]: un DEM/PGM georreferenciado, o una
//! lámina plana) y los rasgos vectoriales del visor de mapas de `nahual`
//! (`nahual-geo-core`: agua, calles, edificios en lon/lat) y los **rasteriza en un
//! [`VoxelGrid`]** de `llimphi-3d` — el mismo tipo que consume el motor 3D y
//! `llimphi-voxel`.
//!
//! Es la contraparte geoespacial del world-gen procedural de `llimphi-voxel`:
//! allá el terreno sale de ruido fractal; aquí sale de un mapa. La API espeja a la
//! de `Bioma`: [`MapaVoxel::generar_ventana`] es a este crate lo que
//! `Bioma::generate_window` es a aquél, y es igual de *streamable* (misma columna de
//! mundo → mismo resultado, sin importar la ventana).
//!
//! ## Uso
//! ```no_run
//! use nahual_geo_voxel::{Heightfield, MapaVoxel, Opciones};
//! use nahual_geo_core::{BBox, load_map};
//!
//! let bbox = BBox { min_lon: -74.01, min_lat: 40.70, max_lon: -73.99, max_lat: 40.72 };
//! // Relieve real desde un PGM (DEM exportado con `gdal_translate -of PNM`)…
//! let dem = std::fs::read("dem.pgm").unwrap();
//! let height = Heightfield::desde_pgm(&dem, bbox, 0.0, 120.0).unwrap();
//! // …y los rasgos desde un GeoJSON del visor de mapas.
//! let md = load_map(&std::fs::read("ciudad.geojson").unwrap(), None).unwrap();
//! let mapa = MapaVoxel::desde_mapdata(bbox, height, Opciones::default(), &md);
//! let (grid, dim, recortado) = mapa.generar_recuadro(512);
//! eprintln!("mundo {dim:?} recortado={recortado}");
//! ```
//!
//! Sin render ni red: este crate **produce el grid**; lo pinta `llimphi-3d`.
mod campo;
mod mapa;
mod proyeccion;
pub use campo::Heightfield;
pub use mapa::{MapaVoxel, Opciones, Paleta};
pub use proyeccion::Proyeccion;
// Reexport de conveniencia para que el consumidor no dependa por separado del core
// geoespacial sólo para armar una `BBox`.
pub use nahual_geo_core::{BBox, Coord};
@@ -0,0 +1,741 @@
//! El puente en sí: [`MapaVoxel`] junta el relieve ([`Heightfield`]) con los rasgos
//! vectoriales del mapa (agua/calles/edificios en lon/lat) y los **rasteriza en un
//! [`VoxelGrid`]** por ventana, igual que `Bioma::generate_window` de `llimphi-voxel`
//! pero con el world-gen viniendo de un lugar real:
//!
//! 1. **Terreno** — cada columna toma su altura del heightfield, bandeada por cota
//! y pendiente (arena/pasto/roca/nieve) y anegada hasta el nivel del mar.
//! 2. **Agua** — los polígonos de agua se pintan a su cota (lagos, ríos anchos).
//! 3. **Calles** — las polilíneas se estampan como pavimento sobre la superficie.
//! 4. **Edificios** — las huellas se **extruyen** desde el suelo hasta su altura
//! (de `properties.height`/`building:levels`, o un default), con muro y techo.
//!
//! Clasificación de rasgos por dos vías: [`MapaVoxel::desde_mapdata`] (GeoJSON ya
//! parseado, decide por `properties`) y [`MapaVoxel::desde_tiles`] (features MVT,
//! decide por el nombre de la capa: `water`/`road`/`building`…).
use llimphi_3d::VoxelGrid;
use nahual_geo_core::vt::{TileFeature, TileGeom};
use nahual_geo_core::{BBox, Coord, MapData};
use crate::campo::Heightfield;
use crate::proyeccion::Proyeccion;
/// Colores de los materiales del mundo (RGB `u8`). Defaults sobrios y legibles.
#[derive(Debug, Clone, Copy)]
pub struct Paleta {
pub arena: [u8; 3],
pub pasto: [u8; 3],
pub roca: [u8; 3],
pub nieve: [u8; 3],
pub agua: [u8; 3],
pub calle: [u8; 3],
pub muro: [u8; 3],
pub techo: [u8; 3],
/// Color de un **hito** (monumento/obelisco marcado como columna alta).
pub hito: [u8; 3],
}
impl Default for Paleta {
fn default() -> Self {
Self {
arena: [206, 194, 148],
pasto: [96, 132, 66],
roca: [122, 120, 116],
nieve: [236, 240, 246],
agua: [58, 108, 168],
calle: [58, 58, 64],
muro: [188, 182, 170],
techo: [150, 96, 84],
hito: [236, 214, 128], // dorado claro, destaca sobre la ciudad
}
}
}
/// Opciones de conversión mapa → voxel.
#[derive(Debug, Clone, Copy)]
pub struct Opciones {
/// Resolución horizontal: metros por voxel.
pub metros_por_voxel: f64,
/// Exageración vertical (1.0 = fiel; >1 acentúa el relieve).
pub escala_v: f32,
/// Cota del agua, en metros (nivel del mar/lago).
pub nivel_mar_m: f32,
/// Voxels de colchón bajo la cota mínima (para que nada quede en `y=0`).
pub base_y: u32,
/// Altura por defecto de un edificio sin dato, en metros.
pub alto_edificio_m: f32,
/// Ancho de calzada por defecto, en metros.
pub ancho_calle_m: f32,
/// Cota (m) sobre la que aflora nieve. Default muy alto → trópico sin nieve
/// (el Ávila/Henri Pittier no tienen nieve). Bajala para climas fríos.
pub snowline_m: f32,
/// **Cota de referencia** (m) que mapea a `base_y`. `None` = usar el mínimo del
/// heightfield entero. Al recortar un chunk chico de un DEM grande, pasa el
/// mínimo LOCAL para que el relieve del chunk quepa en el alto del mundo (si no,
/// una zona alta se aplasta contra el techo).
pub ref_elev_m: Option<f32>,
}
impl Default for Opciones {
fn default() -> Self {
Self {
metros_por_voxel: 3.0,
escala_v: 1.0,
nivel_mar_m: 0.0,
base_y: 2,
alto_edificio_m: 9.0,
ancho_calle_m: 8.0,
snowline_m: 4800.0,
ref_elev_m: None,
}
}
}
/// Un mundo voxel derivado de un mapa real: relieve + rasgos, listo para
/// [`generar_ventana`](Self::generar_ventana).
#[derive(Debug, Clone)]
pub struct MapaVoxel {
pub proj: Proyeccion,
pub height: Heightfield,
/// Cuerpos de agua (anillo exterior en lon/lat).
pub agua: Vec<Vec<Coord>>,
/// Calles/caminos (polilíneas en lon/lat).
pub calles: Vec<Vec<Coord>>,
/// Edificios: `(huella exterior lon/lat, altura en metros)`.
pub edificios: Vec<(Vec<Coord>, f32)>,
/// **Hitos** (monumentos/obeliscos): `(punto lon/lat, altura en metros)`,
/// pintados como una columna delgada del color [`Paleta::hito`].
pub hitos: Vec<(Coord, f32)>,
pub paleta: Paleta,
pub opts: Opciones,
}
impl MapaVoxel {
/// Constructor base: sólo relieve, sin rasgos (agregalos con los `desde_*` o a
/// mano). `bbox` fija la extensión; el heightfield puede cubrir otra caja (se
/// muestrea por lon/lat).
pub fn nuevo(bbox: BBox, height: Heightfield, opts: Opciones) -> Self {
Self {
proj: Proyeccion::new(bbox, opts.metros_por_voxel),
height,
agua: Vec::new(),
calles: Vec::new(),
edificios: Vec::new(),
hitos: Vec::new(),
paleta: Paleta::default(),
opts,
}
}
/// Clasifica los rasgos de un [`MapData`] (GeoJSON) por sus `properties`:
/// polígonos con `building` → edificios (altura de `height`/`building:levels`),
/// polígonos de agua (`natural=water`, `waterway`, `water`) → agua, líneas con
/// `highway` → calles.
pub fn desde_mapdata(bbox: BBox, height: Heightfield, opts: Opciones, md: &MapData) -> Self {
let mut m = Self::nuevo(bbox, height, opts);
// Polígonos.
for (i, poly) in md.polygons.iter().enumerate() {
let ring = match poly.first() {
Some(r) if r.len() >= 3 => r.clone(),
_ => continue,
};
let feat = md.polygon_feat.get(i).and_then(|&f| md.features.get(f));
if let Some(f) = feat {
if es_edificio(f) {
let alt = altura_explicita(f).unwrap_or_else(|| altura_variada(&ring, opts.alto_edificio_m));
m.edificios.push((ring, alt));
continue;
}
if es_agua_props(f) {
m.agua.push(ring);
continue;
}
}
}
// Líneas → calles si son viales.
for (i, line) in md.lines.iter().enumerate() {
if line.len() < 2 {
continue;
}
let feat = md.line_feat.get(i).and_then(|&f| md.features.get(f));
let vial = feat.map(es_vial_props).unwrap_or(false);
if vial {
m.calles.push(line.clone());
}
}
m
}
/// Clasifica *features* MVT ya decodificadas (lon/lat) por el **nombre de la
/// capa** (convención OpenMapTiles/Mapbox): `water`/`waterway` → agua,
/// `transportation`/`road`/`highway` → calles, `building` → edificios.
/// Los tiles MVT no traen alturas aquí, así que los edificios usan el default.
pub fn desde_tiles(bbox: BBox, height: Heightfield, opts: Opciones, feats: &[TileFeature]) -> Self {
let mut m = Self::nuevo(bbox, height, opts);
for f in feats {
let capa = f.layer.to_ascii_lowercase();
match &f.geom {
TileGeom::Polygon(rings) => {
let ring = match rings.first() {
Some(r) if r.len() >= 3 => r.clone(),
_ => continue,
};
if capa.contains("building") {
let alt = altura_variada(&ring, opts.alto_edificio_m);
m.edificios.push((ring, alt));
} else if capa.contains("water") {
m.agua.push(ring);
}
}
TileGeom::Line(pts) => {
if pts.len() >= 2
&& (capa.contains("transport")
|| capa.contains("road")
|| capa.contains("highway")
|| capa.contains("street"))
{
m.calles.push(pts.clone());
}
}
TileGeom::Point(_) => {}
}
}
m
}
// ── Métrica vertical ──────────────────────────────────────────────────────
/// Voxels por metro en vertical (con la exageración aplicada).
#[inline]
fn vpm(&self) -> f32 {
self.opts.escala_v / self.opts.metros_por_voxel as f32
}
/// Cota `y` (voxel, clampeada a `[0, dy-1]`) de una elevación en metros.
#[inline]
fn y_de(&self, elev: f32, dy: u32) -> u32 {
let ref_m = self.opts.ref_elev_m.unwrap_or(self.height.min_m);
let y = self.opts.base_y as i32 + ((elev - ref_m) * self.vpm()).round() as i32;
y.clamp(0, dy as i32 - 1) as u32
}
/// Cota `y` del tope sólido de la columna de mundo `(wx, wz)`.
#[inline]
fn tope(&self, wx: i32, wz: i32, dy: u32) -> u32 {
let (lon, lat) = self.proj.a_geo(wx, wz);
self.y_de(self.height.muestrear(lon, lat), dy)
}
/// **Alto sugerido** del mundo (voxels) para que quepan relieve + edificios.
pub fn alto_sugerido(&self) -> u32 {
let rango_m = (self.height.max_m - self.height.min_m).max(0.0);
let edif_m = self
.edificios
.iter()
.map(|(_, h)| *h)
.fold(self.opts.alto_edificio_m, f32::max);
let v = self.opts.base_y as f32 + (rango_m + edif_m) * self.vpm() + 4.0;
(v.ceil() as u32).clamp(16, 512)
}
/// Dimensión completa del mundo `[ancho, alto, largo]` con el `alto` dado.
pub fn dim_mundo(&self, alto: u32) -> [u32; 3] {
[self.proj.ancho, alto, self.proj.largo]
}
// ── Generación ────────────────────────────────────────────────────────────
/// Genera la **ventana** `dim` cuyo vértice inferior está en `origin` (mundo
/// voxel), poblada con terreno + agua + calles + edificios. Streamable: el
/// mismo `(wx, wz)` da siempre la misma columna, sin importar la ventana.
pub fn generar_ventana(&self, dim: [u32; 3], origin: [i32; 2]) -> VoxelGrid {
let [dx, dy, dz] = dim;
let (ox, oz) = (origin[0], origin[1]);
let mut g = VoxelGrid::new(dim);
let wy = self.y_de(self.opts.nivel_mar_m, dy);
// 1) Terreno + anegado hasta el nivel del mar.
for lz in 0..dz as i32 {
for lx in 0..dx as i32 {
let (wx, wz) = (ox + lx, oz + lz);
let (lon, lat) = self.proj.a_geo(wx, wz);
let elev = self.height.muestrear(lon, lat);
let th = self.y_de(elev, dy);
let slope = (th as i32 - self.tope(wx - 1, wz, dy) as i32)
.abs()
.max((th as i32 - self.tope(wx, wz - 1, dy) as i32).abs());
for y in 0..=th {
let c = self.color_terreno(elev, y, th, slope, wx, wz);
g.set(lx as u32, y, lz as u32, c);
}
if th < wy {
for y in (th + 1)..=wy {
g.set(lx as u32, y, lz as u32, self.paleta.agua);
}
}
}
}
// 2) Cuerpos de agua explícitos (lagos/ríos): a su cota.
let agua = self.paleta.agua;
for ring in &self.agua {
self.por_columnas_en(ring, origin, dim, |g, lx, lz, wx, wz| {
let t = self.tope(wx, wz, dy);
let lo = t.min(wy);
for y in lo..=wy {
g.set(lx, y, lz, agua);
}
}, &mut g);
}
// 3) Calles: pavimento sobre la superficie.
let hw = ((self.opts.ancho_calle_m * 0.5) * self.proj.voxels_por_metro() as f32)
.round()
.max(0.0) as i32;
let calle = self.paleta.calle;
for line in &self.calles {
self.por_segmentos(line, origin, dim, hw, |g, lx, lz, wx, wz| {
let t = self.tope(wx, wz, dy);
g.set(lx, t, lz, calle);
}, &mut g);
}
// 4) Edificios: extruidos desde el suelo.
let (muro, techo) = (self.paleta.muro, self.paleta.techo);
for (ring, alt_m) in &self.edificios {
let alto_vox = (*alt_m * self.vpm()).round().max(1.0) as u32;
self.por_columnas_en(ring, origin, dim, |g, lx, lz, wx, wz| {
let base = self.tope(wx, wz, dy);
let top = (base + alto_vox).min(dy - 1);
for y in (base + 1)..=top {
g.set(lx, y, lz, muro);
}
if top > base {
g.set(lx, top, lz, techo);
}
}, &mut g);
}
// 5) Hitos (monumentos): una columna delgada 2×2 desde el suelo hasta su
// altura, para que un obelisco/torre marcada se lea sobre la ciudad.
let hito_col = self.paleta.hito;
for (pt, alt_m) in &self.hitos {
let (fx, fz) = self.proj.a_mundo(pt[0], pt[1]);
let alto_vox = (*alt_m * self.vpm()).round().max(2.0) as u32;
for dz2 in 0..3 {
for dx2 in 0..3 {
let (wx, wz) = (fx.floor() as i32 + dx2, fz.floor() as i32 + dz2);
let (lx, lz) = (wx - ox, wz - oz);
if lx < 0 || lz < 0 || lx >= dx as i32 || lz >= dz as i32 {
continue;
}
let base = self.tope(wx, wz, dy);
let top = (base + alto_vox).min(dy - 1);
for y in (base + 1)..=top {
g.set(lx as u32, y, lz as u32, hito_col);
}
}
}
}
g.reset_dirty();
g
}
/// Genera un **recuadro** desde `origin = [0,0]` acotado a `max_lado` voxels por
/// eje (para no reventar la RAM con ciudades enormes). Devuelve el grid y la
/// `dim` efectivamente usada; si se recortó, `recortado` lo indica.
pub fn generar_recuadro(&self, max_lado: u32) -> (VoxelGrid, [u32; 3], bool) {
let alto = self.alto_sugerido();
let dx = self.proj.ancho.min(max_lado);
let dz = self.proj.largo.min(max_lado);
let recortado = dx < self.proj.ancho || dz < self.proj.largo;
let dim = [dx, alto, dz];
(self.generar_ventana(dim, [0, 0]), dim, recortado)
}
/// Color de un voxel de terreno, **por metros absolutos + pendiente + moteado**
/// (no por fracción del alto del mundo, que aplanaba todo). `elev_m` = cota de la
/// columna; `surf` = si es el voxel superior. La superficie decide playa / roca de
/// acantilado / nieve (sólo sobre `snowline_m`) / vegetación que se oscurece con la
/// altura; el interior es tierra. Todo con un jitter determinista para romper el
/// look plano de "un solo verde".
fn color_terreno(&self, elev_m: f32, y: u32, th: u32, slope: i32, wx: i32, wz: i32) -> [u8; 3] {
let p = &self.paleta;
// Interior de la columna: tierra parda (rara vez visible salvo en cortes).
if y != th {
return jitter([96, 78, 60], wx, y, wz, 12);
}
let mar = self.opts.nivel_mar_m;
// Mar: bajo el nivel del mar, agua (se lee a cualquier resolución, sin
// depender del relleno de volumen que a escala regional es sub-voxel).
if elev_m <= mar {
return jitter(p.agua, wx, y, wz, 6);
}
// Playa: franja angosta apenas sobre el nivel del mar.
if elev_m <= mar + 8.0 {
return jitter(p.arena, wx, y, wz, 8);
}
// Acantilado: cara superior en pendiente fuerte = roca expuesta.
if slope > 4 {
return jitter(p.roca, wx, y, wz, 14);
}
// Nieve sólo sobre la línea de nieves (trópico: nunca).
if elev_m > self.opts.snowline_m {
return jitter(p.nieve, wx, y, wz, 4);
}
// Roca en las cumbres altas aunque no sean acantilado (arriba de ~1900 m aflora).
let t_roca = ((elev_m - 1900.0) / 500.0).clamp(0.0, 1.0);
// Vegetación: verde de valle → verde oscuro/seco de altura, mezclando a roca arriba.
let t = ((elev_m - mar) / 1400.0).clamp(0.0, 1.0);
let verde = lerp_col(p.pasto, [74, 96, 54], t);
let base = lerp_col(verde, p.roca, t_roca);
jitter(base, wx, y, wz, 16)
}
/// Rasteriza un polígono (anillo lon/lat) sobre la ventana: llama `f` por cada
/// columna local `(lx, lz)` cuyo centro cae dentro. Acota al bbox del anillo.
fn por_columnas_en(
&self,
ring: &[Coord],
origin: [i32; 2],
dim: [u32; 3],
f: impl Fn(&mut VoxelGrid, u32, u32, i32, i32),
g: &mut VoxelGrid,
) {
let [dx, _dy, dz] = dim;
let (ox, oz) = (origin[0], origin[1]);
let (mut minx, mut minz, mut maxx, mut maxz) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for &[lon, lat] in ring {
let (fx, fz) = self.proj.a_mundo(lon, lat);
minx = minx.min(fx);
minz = minz.min(fz);
maxx = maxx.max(fx);
maxz = maxz.max(fz);
}
let x0 = (minx.floor() as i32).max(ox);
let x1 = (maxx.ceil() as i32).min(ox + dx as i32 - 1);
let z0 = (minz.floor() as i32).max(oz);
let z1 = (maxz.ceil() as i32).min(oz + dz as i32 - 1);
for wz in z0..=z1 {
for wx in x0..=x1 {
let (lon, lat) = self.proj.a_geo(wx, wz);
if punto_en_poligono(ring, lon, lat) {
f(g, (wx - ox) as u32, (wz - oz) as u32, wx, wz);
}
}
}
}
/// Rasteriza una polilínea (lon/lat) como una banda de medio-ancho `hw` voxels:
/// muestrea a lo largo de cada segmento y estampa un cuadrado por muestra.
fn por_segmentos(
&self,
line: &[Coord],
origin: [i32; 2],
dim: [u32; 3],
hw: i32,
f: impl Fn(&mut VoxelGrid, u32, u32, i32, i32),
g: &mut VoxelGrid,
) {
let [dx, _dy, dz] = dim;
let (ox, oz) = (origin[0], origin[1]);
for par in line.windows(2) {
let (fx0, fz0) = self.proj.a_mundo(par[0][0], par[0][1]);
let (fx1, fz1) = self.proj.a_mundo(par[1][0], par[1][1]);
let len = ((fx1 - fx0).hypot(fz1 - fz0)).max(1e-3);
let pasos = (len * 2.0).ceil() as i32; // 2 muestras por voxel
for s in 0..=pasos {
let t = s as f64 / pasos as f64;
let cx = (fx0 + (fx1 - fx0) * t).round() as i32;
let cz = (fz0 + (fz1 - fz0) * t).round() as i32;
for dz2 in -hw..=hw {
for dx2 in -hw..=hw {
let (wx, wz) = (cx + dx2, cz + dz2);
let (lx, lz) = (wx - ox, wz - oz);
if lx >= 0 && lz >= 0 && lx < dx as i32 && lz < dz as i32 {
f(g, lx as u32, lz as u32, wx, wz);
}
}
}
}
}
}
}
// ── Clasificadores de properties (GeoJSON/OSM) ────────────────────────────────
fn prop<'a>(f: &'a nahual_geo_core::FeatureProps, key: &str) -> Option<&'a str> {
f.props.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
/// `true` si la feature es un edificio.
fn es_edificio(f: &nahual_geo_core::FeatureProps) -> bool {
match prop(f, "building").or_else(|| prop(f, "building:part")) {
Some(v) => v != "no" && v != "false",
None => false,
}
}
/// Altura **explícita** del edificio (de `height`/`render_height` o `building:levels`
/// ×3 m/piso), en metros; `None` si OSM no la trae (→ se estima con [`altura_variada`]).
fn altura_explicita(f: &nahual_geo_core::FeatureProps) -> Option<f32> {
if let Some(h) = f.number("height").or_else(|| f.number("render_height")) {
return Some((h as f32).max(2.0));
}
let niveles = f
.number("building:levels")
.or_else(|| f.number("levels"))
.or_else(|| f.number("building:floors"));
niveles.map(|n| (n as f32 * 3.0).max(3.0))
}
/// **Altura estimada** para un edificio sin dato: en vez de un valor fijo (que deja
/// un skyline plano), varía por **tamaño de huella** (más grande → más alto, ~√área)
/// y un **jitter determinista** por posición — así dos edificios sin dato distintos
/// dan alturas distintas, estables entre ventanas. Rango `[base·0.45, base·4.5]`.
fn altura_variada(ring: &[Coord], base: f32) -> f32 {
// Área de la huella (shoelace, en grados²) → m² con la escala local.
let lat0 = ring.first().map(|c| c[1]).unwrap_or(0.0);
let m_lat = 111_320.0_f64;
let m_lon = m_lat * lat0.to_radians().cos().abs().max(1e-3);
let mut area2 = 0.0_f64;
let (mut cx, mut cz) = (0.0_f64, 0.0_f64);
let n = ring.len();
for i in 0..n {
let [x0, y0] = ring[i];
let [x1, y1] = ring[(i + 1) % n];
area2 += x0 * y1 - x1 * y0;
cx += x0;
cz += y0;
}
let area_m2 = (area2.abs() * 0.5) * m_lon * m_lat;
let (cx, cz) = (cx / n as f64, cz / n as f64);
// Factor por área: ~1 para 300 m², sube con √área (acotado).
let area_f = ((area_m2 / 300.0).sqrt() as f32).clamp(0.5, 5.0);
// Jitter determinista por centroide (mezcla entera estilo hash).
let h = hash01(cx, cz);
let mult = (0.6 + 1.1 * h) * (0.7 + 0.35 * area_f);
(base * mult).clamp(base * 0.45, base * 4.5)
}
/// Hash determinista de un par de coordenadas → `[0, 1)`.
fn hash01(a: f64, b: f64) -> f32 {
let xa = (a * 100_000.0) as i64 as u64;
let xb = (b * 100_000.0) as i64 as u64;
let mut h = xa.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(xb.wrapping_mul(0xC2B2_AE3D_27D4_EB4F));
h ^= h >> 29;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
h ^= h >> 32;
(h >> 40) as f32 / (1u32 << 24) as f32
}
fn es_agua_props(f: &nahual_geo_core::FeatureProps) -> bool {
prop(f, "natural") == Some("water")
|| prop(f, "water").is_some()
|| prop(f, "waterway").is_some()
|| prop(f, "landuse") == Some("reservoir")
}
fn es_vial_props(f: &nahual_geo_core::FeatureProps) -> bool {
prop(f, "highway").is_some() || prop(f, "railway").is_some()
}
/// Interpola dos colores RGB `[0..255]`.
fn lerp_col(a: [u8; 3], b: [u8; 3], t: f32) -> [u8; 3] {
let t = t.clamp(0.0, 1.0);
[
(a[0] as f32 + (b[0] as f32 - a[0] as f32) * t) as u8,
(a[1] as f32 + (b[1] as f32 - a[1] as f32) * t) as u8,
(a[2] as f32 + (b[2] as f32 - a[2] as f32) * t) as u8,
]
}
/// Perturba un color determinísticamente por posición de mundo (±`amp` por canal +
/// un cambio de brillo suave). Rompe el look plano de un color macizo. Puro → seamless
/// entre ventanas de streaming.
fn jitter(base: [u8; 3], wx: i32, y: u32, wz: i32, amp: i32) -> [u8; 3] {
let h = |s: u32| {
let mut v = (wx as u32)
.wrapping_mul(0x9E37_79B9)
.wrapping_add((wz as u32).wrapping_mul(0x85EB_CA77))
.wrapping_add((y).wrapping_mul(0xC2B2_AE35))
.wrapping_add(s);
v ^= v >> 15;
v = v.wrapping_mul(0x2C1B_3C6D);
v ^= v >> 13;
(v & 0xFF) as i32 - 128
};
let d = h(0) * amp / 128;
let ch = |c: u8, k: i32| (c as i32 + d + k).clamp(0, 255) as u8;
[ch(base[0], h(1) * amp / 256), ch(base[1], h(2) * amp / 256), ch(base[2], h(3) * amp / 256)]
}
/// Ray-casting: `true` si `(lon, lat)` cae dentro del anillo (lon/lat).
fn punto_en_poligono(ring: &[Coord], lon: f64, lat: f64) -> bool {
let mut dentro = false;
let n = ring.len();
if n < 3 {
return false;
}
let mut j = n - 1;
for i in 0..n {
let (xi, yi) = (ring[i][0], ring[i][1]);
let (xj, yj) = (ring[j][0], ring[j][1]);
let cruza = (yi > lat) != (yj > lat);
if cruza {
let x_corte = xi + (lat - yi) / (yj - yi) * (xj - xi);
if lon < x_corte {
dentro = !dentro;
}
}
j = i;
}
dentro
}
#[cfg(test)]
mod tests {
use super::*;
fn caja() -> BBox {
BBox { min_lon: 0.0, min_lat: 0.0, max_lon: 0.009, max_lat: 0.009 }
}
// Cuadrado en lon/lat centrado, ~200 m de lado.
fn cuadrado(cx: f64, cz: f64, r: f64) -> Vec<Coord> {
vec![
[cx - r, cz - r],
[cx + r, cz - r],
[cx + r, cz + r],
[cx - r, cz + r],
[cx - r, cz - r],
]
}
fn contar_color(g: &VoxelGrid, color: [u8; 3]) -> usize {
let [dx, dy, dz] = g.dim();
let mut n = 0;
for z in 0..dz {
for y in 0..dy {
for x in 0..dx {
if let Some([r, gg, b, a]) = g.get(x, y, z) {
if a > 127 && [r, gg, b] == color {
n += 1;
}
}
}
}
}
n
}
#[test]
fn punto_en_cuadrado() {
let sq = cuadrado(0.0045, 0.0045, 0.001);
assert!(punto_en_poligono(&sq, 0.0045, 0.0045));
assert!(!punto_en_poligono(&sq, 0.0, 0.0));
assert!(!punto_en_poligono(&sq, 0.008, 0.008));
}
#[test]
fn terreno_plano_llena_todas_las_columnas() {
// Terreno plano SOBRE el mar → cada columna tiene tope sólido (el color ahora
// lleva jitter, así que se cuenta el relleno, no un color exacto).
let m = MapaVoxel::nuevo(caja(), Heightfield::plano(caja(), 150.0), Opciones::default());
let (g, dim, _) = m.generar_recuadro(64);
let mut columnas = 0;
for z in 0..dim[2] {
for x in 0..dim[0] {
if g.height_at(x, z).is_some() {
columnas += 1;
}
}
}
assert_eq!(columnas, (dim[0] * dim[2]) as usize, "toda columna debe tener suelo");
}
#[test]
fn bajo_el_mar_es_agua() {
// Relieve a 0 m con nivel del mar 0 → la superficie se pinta azulada (agua).
let mut opts = Opciones::default();
opts.nivel_mar_m = 0.0;
let m = MapaVoxel::nuevo(caja(), Heightfield::plano(caja(), 0.0), opts);
let (g, dim, _) = m.generar_recuadro(48);
let mut azul = 0;
for z in 0..dim[2] {
for x in 0..dim[0] {
if let Some(y) = g.height_at(x, z) {
if let Some([r, gg, b, _]) = g.get(x, y, z) {
if b as i32 > r as i32 + 40 && b as i32 > gg as i32 + 30 {
azul += 1;
}
}
}
}
}
assert!(azul > 0, "el nivel del mar debe leerse como agua (azulado)");
}
#[test]
fn altura_variada_no_es_uniforme() {
// Cuatro huellas iguales en posiciones distintas → alturas distintas.
let base = 10.0;
let hs: Vec<f32> = [(0.001, 0.001), (0.004, 0.002), (0.006, 0.005), (0.002, 0.007)]
.iter()
.map(|&(cx, cz)| altura_variada(&cuadrado(cx, cz, 0.0004), base))
.collect();
let distintas =
hs.iter().map(|h| (h * 100.0) as i32).collect::<std::collections::BTreeSet<_>>().len();
assert!(distintas >= 3, "las alturas deben variar, no ser un slab: {hs:?}");
// Y todas dentro del rango acotado.
for h in hs {
assert!(h >= base * 0.45 && h <= base * 4.5, "altura {h} fuera de rango");
}
}
#[test]
fn edificio_extruido_sube() {
let mut m = MapaVoxel::nuevo(caja(), Heightfield::plano(caja(), 0.0), Opciones::default());
m.edificios.push((cuadrado(0.0045, 0.0045, 0.0008), 30.0));
let alto = m.alto_sugerido();
let g = m.generar_ventana(m.dim_mundo(alto), [0, 0]);
let muros = contar_color(&g, m.paleta.muro);
let techos = contar_color(&g, m.paleta.techo);
assert!(muros > 0, "debe haber muros extruidos");
assert!(techos > 0, "debe haber techo");
// 30 m a escala 1, 3 m/voxel → ~10 voxels de alto por columna de huella.
assert!(muros > techos * 5, "muros {muros} deberían superar techos {techos}");
}
#[test]
fn agua_se_pinta_en_su_huella() {
let mut m = MapaVoxel::nuevo(caja(), Heightfield::plano(caja(), 5.0), Opciones::default());
m.opts.nivel_mar_m = 5.0;
m.agua.push(cuadrado(0.0045, 0.0045, 0.001));
let g = m.generar_ventana(m.dim_mundo(m.alto_sugerido()), [0, 0]);
assert!(contar_color(&g, m.paleta.agua) > 0, "debe pintarse agua");
}
#[test]
fn calle_se_estampa() {
let mut m = MapaVoxel::nuevo(caja(), Heightfield::plano(caja(), 0.0), Opciones::default());
// Calle horizontal cruzando el centro.
m.calles.push(vec![[0.001, 0.0045], [0.008, 0.0045]]);
let g = m.generar_ventana(m.dim_mundo(m.alto_sugerido()), [0, 0]);
assert!(contar_color(&g, m.paleta.calle) > 0, "debe estamparse calle");
}
#[test]
fn relieve_real_hace_montania() {
// Heightfield 2×2: sur bajo (0 m), norte alto (300 m).
let h = Heightfield::desde_muestras(caja(), 2, 2, vec![300.0, 300.0, 0.0, 0.0]);
let m = MapaVoxel::nuevo(caja(), h, Opciones::default());
let dy = m.alto_sugerido();
// Norte (wz chico) más alto que sur (wz grande).
let t_norte = m.tope(m.proj.ancho as i32 / 2, 2, dy);
let t_sur = m.tope(m.proj.ancho as i32 / 2, m.proj.largo as i32 - 2, dy);
assert!(t_norte > t_sur + 10, "norte {t_norte} debe superar sur {t_sur}");
}
}
@@ -0,0 +1,105 @@
//! Proyección local **equirectangular en metros**: convierte la caja lon/lat del
//! mapa a un plano métrico y de ahí a **coordenadas de mundo voxel** (enteras), y
//! de vuelta. Es el puente entre el espacio geográfico de `nahual-geo-core` y el
//! espacio de la grilla de `llimphi-3d`.
//!
//! No es Web Mercator: para una ciudad (unos pocos km) la equirectangular local
//! centrada en la latitud media es fiel al metro y no deforma las alturas. El eje
//! **+X va al este**, el eje **+Z va al sur** (norte = `wz` chico), como una
//! lámina mirada de arriba.
use nahual_geo_core::BBox;
/// Metros por grado de latitud (constante buena a nivel de ciudad).
const M_POR_GRADO_LAT: f64 = 111_320.0;
/// Proyección de una `BBox` a mundo voxel con un tamaño de voxel dado.
#[derive(Debug, Clone, Copy)]
pub struct Proyeccion {
pub bbox: BBox,
/// Metros que mide el lado de un voxel (resolución horizontal del mundo).
pub metros_por_voxel: f64,
/// Metros por grado de longitud a la latitud media (cos-corregido).
m_por_grado_lon: f64,
/// Ancho/alto del mundo en voxels (derivados de la caja y la resolución).
pub ancho: u32,
pub largo: u32,
}
impl Proyeccion {
/// Arma la proyección de `bbox` con `metros_por_voxel` de resolución.
pub fn new(bbox: BBox, metros_por_voxel: f64) -> Self {
let lat0 = (bbox.min_lat + bbox.max_lat) * 0.5;
let m_por_grado_lon = M_POR_GRADO_LAT * lat0.to_radians().cos().abs().max(1e-6);
let mpv = metros_por_voxel.max(0.05);
let span_x_m = (bbox.max_lon - bbox.min_lon).max(0.0) * m_por_grado_lon;
let span_z_m = (bbox.max_lat - bbox.min_lat).max(0.0) * M_POR_GRADO_LAT;
let ancho = ((span_x_m / mpv).ceil() as u32).max(1);
let largo = ((span_z_m / mpv).ceil() as u32).max(1);
Self { bbox, metros_por_voxel: mpv, m_por_grado_lon, ancho, largo }
}
/// lon/lat → posición **continua** en mundo voxel `(fx, fz)`.
#[inline]
pub fn a_mundo(&self, lon: f64, lat: f64) -> (f64, f64) {
let mx = (lon - self.bbox.min_lon) * self.m_por_grado_lon;
let mz = (self.bbox.max_lat - lat) * M_POR_GRADO_LAT;
(mx / self.metros_por_voxel, mz / self.metros_por_voxel)
}
/// Centro del voxel de mundo `(wx, wz)` → lon/lat.
#[inline]
pub fn a_geo(&self, wx: i32, wz: i32) -> (f64, f64) {
let mx = (wx as f64 + 0.5) * self.metros_por_voxel;
let mz = (wz as f64 + 0.5) * self.metros_por_voxel;
let lon = self.bbox.min_lon + mx / self.m_por_grado_lon;
let lat = self.bbox.max_lat - mz / M_POR_GRADO_LAT;
(lon, lat)
}
/// Metros → cantidad de voxels (para anchos de calle, alturas de edificio…).
#[inline]
pub fn voxels_por_metro(&self) -> f64 {
1.0 / self.metros_por_voxel
}
}
#[cfg(test)]
mod tests {
use super::*;
fn caja() -> BBox {
// ~1 km × 1 km cerca del ecuador para números redondos.
BBox { min_lon: 0.0, min_lat: 0.0, max_lon: 0.008_983, max_lat: 0.008_983 }
}
#[test]
fn ida_y_vuelta_es_estable() {
let p = Proyeccion::new(caja(), 4.0);
for &(lon, lat) in &[(0.001, 0.001), (0.004, 0.006), (0.008, 0.0005)] {
let (fx, fz) = p.a_mundo(lon, lat);
let (lon2, lat2) = p.a_geo(fx as i32, fz as i32);
// Dentro de un voxel (≈4 m ≈ 3.6e-5°).
assert!((lon - lon2).abs() < 5e-5, "lon {lon} vs {lon2}");
assert!((lat - lat2).abs() < 5e-5, "lat {lat} vs {lat2}");
}
}
#[test]
fn el_mundo_mide_lo_que_la_caja_en_metros() {
let p = Proyeccion::new(caja(), 4.0);
// ~1000 m / 4 m por voxel ≈ 250 voxels de lado.
assert!((240..=260).contains(&p.ancho), "ancho {}", p.ancho);
assert!((240..=260).contains(&p.largo), "largo {}", p.largo);
}
#[test]
fn norte_es_z_chico() {
let p = Proyeccion::new(caja(), 4.0);
let (_, z_norte) = p.a_mundo(0.004, 0.008); // lat alta = norte
let (_, z_sur) = p.a_mundo(0.004, 0.001); // lat baja = sur
assert!(z_norte < z_sur, "norte {z_norte} debe ir arriba (z menor) de sur {z_sur}");
}
}
@@ -0,0 +1,21 @@
# nahual-hex-viewer-llimphi
*Read this in English: [README.md](README.md).*
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.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,18 @@
# nahual-hex-viewer-llimphi
Hex/ASCII dump of binaries.
The seventh viewer of the meta-app shell. The binaries `shuma-discern` recognizes
by magic bytes (ELF, wasm, gzip, zip…) used to fall to the text viewer, which only
says "(binary — no preview)". This viewer dumps them as the classic
`offset hex |ascii|`: enough to inspect a header, confirm a magic number or see
the shape of a blob without leaving the shell.
The thin pattern of the other viewers: sync loading in `load_hex`, rendering in
`hex_viewer_view`. It reads only the first few KB (a longer dump is not scanned by
eye). The body is requested in a **monospaced** font (`font_family = "monospace"`)
so the columns line up.
---
Part of **nahual** — see [nahual](../README.md).
@@ -69,7 +69,7 @@ where
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string()),
None => "(seleccioná un binario)".to_string(),
None => "(selecciona un binario)".to_string(),
};
let header_text = match state {
HexPreview::Dump { total, shown, .. } => {
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "nahual-iconos"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-iconos — la personalidad de íconos de nahual: un set completo de íconos vectoriales por tipo de nodo (carpeta/imagen/vídeo/audio/código/documento/comprimido/binario/fuente/enlace…), GENERADO desde la paleta del theme del sistema (rotación HSL del accent) para que recolore vivo al cambiar de theme. Sobre tullpu-icon-core (IconSpec/Forma/Capa). El `trait IconTheme` deja enchufar packs estáticos más adelante sin tocar los consumidores; `Generativo` es el default. Cohesivo con los íconos de Mónadas (mismo lenguaje baldosa+emblema)."
[dependencies]
tullpu-icon-core = { workspace = true }
llimphi-theme = { workspace = true }
+27
View File
@@ -0,0 +1,27 @@
# nahual-iconos
*Read this in English: [README.md](README.md).*
**La personalidad de íconos de nahual.**
Un set completo de íconos vectoriales por tipo de nodo, **generado desde la
paleta del theme del sistema** — no glifos de fuente, no PNGs enlatados. Cada
tipo (carpeta / imagen / vídeo / audio / código / documento / comprimido /
binario / fuente / enlace…) recibe un `IconSpec` de `tullpu-icon-core` con:
- **color** derivado del theme: la carpeta toma el `accent` tal cual; el
resto son tonos canónicos (imagen≈teal, vídeo≈rosa, audio≈violeta,
código≈azul, comprimido≈ámbar…) cuya **saturación y luminancia salen del
accent** y cuyo matiz se **mezcla** un poco hacia él. Cambias el theme y
todo el set recolorea, coherente en claro y oscuro.
- **emblema** blanco/tinta según la naturaleza — mismo lenguaje visual que
los íconos de Mónadas (`nahual-shell::monad_icon`), así la app se ve de una
sola pieza.
El `trait IconTheme` permite enchufar packs estáticos (estilo Papirus) más
adelante sin tocar a los consumidores; `Generativo` es el default y la
respuesta nativa a «¿puedo generar los íconos desde los colores del theme?».
---
Parte de **nahual** — ver [nahual](../LEEME.md).
+24
View File
@@ -0,0 +1,24 @@
# nahual-iconos
**nahual's icon personality.**
A complete set of vector icons per node type, **generated from the system theme's
palette** — not font glyphs, not canned PNGs. Each type (folder / image / video /
audio / code / document / archive / binary / font / link…) receives an `IconSpec`
from `tullpu-icon-core` with:
- a **colour** derived from the theme: the folder takes the `accent` as-is; the
rest are canonical tones (image≈teal, video≈pink, audio≈violet, code≈blue,
archive≈amber…) whose **saturation and luminance come from the accent** and
whose hue is **blended** slightly towards it. Change the theme and the whole set
recolours, coherent in light and dark.
- a white/ink **emblem** according to its nature — the same visual language as the
Monad icons (`nahual-shell::monad_icon`), so the app looks of one piece.
The `IconTheme` trait allows plugging in static packs (Papirus style) later
without touching the consumers; `Generativo` is the default and the native answer
to "can I generate the icons from the theme's colours?".
---
Part of **nahual** — see [nahual](../README.md).
+581
View File
@@ -0,0 +1,581 @@
//! **La personalidad de íconos de nahual.**
//!
//! Un set completo de íconos vectoriales por tipo de nodo, **generado desde la
//! paleta del theme del sistema** — no glifos de fuente, no PNGs enlatados. Cada
//! tipo (carpeta / imagen / vídeo / audio / código / documento / comprimido /
//! binario / fuente / enlace…) recibe un [`IconSpec`] de `tullpu-icon-core` con:
//!
//! - **color** derivado del theme: la carpeta toma el `accent` tal cual; el
//! resto son tonos canónicos (imagen≈teal, vídeo≈rosa, audio≈violeta,
//! código≈azul, comprimido≈ámbar…) cuya **saturación y luminancia salen del
//! accent** y cuyo matiz se **mezcla** un poco hacia él. Cambias el theme y
//! todo el set recolorea, coherente en claro y oscuro.
//! - **emblema** blanco/tinta según la naturaleza — mismo lenguaje visual que
//! los íconos de Mónadas (`nahual-shell::monad_icon`), así la app se ve de una
//! sola pieza.
//!
//! El [`trait IconTheme`] permite enchufar packs estáticos (estilo Papirus) más
//! adelante sin tocar a los consumidores; [`Generativo`] es el default y la
//! respuesta nativa a «¿puedo generar los íconos desde los colores del theme?».
use llimphi_theme::Theme;
use tullpu_icon_core::{Capa, Color, Forma, IconSpec};
/// Lado de la grilla de diseño (coincide con `llimphi-icons` y `tullpu-icon`).
pub const GRILLA: f32 = 24.0;
// =============================================================================
// Vocabulario: qué naturaleza tiene un nodo, y en qué «lugar» estamos
// =============================================================================
/// Carpeta especial reconocida por su ruta — cambia el emblema (y podría el
/// color). `Generica` es cualquier otra carpeta.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Lugar {
Home,
Descargas,
Documentos,
Imagenes,
Musica,
Videos,
Escritorio,
Raiz,
Git,
Generica,
}
/// Naturaleza de un nodo a efectos de iconografía. La discierne el consumidor
/// (el shell ya tiene `es_imagen/es_video/es_audio` + extensión + `NodeKind`).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TipoNodo {
Carpeta(Lugar),
Imagen,
Video,
Audio,
Codigo,
Documento,
Markdown,
Tabla,
Comprimido,
Binario,
Fuente,
Texto,
Enlace,
Generico,
}
/// Emblema superpuesto en la esquina inferior derecha (estado, no naturaleza):
/// enlace simbólico, dispositivo montado, elemento marcado, repo git.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Emblema {
Symlink,
Montada,
Marcada,
Git,
}
// =============================================================================
// IconTheme — el punto de extensión (generativo hoy, packs mañana)
// =============================================================================
/// Fuente de íconos por tipo. `Generativo` es el default; un pack estático
/// implementaría este mismo trait leyendo `.icon`/SVG de disco.
pub trait IconTheme {
/// `IconSpec` para un tipo. `semilla` (id/ruta) permite variación estable
/// donde tenga sentido (hoy sólo la usan las Mónadas, fuera de este crate).
fn spec(&self, tipo: TipoNodo, semilla: &str) -> IconSpec;
/// Igual, con un emblema de estado encima. Default: compone el emblema
/// sobre `spec(tipo)`.
fn spec_con_emblema(&self, tipo: TipoNodo, semilla: &str, emblema: Emblema) -> IconSpec {
let mut base = self.spec(tipo, semilla);
base.capas.extend(emblema_capas(emblema));
base
}
}
// =============================================================================
// Paleta derivada del theme (rotación HSL del accent)
// =============================================================================
/// Colores del set, derivados de un [`Theme`]. Guarda los parámetros HSL del
/// accent y sintetiza cada tono canónico bajo la misma saturación/luminancia,
/// para que el set entero «siga» al theme (claro/oscuro, vibrante/sobrio).
#[derive(Clone, Copy, Debug)]
pub struct PaletaIconos {
accent: [u8; 4],
neutro: [u8; 4],
ah: f32, // hue del accent (grados)
s: f32, // saturación objetivo
l: f32, // luminancia objetivo
}
impl PaletaIconos {
/// Deriva la paleta de íconos de un theme. La carpeta usa el `accent`; el
/// resto sale de rotar/mezclar hacia el hue del accent con S/L tomadas de él
/// y ajustadas a la claridad del fondo.
pub fn from_theme(t: &Theme) -> Self {
let accent = rgba8(t.accent);
let neutro = rgba8(t.fg_muted);
let (ah, a_s, _al) = rgb_a_hsl(accent);
// Fondo claro u oscuro → distinta luminancia objetivo de los íconos.
let (bl, _, _) = luminancia(rgba8(t.bg_app));
let oscuro = bl < 0.5;
// Saturación viva pero acotada; luminancia legible sobre el fondo.
let s = a_s.clamp(0.45, 0.82);
let l = if oscuro { 0.60 } else { 0.50 };
Self { accent, neutro, ah, s, l }
}
/// Tono para un hue canónico: mezcla un 22 % hacia el hue del accent y usa
/// la S/L de la paleta. Así «imagen» tiende a teal pero teñido por el theme.
fn tono(&self, hue_canonico: f32) -> [u8; 4] {
let h = mezclar_hue(hue_canonico, self.ah, 0.22);
hsl_a_rgba(h, self.s, self.l)
}
/// Color base de la baldosa/silueta de un tipo.
fn color(&self, tipo: TipoNodo) -> [u8; 4] {
match tipo {
TipoNodo::Carpeta(_) => self.accent,
TipoNodo::Imagen => self.tono(175.0),
TipoNodo::Video => self.tono(330.0),
TipoNodo::Audio => self.tono(275.0),
TipoNodo::Codigo => self.tono(215.0),
TipoNodo::Documento => self.tono(145.0),
TipoNodo::Markdown => self.tono(200.0),
TipoNodo::Tabla => self.tono(160.0),
TipoNodo::Comprimido => self.tono(40.0),
TipoNodo::Binario => self.tono(12.0),
TipoNodo::Fuente => self.tono(300.0),
TipoNodo::Enlace => self.tono(230.0),
TipoNodo::Texto | TipoNodo::Generico => self.neutro,
}
}
}
// =============================================================================
// Generativo — el IconTheme por defecto
// =============================================================================
/// Set de íconos generado desde la paleta del theme.
#[derive(Clone, Copy, Debug)]
pub struct Generativo {
pal: PaletaIconos,
}
impl Generativo {
pub fn from_theme(t: &Theme) -> Self {
Self { pal: PaletaIconos::from_theme(t) }
}
pub fn con_paleta(pal: PaletaIconos) -> Self {
Self { pal }
}
/// Acceso al color base de un tipo (para tintar chips/labels afines).
pub fn color_de(&self, tipo: TipoNodo) -> [u8; 4] {
self.pal.color(tipo)
}
}
impl IconTheme for Generativo {
fn spec(&self, tipo: TipoNodo, semilla: &str) -> IconSpec {
let color = self.pal.color(tipo);
let capas = match tipo {
TipoNodo::Carpeta(lugar) => carpeta_capas(color, lugar),
otro => baldosa_emblema(color, otro),
};
IconSpec::nuevo(semilla, capas)
}
}
// =============================================================================
// Dibujo — carpetas
// =============================================================================
fn folder_silueta(color: [u8; 4]) -> Vec<Capa> {
// Pestaña + cuerpo con un realce superior sutil (profundidad sin ruido).
let cuerpo = Color::Rgba(color);
let realce = Color::Rgba(aclarar(color, 0.20));
vec![
// Pestaña asomando arriba-izquierda.
Capa::rellena(
Forma::RectRedondeado { x: 3.0, y: 4.0, w: 8.5, h: 4.5, r: 1.6 },
cuerpo.clone(),
),
// Cuerpo.
Capa::rellena(
Forma::RectRedondeado { x: 3.0, y: 6.5, w: 18.0, h: 13.0, r: 2.6 },
cuerpo,
),
// Realce del borde superior del cuerpo (la «tapa»).
Capa::rellena(
Forma::RectRedondeado { x: 3.0, y: 6.5, w: 18.0, h: 4.2, r: 2.6 },
realce,
),
]
}
fn carpeta_capas(color: [u8; 4], lugar: Lugar) -> Vec<Capa> {
let mut capas = folder_silueta(color);
let e = emblema_color(color);
// Emblema del «lugar», centrado en el cuerpo de la carpeta (~y 13).
match lugar {
Lugar::Home => {
// Techo + jamba.
capas.push(Capa::trazada(Forma::polilinea(&[(8.5, 14.0), (12.0, 11.0), (15.5, 14.0)]), e.clone(), 1.6));
capas.push(Capa::trazada(Forma::polilinea(&[(9.5, 13.2), (9.5, 16.5), (14.5, 16.5), (14.5, 13.2)]), e, 1.5));
}
Lugar::Descargas => {
capas.push(Capa::trazada(Forma::polilinea(&[(12.0, 10.5), (12.0, 15.5)]), e.clone(), 1.7));
capas.push(Capa::trazada(Forma::polilinea(&[(9.3, 13.0), (12.0, 15.8), (14.7, 13.0)]), e.clone(), 1.7));
capas.push(Capa::trazada(Forma::polilinea(&[(9.0, 17.0), (15.0, 17.0)]), e, 1.6));
}
Lugar::Documentos => {
capas.push(Capa::trazada(
Forma::RectRedondeado { x: 9.0, y: 10.5, w: 6.0, h: 6.8, r: 1.0 },
e.clone(),
1.4,
));
for y in [12.5_f32, 14.0, 15.5] {
capas.push(Capa::trazada(Forma::polilinea(&[(10.3, y), (13.7, y)]), e.clone(), 1.1));
}
}
Lugar::Imagenes => {
capas.push(Capa::rellena(Forma::Circulo { cx: 10.0, cy: 12.6, r: 1.4 }, e.clone()));
capas.push(Capa::rellena(Forma::PoligonoRegular { cx: 13.5, cy: 16.0, r: 3.4, lados: 3 }, e));
}
Lugar::Musica => {
capas.push(Capa::rellena(Forma::Circulo { cx: 10.2, cy: 16.4, r: 1.7 }, e.clone()));
capas.push(Capa::trazada(Forma::polilinea(&[(11.9, 16.4), (11.9, 11.2), (15.2, 12.2)]), e, 1.5));
}
Lugar::Videos => {
capas.push(Capa::rellena(Forma::PoligonoRegular { cx: 12.6, cy: 14.5, r: 3.6, lados: 3 }, e));
}
Lugar::Escritorio => {
capas.push(Capa::trazada(
Forma::RectRedondeado { x: 8.5, y: 11.0, w: 7.0, h: 5.0, r: 0.8 },
e.clone(),
1.4,
));
capas.push(Capa::trazada(Forma::polilinea(&[(11.0, 17.2), (13.0, 17.2)]), e, 1.4));
}
Lugar::Raiz => {
capas.push(Capa::trazada(Forma::Circulo { cx: 12.0, cy: 14.0, r: 3.2 }, e.clone(), 1.5));
capas.push(Capa::trazada(Forma::polilinea(&[(12.0, 10.8), (12.0, 17.2)]), e, 1.3));
}
Lugar::Git => {
// Rombo + tres nodos (un grafo mínimo).
capas.push(Capa::trazada(Forma::polilinea(&[(12.0, 10.8), (12.0, 16.6)]), e.clone(), 1.4));
capas.push(Capa::trazada(Forma::polilinea(&[(12.0, 13.0), (15.0, 15.0)]), e.clone(), 1.4));
for (cx, cy) in [(12.0, 10.4), (12.0, 17.0), (15.4, 15.2)] {
capas.push(Capa::rellena(Forma::Circulo { cx, cy, r: 1.5 }, e.clone()));
}
}
Lugar::Generica => {}
}
capas
}
// =============================================================================
// Dibujo — archivos (baldosa redondeada + emblema por naturaleza)
// =============================================================================
fn baldosa_emblema(color: [u8; 4], tipo: TipoNodo) -> Vec<Capa> {
let base = Color::Rgba(color);
let e = emblema_color(color);
let mut capas = vec![Capa::rellena(
Forma::RectRedondeado { x: 3.0, y: 2.0, w: 18.0, h: 20.0, r: 5.0 },
base,
)];
match tipo {
TipoNodo::Imagen => {
capas.push(Capa::rellena(Forma::Circulo { cx: 9.0, cy: 9.5, r: 2.0 }, e.clone()));
capas.push(Capa::rellena(Forma::PoligonoRegular { cx: 13.5, cy: 15.0, r: 5.0, lados: 3 }, e));
}
TipoNodo::Video => {
capas.push(Capa::rellena(Forma::PoligonoRegular { cx: 13.2, cy: 12.0, r: 5.2, lados: 3 }, e));
}
TipoNodo::Audio => {
capas.push(Capa::rellena(Forma::Circulo { cx: 9.5, cy: 16.0, r: 2.2 }, e.clone()));
capas.push(Capa::trazada(Forma::polilinea(&[(11.7, 16.0), (11.7, 7.5), (16.5, 9.0)]), e.clone(), 1.7));
capas.push(Capa::rellena(Forma::Circulo { cx: 14.6, cy: 14.4, r: 2.2 }, e));
}
TipoNodo::Codigo => {
capas.push(Capa::trazada(Forma::polilinea(&[(11.0, 8.0), (7.0, 12.0), (11.0, 16.0)]), e.clone(), 2.0));
capas.push(Capa::trazada(Forma::polilinea(&[(13.0, 8.0), (17.0, 12.0), (13.0, 16.0)]), e, 2.0));
}
TipoNodo::Markdown => {
// «M» + flecha hacia abajo (el logo canónico de Markdown, sobrio).
capas.push(Capa::trazada(Forma::polilinea(&[(6.5, 16.0), (6.5, 8.0), (9.5, 12.0), (12.5, 8.0), (12.5, 16.0)]), e.clone(), 1.5));
capas.push(Capa::trazada(Forma::polilinea(&[(15.5, 8.5), (15.5, 15.5)]), e.clone(), 1.5));
capas.push(Capa::trazada(Forma::polilinea(&[(13.5, 13.2), (15.5, 15.8), (17.5, 13.2)]), e, 1.5));
}
TipoNodo::Tabla => {
capas.push(Capa::trazada(
Forma::RectRedondeado { x: 6.0, y: 6.5, w: 12.0, h: 11.0, r: 1.2 },
e.clone(),
1.4,
));
capas.push(Capa::trazada(Forma::polilinea(&[(6.0, 10.2), (18.0, 10.2)]), e.clone(), 1.2));
capas.push(Capa::trazada(Forma::polilinea(&[(6.0, 13.8), (18.0, 13.8)]), e.clone(), 1.2));
capas.push(Capa::trazada(Forma::polilinea(&[(12.0, 6.5), (12.0, 17.5)]), e, 1.2));
}
TipoNodo::Comprimido => {
// Cremallera: eje central + dientes alternados.
capas.push(Capa::trazada(Forma::polilinea(&[(12.0, 5.0), (12.0, 19.0)]), e.clone(), 1.6));
for y in [7.0_f32, 9.5, 12.0, 14.5, 17.0] {
let (x1, x2) = if (y as i32) % 5 == 2 { (10.0, 12.0) } else { (12.0, 14.0) };
capas.push(Capa::trazada(Forma::polilinea(&[(x1, y), (x2, y)]), e.clone(), 1.4));
}
}
TipoNodo::Binario => {
// Hexágono (un «chip») + dos bits.
capas.push(Capa::trazada(Forma::PoligonoRegular { cx: 12.0, cy: 12.0, r: 6.0, lados: 6 }, e.clone(), 1.5));
capas.push(Capa::trazada(Forma::polilinea(&[(11.0, 10.0), (11.0, 14.0)]), e.clone(), 1.4));
capas.push(Capa::trazada(Forma::Circulo { cx: 13.6, cy: 12.0, r: 1.4 }, e, 1.4));
}
TipoNodo::Fuente => {
// Una «A» serif.
capas.push(Capa::trazada(Forma::polilinea(&[(7.5, 17.0), (12.0, 6.5), (16.5, 17.0)]), e.clone(), 1.7));
capas.push(Capa::trazada(Forma::polilinea(&[(9.5, 13.0), (14.5, 13.0)]), e, 1.5));
}
TipoNodo::Enlace => {
// Cadena (dos eslabones).
capas.push(Capa::trazada(
Forma::RectRedondeado { x: 6.5, y: 10.0, w: 6.5, h: 4.0, r: 2.0 },
e.clone(),
1.6,
));
capas.push(Capa::trazada(
Forma::RectRedondeado { x: 11.0, y: 10.0, w: 6.5, h: 4.0, r: 2.0 },
e,
1.6,
));
}
// Texto / Documento / Genérico y cualquier otro: página con renglones.
_ => {
capas.push(Capa::trazada(
Forma::RectRedondeado { x: 7.0, y: 6.0, w: 10.0, h: 12.0, r: 1.4 },
e.clone(),
1.5,
));
for y in [9.5_f32, 12.0, 14.5] {
capas.push(Capa::trazada(Forma::polilinea(&[(9.0, y), (15.0, y)]), e.clone(), 1.2));
}
}
}
capas
}
// =============================================================================
// Dibujo — emblemas de estado (esquina inferior derecha, ~6×6)
// =============================================================================
fn emblema_capas(emblema: Emblema) -> Vec<Capa> {
// Disco blanco de fondo para que el emblema lea sobre cualquier ícono.
let disco = Color::Rgba([255, 255, 255, 245]);
let tinta = Color::Rgba([40, 44, 52, 255]);
let mut capas = vec![Capa::rellena(Forma::Circulo { cx: 18.0, cy: 18.0, r: 5.0 }, disco)];
match emblema {
Emblema::Symlink => {
capas.push(Capa::trazada(Forma::polilinea(&[(20.0, 16.0), (16.0, 20.0)]), tinta.clone(), 1.4));
capas.push(Capa::trazada(
Forma::RectRedondeado { x: 18.5, y: 15.0, w: 3.2, h: 2.2, r: 1.1 },
tinta.clone(),
1.2,
));
capas.push(Capa::trazada(
Forma::RectRedondeado { x: 14.3, y: 18.8, w: 3.2, h: 2.2, r: 1.1 },
tinta,
1.2,
));
}
Emblema::Montada => {
capas.push(Capa::trazada(Forma::polilinea(&[(15.6, 18.0), (17.4, 20.0), (20.6, 15.8)]), tinta, 1.5));
}
Emblema::Marcada => {
capas.push(Capa::rellena(Forma::Estrella { cx: 18.0, cy: 18.0, r_ext: 3.2, r_int: 1.4, puntas: 5 }, tinta));
}
Emblema::Git => {
capas.push(Capa::trazada(Forma::polilinea(&[(18.0, 15.5), (18.0, 20.5)]), tinta.clone(), 1.3));
capas.push(Capa::trazada(Forma::polilinea(&[(18.0, 17.4), (20.2, 18.8)]), tinta.clone(), 1.3));
capas.push(Capa::rellena(Forma::Circulo { cx: 20.6, cy: 19.0, r: 1.2 }, tinta));
}
}
capas
}
/// Color de emblema legible sobre `fondo`: blanco si el fondo es oscuro, tinta
/// oscura si es claro.
fn emblema_color(fondo: [u8; 4]) -> Color {
let (l, _, _) = luminancia(fondo);
if l < 0.62 {
Color::Rgba([255, 255, 255, 240])
} else {
Color::Rgba([34, 38, 46, 255])
}
}
// =============================================================================
// Color: conversión peniko → rgba8, HSL, mezcla de hue, luminancia
// =============================================================================
fn rgba8(c: llimphi_theme::Color) -> [u8; 4] {
let [r, g, b, a] = c.components;
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
[q(r), q(g), q(b), q(a)]
}
/// Luminancia relativa aproximada (0..1) + max/min de canales normalizados.
fn luminancia(c: [u8; 4]) -> (f32, f32, f32) {
let r = c[0] as f32 / 255.0;
let g = c[1] as f32 / 255.0;
let b = c[2] as f32 / 255.0;
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
(l, max, min)
}
/// RGBA8 → (hue°, s, l). Alpha se descarta.
fn rgb_a_hsl(c: [u8; 4]) -> (f32, f32, f32) {
let r = c[0] as f32 / 255.0;
let g = c[1] as f32 / 255.0;
let b = c[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let d = max - min;
if d < 1e-6 {
return (0.0, 0.0, l);
}
let s = d / (1.0 - (2.0 * l - 1.0).abs());
let h = if max == r {
60.0 * (((g - b) / d).rem_euclid(6.0))
} else if max == g {
60.0 * (((b - r) / d) + 2.0)
} else {
60.0 * (((r - g) / d) + 4.0)
};
(h.rem_euclid(360.0), s.clamp(0.0, 1.0), l)
}
/// (hue°, s, l) → RGBA8 (alpha 255).
fn hsl_a_rgba(h: f32, s: f32, l: f32) -> [u8; 4] {
let h = h.rem_euclid(360.0);
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - ((h / 60.0).rem_euclid(2.0) - 1.0).abs());
let m = l - c / 2.0;
let (r, g, b) = match (h / 60.0) as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let q = |v: f32| ((v + m).clamp(0.0, 1.0) * 255.0).round() as u8;
[q(r), q(g), q(b), 255]
}
/// Mezcla dos hues sobre el círculo (interpola vectores unitarios). `t` = peso
/// del segundo hue (0 = sólo `a`, 1 = sólo `b`).
fn mezclar_hue(a: f32, b: f32, t: f32) -> f32 {
let (ar, br) = (a.to_radians(), b.to_radians());
let x = ar.cos() * (1.0 - t) + br.cos() * t;
let y = ar.sin() * (1.0 - t) + br.sin() * t;
y.atan2(x).to_degrees().rem_euclid(360.0)
}
/// Aclara un color mezclándolo hacia blanco por `f` (0..1).
fn aclarar(c: [u8; 4], f: f32) -> [u8; 4] {
let mix = |v: u8| (v as f32 + (255.0 - v as f32) * f).round() as u8;
[mix(c[0]), mix(c[1]), mix(c[2]), c[3]]
}
#[cfg(test)]
mod tests {
use super::*;
use tullpu_icon_core::ColorFijo;
fn gen() -> Generativo {
Generativo::from_theme(&Theme::dark())
}
const TODOS: &[TipoNodo] = &[
TipoNodo::Carpeta(Lugar::Generica),
TipoNodo::Carpeta(Lugar::Home),
TipoNodo::Carpeta(Lugar::Descargas),
TipoNodo::Carpeta(Lugar::Documentos),
TipoNodo::Carpeta(Lugar::Imagenes),
TipoNodo::Carpeta(Lugar::Musica),
TipoNodo::Carpeta(Lugar::Videos),
TipoNodo::Carpeta(Lugar::Escritorio),
TipoNodo::Carpeta(Lugar::Raiz),
TipoNodo::Carpeta(Lugar::Git),
TipoNodo::Imagen,
TipoNodo::Video,
TipoNodo::Audio,
TipoNodo::Codigo,
TipoNodo::Documento,
TipoNodo::Markdown,
TipoNodo::Tabla,
TipoNodo::Comprimido,
TipoNodo::Binario,
TipoNodo::Fuente,
TipoNodo::Texto,
TipoNodo::Enlace,
TipoNodo::Generico,
];
#[test]
fn cada_tipo_compila_no_vacio() {
let g = gen();
let r = ColorFijo::nuevo([255, 255, 255, 255]);
for &t in TODOS {
let spec = g.spec(t, "sem");
assert!(spec.capas.len() >= 1, "{t:?} sin capas");
let pvs = spec.compilar(&r);
assert!(!pvs.is_empty(), "{t:?} compila vacío");
for pv in pvs {
assert!(!pv.comandos.is_empty(), "{t:?} capa sin comandos");
}
}
}
#[test]
fn emblema_agrega_capas() {
let g = gen();
let base = g.spec(TipoNodo::Texto, "s").capas.len();
let con = g.spec_con_emblema(TipoNodo::Texto, "s", Emblema::Symlink).capas.len();
assert!(con > base, "el emblema debería sumar capas");
}
#[test]
fn theme_distinto_cambia_paleta() {
let d = PaletaIconos::from_theme(&Theme::dark());
let l = PaletaIconos::from_theme(&Theme::light());
// El accent difiere entre themes → al menos la carpeta cambia de color.
assert_ne!(d.color(TipoNodo::Carpeta(Lugar::Generica)), l.color(TipoNodo::Carpeta(Lugar::Generica)));
}
#[test]
fn carpeta_usa_accent_exacto() {
let t = Theme::dark();
let p = PaletaIconos::from_theme(&t);
assert_eq!(p.color(TipoNodo::Carpeta(Lugar::Home)), rgba8(t.accent));
}
#[test]
fn hsl_ida_y_vuelta() {
for c in [[200u8, 90, 40, 255], [40, 130, 200, 255], [120, 200, 90, 255]] {
let (h, s, l) = rgb_a_hsl(c);
let c2 = hsl_a_rgba(h, s, l);
for i in 0..3 {
assert!((c[i] as i32 - c2[i] as i32).abs() <= 2, "canal {i}: {c:?} vs {c2:?}");
}
}
}
}
@@ -11,7 +11,11 @@ description = "nahual-image-viewer-llimphi — visor de imágenes (PNG/JPEG) sob
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
image = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-widget-empty = { workspace = true }
llimphi-image = { workspace = true }
foreign-psd = { workspace = true }
foreign-jxl = { workspace = true }
rimay-localize = { workspace = true }
[[example]]
@@ -7,10 +7,12 @@
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::llimphi_raster::peniko::{
Blob, ImageAlphaType, ImageBrush as Image, ImageData, ImageFormat,
};
use llimphi_ui::{App, Handle, View};
use nahual_image_viewer_llimphi::{
image_viewer_view, load_image, ImagePreviewState, ImageViewerPalette,
image_viewer_view_zoom, load_image, ImagePreviewState, ImageViewerPalette, ImageViewport,
DEFAULT_IMAGE_BYTES_MAX,
};
@@ -20,10 +22,15 @@ const PROC_H: u32 = 320;
struct Model {
state: ImagePreviewState,
path: Option<PathBuf>,
viewport: ImageViewport,
}
#[derive(Clone)]
enum Msg {}
enum Msg {
Zoom { factor: f32 },
Pan { dx: f32, dy: f32 },
Reset,
}
struct Showcase;
@@ -41,25 +48,37 @@ impl App for Showcase {
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,
},
let (state, path) = match arg {
Some(p) => (load_image(&p, DEFAULT_IMAGE_BYTES_MAX), Some(p)),
None => (procedural_state(), None),
};
Model {
state,
path,
viewport: ImageViewport::default(),
}
}
fn update(model: Model, _: Msg, _: &Handle<Msg>) -> Model {
fn update(mut model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
match msg {
Msg::Zoom { factor } => model.viewport.zoom_by(factor),
Msg::Pan { dx, dy } => model.viewport.pan_by(dx, dy),
Msg::Reset => model.viewport.reset(),
}
model
}
fn view(model: &Model) -> View<Msg> {
let palette = ImageViewerPalette::default();
let viewer = image_viewer_view::<Msg>(&model.state, model.path.as_deref(), &palette);
let viewer = image_viewer_view_zoom::<Msg, _, _>(
&model.state,
model.path.as_deref(),
&palette,
model.viewport,
|factor, _fx, _fy| Msg::Zoom { factor },
|dx, dy| Msg::Pan { dx, dy },
Msg::Reset,
);
View::new(Style {
size: Size {
width: percent(1.0_f32),
@@ -88,7 +107,13 @@ fn procedural_state() -> ImagePreviewState {
}
}
let blob = Blob::from(pixels);
let image = Image::new(blob, ImageFormat::Rgba8, PROC_W, PROC_H);
let image = Image::new(ImageData {
data: blob,
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: PROC_W,
height: PROC_H,
});
ImagePreviewState::Image {
image,
width: PROC_W,
@@ -1,30 +1,32 @@
//! `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`].
//! lógica de carga vive en [`load_image`] (size cap + decode → Rgba8 vía
//! `llimphi-image`), 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.
//! Formatos soportados: PNG, JPEG y WEBP (los que active el workspace
//! del crate `image` upstream — ver `llimphi-image`).
#![forbid(unsafe_code)]
use std::fs;
use std::path::Path;
use image::ImageReader;
use llimphi_image::{from_rgba8, load_path, DecodeError, Image};
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_raster::kurbo::{Affine, Rect as KurboRect};
use llimphi_ui::llimphi_raster::peniko::{BlendMode, Color, Fill};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_ui::{DragPhase, GesturePhase, View};
use llimphi_icons::Icon;
use llimphi_theme::{alpha, motion};
use llimphi_widget_empty::{empty_view, EmptyPalette};
/// 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
@@ -52,38 +54,76 @@ impl Default for ImagePreviewState {
}
}
/// Lee, decodifica y arma el `peniko::Image`. Sync.
/// Lee, decodifica y arma el `peniko::Image`. Sync. Delega en
/// [`llimphi_image::load_path`] — el cap de tamaño aplica al archivo en
/// disco (no a la imagen decodificada en RGBA8, que puede ser mucho
/// mayor: un PNG 4K son ~64 MB descomprimidos).
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()),
match load_path(path, max_bytes) {
Ok(image) => {
let (width, height) = (image.image.width, image.image.height);
ImagePreviewState::Image { image, width, height }
}
Err(DecodeError::TooBig { size_bytes, .. }) => ImagePreviewState::TooBig(size_bytes),
Err(DecodeError::UnsupportedFormat) => {
ImagePreviewState::Unsupported(rimay_localize::t("nahual-image-unsupported"))
}
Err(DecodeError::Io(e)) => ImagePreviewState::Error(e.to_string()),
Err(DecodeError::Decode(s)) => ImagePreviewState::Error(s),
}
}
/// Tope de bytes para un `.psd` (64 MiB). Los PSD suelen ser grandes; el cap
/// aplica al archivo en disco, no al RGBA aplanado (que puede ser mayor).
pub const DEFAULT_PSD_BYTES_MAX: u64 = 64 * 1024 * 1024;
/// Carga un `.psd` (Adobe Photoshop) como imagen: lo aplana al composite RGBA8
/// con `foreign-psd` (el mismo puente que importa PSD a tullpu) y arma el
/// `peniko::Image`, reutilizando este visor ráster. El decoder genérico
/// (`image`) no abre PSD; por eso pasa por el puente. Sync.
pub fn load_psd(path: &Path, max_bytes: u64) -> ImagePreviewState {
match std::fs::metadata(path) {
Ok(m) if m.len() > max_bytes => return ImagePreviewState::TooBig(m.len()),
Err(e) => return ImagePreviewState::Error(e.to_string()),
_ => {}
}
let reader = match ImageReader::open(path) {
Ok(r) => r,
let bytes = match std::fs::read(path) {
Ok(b) => b,
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"));
match foreign_psd::composite_rgba(&bytes) {
Ok((width, height, rgba)) => {
let image = from_rgba8(rgba, width, height);
ImagePreviewState::Image { image, width, height }
}
Err(e) => ImagePreviewState::Unsupported(format!("PSD: {e}")),
}
let img = match reader.decode() {
Ok(i) => i,
}
/// Tope de bytes para un `.jxl` (64 MiB). JPEG XL comprime muy denso; el cap
/// aplica al archivo en disco, no al RGBA aplanado (que puede ser mucho mayor).
pub const DEFAULT_JXL_BYTES_MAX: u64 = 64 * 1024 * 1024;
/// Carga un `.jxl` (JPEG XL) como imagen: decodifica el primer frame a RGBA8
/// con `foreign-jxl` (decoder puro-Rust `jxl-oxide`) y arma el `peniko::Image`,
/// reutilizando este visor ráster igual que `load_psd`. El decoder genérico
/// (`image`) no abre JXL; por eso pasa por el puente. Sync.
pub fn load_jxl(path: &Path, max_bytes: u64) -> ImagePreviewState {
match std::fs::metadata(path) {
Ok(m) if m.len() > max_bytes => return ImagePreviewState::TooBig(m.len()),
Err(e) => return ImagePreviewState::Error(e.to_string()),
_ => {}
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
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,
match foreign_jxl::decode_rgba(&bytes) {
Ok((width, height, rgba)) => {
let image = from_rgba8(rgba, width, height);
ImagePreviewState::Image { image, width, height }
}
Err(e) => ImagePreviewState::Unsupported(format!("JXL: {e}")),
}
}
@@ -111,6 +151,60 @@ impl ImageViewerPalette {
}
}
/// Estado de zoom/pan del viewer, **propiedad del caller** (Regla 2: el
/// widget es stateless; el foco vive en el modelo de la app).
///
/// - `zoom = 1.0` es el aspect-fit base centrado; valores mayores agrandan.
/// - `pan` es el desplazamiento en px de pantalla desde el centrado base.
///
/// v1: el zoom es **hacia el centro** del viewport (más el pan actual), no
/// hacia el cursor — el `update` de Elm no conoce el rect del nodo, así que el
/// punto focal del gesto se difiere. El [`on_scale`](View::on_scale) igualmente
/// entrega el focal por si una versión futura lo aprovecha.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ImageViewport {
pub zoom: f32,
pub pan: (f32, f32),
}
impl Default for ImageViewport {
fn default() -> Self {
Self {
zoom: 1.0,
pan: (0.0, 0.0),
}
}
}
impl ImageViewport {
/// No achicamos por debajo del aspect-fit (el fit ya entra entero).
pub const MIN_ZOOM: f32 = 1.0;
pub const MAX_ZOOM: f32 = 16.0;
/// Vuelve al aspect-fit centrado.
pub fn reset(&mut self) {
*self = Self::default();
}
/// Aplica el `factor` multiplicativo incremental de un gesto `on_scale`,
/// clampeado al rango. Al volver al fit recentra (pan = 0).
pub fn zoom_by(&mut self, factor: f32) {
self.zoom = (self.zoom * factor).clamp(Self::MIN_ZOOM, Self::MAX_ZOOM);
if self.zoom <= Self::MIN_ZOOM {
self.pan = (0.0, 0.0);
}
}
/// Desplaza el pan en px de pantalla (delta de un arrastre). No-op sin zoom.
pub fn pan_by(&mut self, dx: f32, dy: f32) {
if self.zoom <= Self::MIN_ZOOM {
return;
}
self.pan.0 += dx;
self.pan.1 += dy;
}
}
/// Pinta header (nombre + dimensiones si las hay) + body con la
/// imagen aspect-fit o un placeholder de estado.
pub fn image_viewer_view<Msg>(
@@ -118,20 +212,85 @@ pub fn image_viewer_view<Msg>(
path: Option<&Path>,
palette: &ImageViewerPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let body = match state {
ImagePreviewState::Empty => empty_body(palette),
ImagePreviewState::Image { image, .. } => {
// Pop-in suave al cargar: la `key` (hash del path) es estable
// mientras se mire la misma imagen, así el fade corre una sola vez.
image_body(image.clone()).animated_enter(key_of(path), motion::NORMAL)
}
ImagePreviewState::TooBig(n) => placeholder_body(
&rimay_localize::t_args("nahual-image-toobig", &[("bytes", n.to_string().into())]),
palette.fg_muted,
),
ImagePreviewState::Unsupported(s) => placeholder_body(s, palette.fg_muted),
ImagePreviewState::Error(e) => {
placeholder_body(&rimay_localize::t_args("nahual-image-error", &[("err", e.to_string().into())]), palette.fg_error)
}
};
outer(header_view(state, path, palette), body, palette)
}
/// Como [`image_viewer_view`] pero **interactivo**: la imagen se pinta con el
/// `viewport` (zoom/pan) vía `paint_with` y declara los gestos de Llimphi —
/// `on_scale` (Ctrl+rueda en desktop / pinch en trackpad), arrastre para hacer
/// pan y doble-tap para resetear. El estado lo posee el caller ([`ImageViewport`]):
/// en el `update`, `on_zoom(factor, _, _)` → [`ImageViewport::zoom_by`] y
/// `on_pan(dx, dy)` → [`ImageViewport::pan_by`]; el doble-tap manda `on_reset`.
pub fn image_viewer_view_zoom<Msg, FZoom, FPan>(
state: &ImagePreviewState,
path: Option<&Path>,
palette: &ImageViewerPalette,
viewport: ImageViewport,
on_zoom: FZoom,
on_pan: FPan,
on_reset: Msg,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FZoom: Fn(f32, f32, f32) -> Msg + Send + Sync + 'static,
FPan: Fn(f32, f32) -> Msg + Send + Sync + 'static,
{
let body = match state {
ImagePreviewState::Image { image, .. } => {
zoom_body(image.clone(), viewport, on_zoom, on_pan, on_reset)
.animated_enter(key_of(path), motion::NORMAL)
}
ImagePreviewState::Empty => empty_body(palette),
ImagePreviewState::TooBig(n) => placeholder_body(
&rimay_localize::t_args("nahual-image-toobig", &[("bytes", n.to_string().into())]),
palette.fg_muted,
),
ImagePreviewState::Unsupported(s) => placeholder_body(s, palette.fg_muted),
ImagePreviewState::Error(e) => {
placeholder_body(&rimay_localize::t_args("nahual-image-error", &[("err", e.to_string().into())]), palette.fg_error)
}
};
outer(header_view(state, path, palette), body, palette)
}
/// Header común (nombre + dimensiones).
fn header_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());
.unwrap_or_else(|| rimay_localize::t("nahual-image-select"));
let header_text = match state {
ImagePreviewState::Image { width, height, .. } => {
format!("{name} · {width}×{height}")
}
_ => name,
};
let header = View::new(Style {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
@@ -145,21 +304,14 @@ where
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)
}
};
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start)
}
/// Contenedor columna (header + body) con fondo y clip.
fn outer<Msg>(header: View<Msg>, body: View<Msg>, palette: &ImageViewerPalette) -> View<Msg>
where
Msg: Clone + 'static,
{
View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
@@ -180,6 +332,59 @@ where
.children(vec![header, body])
}
/// Hash estable del path → `key` para el pop-in implícito de Llimphi. La
/// misma imagen produce siempre la misma key entre repintados (zoom/pan),
/// así el fade-in corre sólo al cambiar de imagen.
fn key_of(path: Option<&Path>) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
match path {
Some(p) => p.to_string_lossy().hash(&mut h),
None => 0u8.hash(&mut h),
}
h.finish()
}
/// Deriva una [`EmptyPalette`] desde la [`ImageViewerPalette`] (que no
/// acarrea un `Theme`). Mismo criterio que `EmptyPalette::from_theme`:
/// ícono y descripción apagados sobre `fg_muted`.
fn empty_palette(p: &ImageViewerPalette) -> EmptyPalette {
use llimphi_ui::llimphi_raster::peniko::color::AlphaColor;
let dim = |a: u8| {
let [r, g, b, _] = p.fg_muted.components;
AlphaColor::new([r, g, b, a as f32 / 255.0])
};
EmptyPalette {
fg_icon: dim(alpha::HINT),
fg_title: p.fg_muted,
fg_desc: dim(alpha::DISABLED),
}
}
/// Empty-state con orientación (en vez de un guión solo) cuando todavía no
/// hay imagen seleccionada.
fn empty_body<Msg>(palette: &ImageViewerPalette) -> View<Msg>
where
Msg: Clone + 'static,
{
let desc = rimay_localize::t("nahual-image-empty-body");
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![empty_view(
Icon::Image,
rimay_localize::t("nahual-image-empty-title"),
Some(desc.as_str()),
&empty_palette(palette),
)])
}
fn placeholder_body<Msg>(text: &str, color: Color) -> View<Msg>
where
Msg: Clone + 'static,
@@ -221,3 +426,169 @@ where
})
.image(image)
}
/// Body interactivo: pinta la imagen con `viewport` (aspect-fit × zoom + pan,
/// recortado al rect) y cablea los gestos a los callbacks del caller.
fn zoom_body<Msg, FZoom, FPan>(
image: Image,
vp: ImageViewport,
on_zoom: FZoom,
on_pan: FPan,
on_reset: Msg,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FZoom: Fn(f32, f32, f32) -> Msg + Send + Sync + 'static,
FPan: Fn(f32, f32) -> Msg + Send + Sync + '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()
})
.clip(true)
.paint_with(move |scene, _ts, rect| {
if image.image.width == 0 || image.image.height == 0 || rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let iw = image.image.width as f64;
let ih = image.image.height as f64;
// Escala base = aspect-fit; el zoom del usuario la multiplica.
let s = (rect.w as f64 / iw).min(rect.h as f64 / ih) * vp.zoom as f64;
let disp_w = iw * s;
let disp_h = ih * s;
// Centrado en el rect + pan del usuario.
let ox = rect.x as f64 + (rect.w as f64 - disp_w) * 0.5 + vp.pan.0 as f64;
let oy = rect.y as f64 + (rect.h as f64 - disp_h) * 0.5 + vp.pan.1 as f64;
let clip = KurboRect::new(
rect.x as f64,
rect.y as f64,
(rect.x + rect.w) as f64,
(rect.y + rect.h) as f64,
);
scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, Affine::IDENTITY, &clip);
scene.draw_image(&image, Affine::translate((ox, oy)) * Affine::scale(s));
scene.pop_layer();
})
// Ctrl+rueda / pinch: sólo los Update con cambio real consumen el gesto.
.on_scale(move |phase, factor, fx, fy| {
if phase == GesturePhase::Update && (factor - 1.0).abs() > f32::EPSILON {
Some(on_zoom(factor, fx, fy))
} else {
None
}
})
// Arrastre = pan.
.draggable(move |phase, dx, dy| match phase {
DragPhase::Move => Some(on_pan(dx, dy)),
_ => None,
})
// Doble-tap = volver al fit.
.on_double_tap(on_reset)
}
#[cfg(test)]
mod tests {
use super::{load_psd, ImagePreviewState, ImageViewport, DEFAULT_PSD_BYTES_MAX};
/// PSD real del corpus de `foreign-psd` (1×1, capa verde). Certifica el
/// wrapper completo: bytes en disco → composite_rgba → peniko::Image.
const PSD_1X1: &[u8] =
include_bytes!("../../../../shared/foreign-psd/tests/fixtures/green-1x1.psd");
#[test]
fn load_psd_produce_imagen() {
use std::io::Write;
let mut p = std::env::temp_dir();
p.push("nahual-load-psd.psd");
std::fs::File::create(&p).unwrap().write_all(PSD_1X1).unwrap();
let st = load_psd(&p, DEFAULT_PSD_BYTES_MAX);
let _ = std::fs::remove_file(&p);
match st {
ImagePreviewState::Image { width, height, .. } => {
assert_eq!((width, height), (1, 1));
}
ImagePreviewState::Unsupported(e) => panic!("esperaba Image, no Unsupported: {e}"),
_ => panic!("esperaba Image"),
}
}
#[test]
fn load_psd_de_basura_no_paniquea() {
use std::io::Write;
let mut p = std::env::temp_dir();
p.push("nahual-load-psd-basura.psd");
std::fs::File::create(&p).unwrap().write_all(b"no es psd").unwrap();
let st = load_psd(&p, DEFAULT_PSD_BYTES_MAX);
let _ = std::fs::remove_file(&p);
assert!(matches!(st, ImagePreviewState::Unsupported(_)));
}
#[test]
fn fit_por_defecto() {
let vp = ImageViewport::default();
assert_eq!(vp.zoom, 1.0);
assert_eq!(vp.pan, (0.0, 0.0));
}
#[test]
fn zoom_multiplica_y_clampa() {
let mut vp = ImageViewport::default();
vp.zoom_by(2.0);
assert_eq!(vp.zoom, 2.0);
vp.zoom_by(2.0);
assert_eq!(vp.zoom, 4.0);
// Tope superior.
vp.zoom_by(100.0);
assert_eq!(vp.zoom, ImageViewport::MAX_ZOOM);
// No baja del fit.
vp.zoom_by(0.0001);
assert_eq!(vp.zoom, ImageViewport::MIN_ZOOM);
}
#[test]
fn volver_al_fit_recentra() {
let mut vp = ImageViewport::default();
vp.zoom_by(4.0);
vp.pan_by(50.0, -30.0);
assert_eq!(vp.pan, (50.0, -30.0));
// Al volver al fit, el pan se descarta.
vp.zoom_by(0.01);
assert_eq!(vp.zoom, ImageViewport::MIN_ZOOM);
assert_eq!(vp.pan, (0.0, 0.0));
}
#[test]
fn pan_es_noop_sin_zoom() {
let mut vp = ImageViewport::default();
vp.pan_by(10.0, 10.0);
assert_eq!(vp.pan, (0.0, 0.0));
}
#[test]
fn pan_acumula_con_zoom() {
let mut vp = ImageViewport::default();
vp.zoom_by(3.0);
vp.pan_by(10.0, 5.0);
vp.pan_by(-4.0, 2.0);
assert_eq!(vp.pan, (6.0, 7.0));
}
#[test]
fn reset_vuelve_al_default() {
let mut vp = ImageViewport::default();
vp.zoom_by(5.0);
vp.pan_by(20.0, 20.0);
vp.reset();
assert_eq!(vp, ImageViewport::default());
}
}
@@ -0,0 +1,11 @@
# nahual-map-viewer-llimphi
*Read this in English: [README.md](README.md).*
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).
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,12 @@
# nahual-map-viewer-llimphi
Map viewer (GeoJSON/GPX/KML/PMTiles) over Llimphi.
**The geospatial domain lives in `nahual-geo-core`** (parsers, model, projection,
hit-testing, routing, basemap); this crate only paints it with vello through
`paint_with`, plus the palette and the legend. Swapping the GUI loses nothing of
the domain (the repo's rule #2).
---
Part of **nahual** — see [nahual](../README.md).
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="gioser nahual-map-viewer" xmlns="http://www.topografix.com/GPX/1/1">
<gpx version="1.1" creator="tawasuyu 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>
@@ -127,7 +127,7 @@ where
)
}
(Some(n), _) => format!("mapa · {n}"),
(None, _) => "(seleccioná un .geojson)".to_string(),
(None, _) => "(selecciona un .geojson)".to_string(),
};
// En modo búsqueda/ruteo, el header refleja el estado.
let header_text = if view.searching {
@@ -0,0 +1,29 @@
# nahual-markdown-viewer-llimphi
*Read this in English: [README.md](README.md).*
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.
El formato inline (negrita/itálica/código/tachado/enlaces) se pinta con
`View::text_spans` (RichText): cada tramo lleva su override de peso,
itálica, monospace, color y subrayado. El `href` de los enlaces se
descarta — el visor lee, no navega. Sin scroll (clip, como los demás
visores estáticos); capamos por bloques y bytes para que parley no se
atragante.
```
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,22 @@
# nahual-markdown-viewer-llimphi
Rendered Markdown viewer.
The ninth viewer of the meta-app shell. `shuma-discern` marks `.md` files with the
`markdown` lens, but until now they fell to the text viewer — which shows the raw
syntax (`# title`, `**bold**`, fences). This viewer parses the document with
`pulldown-cmark` into a list of styled blocks and paints them: headings with
increasing size by level, code blocks in monospace over a panel, lists with an
indented bullet, quotes in italics. It is *read* instead of reading like code.
The thin pattern of the other viewers: sync loading in `load_markdown`, rendering
in `markdown_viewer_view`. It knows nothing about the AppBus: the caller passes
the path.
Inline formatting (bold/italic/code/strikethrough/links) is painted with
`View::text_spans` (RichText): each run carries its own weight, italic, monospace,
colour and underline override.
---
Part of **nahual** — see [nahual](../README.md).
@@ -12,11 +12,12 @@
//! 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.
//! El formato inline (negrita/itálica/código/tachado/enlaces) se pinta con
//! `View::text_spans` (RichText): cada tramo lleva su override de peso,
//! itálica, monospace, color y subrayado. El `href` de los enlaces se
//! descarta — el visor lee, no navega. Sin scroll (clip, como los demás
//! visores estáticos); capamos por bloques y bytes para que parley no se
//! atragante.
#![forbid(unsafe_code)]
@@ -27,7 +28,7 @@ use llimphi_ui::llimphi_layout::taffy::{
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::llimphi_text::{Alignment, TextSpan, TextSpanStyle};
use llimphi_ui::View;
// El dominio (parseo + tipos) vive en `nahual-viewer-core`; lo
@@ -44,6 +45,8 @@ pub struct MarkdownViewerPalette {
pub fg_error: Color,
pub code_bg: Color,
pub code_fg: Color,
/// Color de los enlaces (y, por reuso, del código inline tinte acento).
pub fg_link: Color,
}
impl Default for MarkdownViewerPalette {
@@ -62,6 +65,7 @@ impl MarkdownViewerPalette {
fg_error: t.fg_destructive,
code_bg: t.bg_panel,
code_fg: t.fg_text,
fg_link: t.accent,
}
}
}
@@ -93,7 +97,7 @@ where
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string())
),
None => "(seleccioná un .md)".to_string(),
None => "(selecciona un .md)".to_string(),
};
let header = View::new(Style {
@@ -172,47 +176,60 @@ 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,
MdBlock::Heading { level, text } => {
let (s, spans) = build_inline(text, "", InlineFlags::default(), palette);
View::new(block_style(8.0, 3.0)).text_spans(
s,
heading_size(*level),
palette.fg_heading,
spans,
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::Paragraph(text) => {
let (s, spans) = build_inline(text, "", InlineFlags::default(), palette);
View::new(block_style(4.0, 3.0)).text_spans(
s,
13.0,
palette.fg_text,
spans,
Alignment::Start,
)
}
MdBlock::ListItem { depth, text } => {
let indent = " ".repeat(*depth as usize);
let prefix = format!("{indent}");
let (s, spans) = build_inline(text, &prefix, InlineFlags::default(), palette);
View::new(block_style(2.0, 1.0)).text_spans(
s,
13.0,
palette.fg_text,
spans,
Alignment::Start,
)
}
MdBlock::Quote(text) => {
// Base itálica para toda la cita; los spans inline se superponen.
let base = InlineFlags {
italic: true,
..Default::default()
};
let (s, spans) = build_inline(text, "", base, palette);
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_spans(s, 13.0, palette.fg_muted, spans, Alignment::Start)
}
MdBlock::Code(code) => View::new(Style {
size: Size {
width: percent(1.0_f32),
@@ -275,6 +292,46 @@ where
.text_aligned(text.to_string(), 12.0, color, Alignment::Start)
}
/// Construye `(texto, spans)` para un [`Inline`]: antepone `prefix` (viñeta
/// de lista, barra de cita) desplazando los offsets, aplica un `base`
/// full-range (p. ej. itálica de cita) como span de menor especificidad, y
/// luego los spans inline del documento. Listo para `View::text_spans`.
fn build_inline(
inl: &Inline,
prefix: &str,
base: InlineFlags,
palette: &MarkdownViewerPalette,
) -> (String, Vec<TextSpan>) {
let shift = prefix.len();
let text = format!("{prefix}{}", inl.text);
let mut spans = Vec::with_capacity(inl.spans.len() + 1);
if !base.is_plain() {
spans.push(TextSpan::new(0, text.len(), span_style(base, palette)));
}
for (s, e, f) in &inl.spans {
spans.push(TextSpan::new(s + shift, e + shift, span_style(*f, palette)));
}
(text, spans)
}
/// Traduce un conjunto de flags inline al override de estilo de Llimphi.
/// Código y enlace comparten el tinte de acento; el enlace agrega subrayado.
fn span_style(f: InlineFlags, palette: &MarkdownViewerPalette) -> TextSpanStyle {
TextSpanStyle {
size_px: None,
weight: f.bold.then_some(700.0),
italic: f.italic.then_some(true),
font_family: f.code.then(|| "monospace".to_string()),
color: if f.link || f.code {
Some(palette.fg_link)
} else {
None
},
underline: f.link.then_some(true),
strikethrough: f.strikethrough.then_some(true),
}
}
/// Estilo de bloque: ancho completo, padding vertical configurable.
fn block_style(top: f32, bottom: f32) -> Style {
Style {
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "nahual-module"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-module — el front universal de nahual como MÓDULO hospedable. Frontend liviano sobre nahual-source-core (Navigator + Source) con State/Msg/view<HostMsg>/update(+Effect): un chasis (pata, shuma, …) lo monta en un panel, le rutea eventos vía un `lift` y ejecuta sus Effects (generar miniatura, lanzar app). Mismo motor y acciones que nahual-shell, sin la ventana propia."
[dependencies]
nahual-source-core = { path = "../nahual-source-core", features = ["nouser-daemon"] }
nahual-thumb-core = { workspace = true }
app-bus = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-list = { workspace = true }
llimphi-widget-detail-table = { workspace = true }
llimphi-widget-grid = { workspace = true }
llimphi-widget-breadcrumb = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
# Para el pantallazo headless de la vista hospedada.
pollster = { workspace = true }
png = { workspace = true }
+28
View File
@@ -0,0 +1,28 @@
# nahual-module
*Read this in English: [README.md](README.md).*
El front universal de nahual como **módulo hospedable**.
`nahual-shell` es una app con ventana propia (un `App` del bucle Elm). Este
crate expone el **mismo motor y las mismas acciones** como un módulo que un
chasis (pata, shuma, …) monta dentro de un panel —igual que
`shuma-module-shell`—: un `State`, un `Msg`, un `view` genérico sobre
el `Msg` del host (vía un `lift`), y un `update` **puro** que devuelve
`Effect`s para que el host ejecute el trabajo asíncrono con su `Handle`
(generar una miniatura, lanzar una app). El host nunca toca los campos del
`State`: le rutea eventos y pinta su `view`.
Es un **frontend intercambiable sobre `nahual-source-core`** (regla 2 del
repo): toda la navegación —POSIX, Mónadas del daemon vivo, imágenes wawa,
archivos `.zip`— vive en el `Navigator`; este crate sólo lo pinta y traduce
eventos. Por eso convive con `nahual-shell` sin duplicar lógica de dominio.
Cubre navegación (árbol/lista/detalle/iconos + breadcrumb + filtro),
miniaturas async, abrir con la app por defecto y "abrir con…" hacia la
suite. Las operaciones de archivo (crear/borrar/renombrar) son v2 —piden la
cola + prompts del shell.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
+20
View File
@@ -0,0 +1,20 @@
# nahual-module
nahual's universal front as a **hostable module**.
`nahual-shell` is an app with its own window (an Elm-loop `App`). This crate
exposes the **same engine and the same actions** as a module a chassis (pata,
shuma, …) mounts inside a panel — just like `shuma-module-shell`: a `State`, a
`Msg`, a `view` generic over the host's `Msg` (through a `lift`), and a **pure**
`update` returning `Effect`s so the host runs the async work with its `Handle`
(generate a thumbnail, launch an app). The host never touches the `State`'s
fields: it routes events and paints its `view`.
It is an **interchangeable frontend over `nahual-source-core`** (the repo's rule
2): all navigation — POSIX, live-daemon Monads, wawa images, `.zip` files — lives
in the `Navigator`; this crate only paints it and translates events. That is why
it coexists with `nahual-shell` without duplicating domain logic.
---
Part of **nahual** — see [nahual](../README.md).
@@ -0,0 +1,175 @@
//! Pantallazo headless del **módulo hospedado**: renderiza
//! `nahual_module::view` (vista detalle) dentro de un marco tipo drawer, como
//! lo pintaría pata. Prueba que el front universal corre como módulo, no sólo
//! como app.
//!
//! `cargo run -p nahual-module --example pantallazo_modulo --release -- [out.png]`
#![allow(dead_code)]
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy::{
self,
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_layout::LayoutTree;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::llimphi_text::Typesetter;
use llimphi_ui::{measure_text_node, mount, paint, Mounted, View};
const W: u32 = 1000;
const H: u32 = 680;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// El `Msg` del host: envuelve los del módulo (lo que hace pata con `Msg::Nahual`).
#[derive(Clone)]
enum Host {
N(nahual_module::Msg),
Nada,
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "/tmp/shots/modulo.png".to_string());
if let Some(dir) = Path::new(&out).parent() {
std::fs::create_dir_all(dir).ok();
}
let theme = Theme::dark();
// Árbol POSIX real para que las filas sean datos verdaderos.
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("src")).unwrap();
std::fs::create_dir(tmp.path().join("assets")).unwrap();
std::fs::write(tmp.path().join("README.md"), vec![b'#'; 1200]).unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), vec![b'x'; 340]).unwrap();
std::fs::write(tmp.path().join("notas.txt"), vec![b'y'; 64]).unwrap();
// Estado del módulo, en vista detalle (como lo guardaría el host).
let mut st = nahual_module::State::posix(tmp.path());
st = nahual_module::update(st, nahual_module::Msg::ToggleView).0; // List → Details
// Cabecera del drawer (lo que pata dibuja alrededor del módulo).
let titulo = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
padding: pad_h(14.0),
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(theme.bg_panel_alt)
.text("nahual · hospedado en pata (Super+E)", 13.0, theme.accent);
// El módulo: su view genérico sobre el Msg del host vía lift.
let modulo = nahual_module::view::<Host>(&st, &theme, Host::N);
let root = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![titulo, modulo]);
let mut ts = Typesetter::new();
let mut scene = vello::Scene::new();
paint_view(&mut scene, &mut ts, root);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("pantallazo-modulo"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render");
write_png(&hal, &target, &out);
eprintln!("pantallazo_modulo: escrito {out} ({W}x{H}) · view() del módulo con datos reales");
}
fn pad_h(v: f32) -> Rect<taffy::LengthPercentage> {
Rect { left: length(v), right: length(v), top: length(0.0), bottom: length(0.0) }
}
fn paint_view(scene: &mut vello::Scene, ts: &mut Typesetter, view: View<Host>) {
let mut layout = LayoutTree::new();
let mounted: Mounted<Host> = mount(&mut layout, view);
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
paint(scene, &mounted, &computed, ts, None, None);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc =
hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+885
View File
@@ -0,0 +1,885 @@
//! `nahual-module` — el front universal de nahual como **módulo hospedable**.
//!
//! `nahual-shell` es una app con ventana propia (un `App` del bucle Elm). Este
//! crate expone el **mismo motor y las mismas acciones** como un módulo que un
//! chasis (pata, shuma, …) monta dentro de un panel —igual que
//! `shuma-module-shell`—: un [`State`], un [`Msg`], un [`view`] genérico sobre
//! el `Msg` del host (vía un `lift`), y un [`update`] **puro** que devuelve
//! [`Effect`]s para que el host ejecute el trabajo asíncrono con su `Handle`
//! (generar una miniatura, lanzar una app). El host nunca toca los campos del
//! `State`: le rutea eventos y pinta su `view`.
//!
//! Es un **frontend intercambiable sobre `nahual-source-core`** (regla 2 del
//! repo): toda la navegación —POSIX, Mónadas del daemon vivo, imágenes wawa,
//! archivos `.zip`— vive en el `Navigator`; este crate sólo lo pinta y traduce
//! eventos. Por eso convive con `nahual-shell` sin duplicar lógica de dominio.
//!
//! Cubre navegación (árbol/lista/detalle/iconos + breadcrumb + filtro),
//! miniaturas async, abrir con la app por defecto y "abrir con…" hacia la
//! suite. Las operaciones de archivo (crear/borrar/renombrar) son v2 —piden la
//! cola + prompts del shell.
#![forbid(unsafe_code)]
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use app_bus::AppRegistry;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::{
Blob, ImageAlphaType, ImageBrush as Image, ImageData, ImageFormat,
};
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_breadcrumb::{breadcrumb_view, BreadcrumbPalette};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_detail_table::{
detail_table_view, Column, DetailPalette, DetailRow, DetailSpec, SortDir as DtDir,
};
use llimphi_widget_grid::{grid_view, ventana_visible, GridCell, GridMetrics, GridPalette, GridSpec};
use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
use nahual_source_core::{
ArchiveSource, Node, NodeId, NodeKind, Opened, PosixSource, SortKey, SortDir, Source, ViewMode,
WawaImgSource,
};
pub use nahual_source_core::Navigator;
use nahual_thumb_core::{generar_thumb_de_archivo, ThumbRgba};
/// Lado máximo (px) de las miniaturas de la vista iconos.
pub const THUMB_LADO: u32 = 128;
/// Tope de miniaturas pedidas por pasada (acota los spawns del host).
const MAX_ICON_TILES: usize = 160;
/// Trabajo asíncrono o con efectos que el **host** debe ejecutar (tiene el
/// `Handle`; el módulo es puro). El host hace el spawn/launch y, para las
/// miniaturas, realimenta [`Msg::ThumbReady`]/[`Msg::ThumbFailed`].
#[derive(Debug, Clone)]
pub enum Effect {
/// Generar la miniatura de este archivo POSIX y devolverla por
/// `Msg::ThumbReady(path, thumb)`.
GenThumb(PathBuf),
/// Abrir `path` con la app por defecto de su tipo (doble-clic / Enter sobre
/// una hoja que no se monta).
OpenDefault(PathBuf),
/// Abrir `path` con la app `app_id` de la suite ("abrir con…").
Launch { app_id: String, path: PathBuf },
}
/// Menú "abrir con…" abierto sobre una hoja: ancla en coords de panel +
/// opciones `(app_id, label)` ya resueltas, + el path objetivo.
#[derive(Clone)]
struct MenuData {
at: (f32, f32),
options: Vec<(String, String)>,
target: PathBuf,
}
/// Estado del módulo. El host lo guarda en su modelo (`inner`) y nunca lo muta
/// directo — sólo vía [`update`].
pub struct State {
/// Pila de montaje: `[0]` = fuente base; montar empuja, desmontar saca.
nav_stack: Vec<Navigator>,
/// Selección múltiple por id (marca con la barra espaciadora / Insert).
marked: BTreeSet<NodeId>,
/// Cache RAM de miniaturas listas para pintar (clave = ruta POSIX).
thumbs: HashMap<PathBuf, Image>,
thumbs_pending: HashSet<PathBuf>,
thumbs_failed: HashSet<PathBuf>,
/// Catálogo de apps de la suite (open-with).
registry: AppRegistry,
/// `true` mientras se teclea el filtro vivo.
filtering: bool,
/// Menú "abrir con…" abierto, si hay.
menu: Option<MenuData>,
}
impl State {
/// Monta el módulo sobre una fuente cualquiera (POSIX, daemon, …).
pub fn on_source(source: Box<dyn Source>) -> std::io::Result<Self> {
Ok(Self::from_nav(Navigator::open(source)?))
}
/// Monta el módulo sobre el filesystem POSIX, parado en `cwd` con la miga
/// de ancestros completa (raíz anclada en `/`).
pub fn posix(cwd: &Path) -> Self {
Self::from_nav(posix_nav(cwd))
}
/// Monta el módulo sobre las **Mónadas del daemon vivo** de nouser
/// (descubre el socket por el broker → fallback). **Bloqueante** (consulta
/// inicial al daemon): construir el [`State`] así en el hilo de UI lo
/// congela. Para un chasis, preferí [`connect_daemon_navigator`] en un
/// worker + [`State::mount_navigator`].
pub fn nouser_daemon() -> std::io::Result<Self> {
let src = nahual_source_core::NouserDaemonSource::discover()?;
Self::on_source(Box::new(src))
}
/// Empuja un [`Navigator`] **ya construido** sobre la pila de montaje (sin
/// I/O — no bloquea). El gancho para montar una fuente cara (el daemon de
/// Mónadas) que un worker armó con [`connect_daemon_navigator`]: el host
/// hace el `Handle::spawn`, recibe el `Navigator` listo y lo monta aquí.
pub fn mount_navigator(&mut self, nav: Navigator) {
self.nav_stack.push(nav);
self.marked.clear();
self.menu = None;
}
fn from_nav(nav: Navigator) -> Self {
Self {
nav_stack: vec![nav],
marked: BTreeSet::new(),
thumbs: HashMap::new(),
thumbs_pending: HashSet::new(),
thumbs_failed: HashSet::new(),
registry: AppRegistry::with_defaults(),
filtering: false,
menu: None,
}
}
fn cur(&self) -> &Navigator {
self.nav_stack.last().expect("nav_stack nunca vacía")
}
fn cur_mut(&mut self) -> &mut Navigator {
self.nav_stack.last_mut().expect("nav_stack nunca vacía")
}
/// `true` si hay una fuente no-POSIX montada (pila > 1).
pub fn is_foreign(&self) -> bool {
self.nav_stack.len() > 1
}
/// La ruta POSIX del nodo seleccionado, si su id ES una ruta real (POSIX o
/// archivo miembro de una Mónada del daemon). `None` para hojas sintéticas.
fn selected_path(&self) -> Option<PathBuf> {
let n = self.cur().selected_node()?;
if n.is_container {
return None;
}
let p = PathBuf::from(&n.id);
p.is_file().then_some(p)
}
}
/// Mensajes del módulo. El host los envuelve con su `lift` al construir la
/// `view`, y se los reenvía a [`update`] cuando llegan.
#[derive(Debug, Clone)]
pub enum Msg {
Up,
Down,
/// Selecciona la fila `idx` (índice absoluto en los hijos).
Select(usize),
/// Abre la selección: contenedor → desciende; hoja montable → monta; resto
/// → `Effect::OpenDefault`.
Open,
/// Sube al contenedor padre (o desmonta si está en la raíz de una fuente).
Parent,
/// Sube al nivel `depth` del breadcrumb.
BreadcrumbTo(usize),
/// Cicla lista → detalle → iconos.
ToggleView,
/// Muestra/esconde las entradas ocultas (dotfiles) — Ctrl+H, como en
/// cualquier diálogo de archivos.
ToggleHidden,
/// Rueda: +abajo / arriba (líneas).
Scroll(i32),
/// Marca/desmarca la fila bajo el cursor.
ToggleMark,
FilterStart,
FilterInput(String),
FilterBackspace,
FilterEnd,
/// Ordena por la columna `col` (0 nombre · 1 tamaño · 2 fecha · 3 tipo).
SortBy(usize),
/// Abre el menú "abrir con…" sobre la hoja seleccionada, anclado en `(x,y)`.
OpenContextAt(f32, f32),
/// Elige una app del menú "abrir con…".
OpenWith(String),
/// Cierra el menú "abrir con…".
CloseMenu,
/// Una miniatura terminó (la realimenta el host).
ThumbReady(PathBuf, ThumbRgba),
/// La miniatura de este path falló.
ThumbFailed(PathBuf),
}
/// Aplica `msg` a `state` y devuelve los [`Effect`]s que el host debe ejecutar.
/// **Puro**: no spawnea ni toca el `Handle`.
pub fn update(mut state: State, msg: Msg) -> (State, Vec<Effect>) {
let mut fx = Vec::new();
match msg {
Msg::Up => {
state.cur_mut().up();
}
Msg::Down => {
state.cur_mut().down();
}
Msg::Select(idx) => {
state.cur_mut().select(idx);
}
Msg::Open => {
match state.cur_mut().open_selected() {
Ok(Some(Opened::Descended)) => {
state.marked.clear();
request_thumbs(&mut state, &mut fx);
}
Ok(Some(Opened::Leaf(id))) => {
let path = Path::new(&id);
if path.is_file() {
// Montable (.img wawa / .zip|.tar) → empuja; si no, abre
// con la app por defecto.
if let Some(nav) = try_mount(path) {
state.nav_stack.push(nav);
state.marked.clear();
request_thumbs(&mut state, &mut fx);
} else {
fx.push(Effect::OpenDefault(path.to_path_buf()));
}
} else {
// Hoja no-POSIX sin ruta real: nada que lanzar (v2:
// materializar a tempfile como hace el shell).
}
}
Ok(None) | Err(_) => {}
}
}
Msg::Parent => match state.cur_mut().parent() {
Ok(true) => request_thumbs(&mut state, &mut fx),
Ok(false) => {
if state.is_foreign() {
state.nav_stack.pop();
request_thumbs(&mut state, &mut fx);
}
}
Err(_) => {}
},
Msg::BreadcrumbTo(depth) => {
if state.cur_mut().ascend_to(depth).is_ok() {
request_thumbs(&mut state, &mut fx);
}
}
Msg::ToggleView => {
let v = state.cur().view.next();
state.cur_mut().view = v;
if v == ViewMode::Icons {
request_thumbs(&mut state, &mut fx);
}
}
Msg::ToggleHidden => {
state.cur_mut().toggle_hidden();
if state.cur().view == ViewMode::Icons {
request_thumbs(&mut state, &mut fx);
}
}
Msg::Scroll(steps) => {
state.cur_mut().scroll(steps);
if state.cur().view == ViewMode::Icons {
request_thumbs(&mut state, &mut fx);
}
}
Msg::ToggleMark => {
if let Some(n) = state.cur().selected_node() {
let id = n.id.clone();
if !state.marked.insert(id.clone()) {
state.marked.remove(&id);
}
state.cur_mut().down();
}
}
Msg::FilterStart => state.filtering = true,
Msg::FilterInput(s) => {
let mut f = state.cur().filter().to_string();
f.push_str(&s);
state.cur_mut().set_filter(f);
}
Msg::FilterBackspace => {
let mut f = state.cur().filter().to_string();
f.pop();
state.cur_mut().set_filter(f);
}
Msg::FilterEnd => state.filtering = false,
Msg::SortBy(col) => state.cur_mut().set_sort(col_to_sortkey(col)),
Msg::OpenContextAt(x, y) => {
if let Some(path) = state.selected_path() {
let mime = mime_for(&path);
let options: Vec<(String, String)> = state
.registry
.handlers_for(&mime)
.into_iter()
.map(|e| (e.id.clone(), e.label.clone()))
.collect();
state.menu = Some(MenuData { at: (x, y), options, target: path });
}
}
Msg::OpenWith(app_id) => {
if let Some(menu) = state.menu.take() {
fx.push(Effect::Launch { app_id, path: menu.target });
}
}
Msg::CloseMenu => state.menu = None,
Msg::ThumbReady(path, thumb) => {
state.thumbs_pending.remove(&path);
let img = Image::new(ImageData {
data: Blob::from(thumb.rgba),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: thumb.w,
height: thumb.h,
});
state.thumbs.insert(path, img);
}
Msg::ThumbFailed(path) => {
state.thumbs_pending.remove(&path);
state.thumbs_failed.insert(path);
}
}
(state, fx)
}
/// Encola `Effect::GenThumb` para las imágenes visibles aún sin miniatura
/// (sólo en vista iconos sobre POSIX). El host las spawnea.
fn request_thumbs(state: &mut State, fx: &mut Vec<Effect>) {
if state.is_foreign() || state.cur().view != ViewMode::Icons {
return;
}
let pedir: Vec<PathBuf> = {
let nav = state.cur();
let visibles = nav.visible();
let start = nav.visible_offset.min(visibles.len());
let end = (start + MAX_ICON_TILES).min(visibles.len());
visibles[start..end]
.iter()
.filter(|(_, n)| !n.is_container)
.map(|(_, n)| PathBuf::from(&n.id))
.filter(|p| {
es_imagen(p)
&& p.is_file()
&& !state.thumbs.contains_key(p)
&& !state.thumbs_pending.contains(p)
&& !state.thumbs_failed.contains(p)
})
.collect()
};
for p in pedir {
state.thumbs_pending.insert(p.clone());
fx.push(Effect::GenThumb(p));
}
}
/// Conveniencia para el host: ejecuta un [`Effect::GenThumb`] (corre en el
/// worker del host) y arma el `Msg` de vuelta. Centraliza la cadena
/// decode→`ThumbRgba` para que el chasis no la repita.
pub fn run_gen_thumb(path: PathBuf) -> Msg {
match generar_thumb_de_archivo(&path, THUMB_LADO) {
Ok(t) => Msg::ThumbReady(path, t),
Err(_) => Msg::ThumbFailed(path),
}
}
/// Construye —**bloqueante**, pensado para un worker del host— el [`Navigator`]
/// de las Mónadas del daemon vivo: descubre el socket (broker → fallback) y hace
/// la consulta inicial. El chasis lo corre en `Handle::spawn` para no congelar
/// la UI y luego monta el resultado con [`State::mount_navigator`]. El
/// `Navigator` es `Send`, así que viaja del worker al hilo de UI sin problema.
pub fn connect_daemon_navigator() -> std::io::Result<Navigator> {
let src = nahual_source_core::NouserDaemonSource::discover()?;
Navigator::open(Box::new(src))
}
// =====================================================================
// View
// =====================================================================
/// Pinta el módulo: breadcrumb + lista/detalle/iconos. Genérico sobre el `Msg`
/// del host vía `lift`. El menú contextual "abrir con…" va aparte en
/// [`context_overlay`] (es un overlay absoluto que el host posiciona).
pub fn view<H: Clone + 'static>(
state: &State,
theme: &Theme,
lift: impl Fn(Msg) -> H + Clone + 'static,
) -> View<H> {
let crumb = breadcrumb::<H>(state, theme, lift.clone());
let body = match state.cur().view {
ViewMode::List => list_panel::<H>(state, theme, lift.clone()),
ViewMode::Details => detail_panel::<H>(state, theme, lift.clone()),
// El módulo aún no distingue galería: usa la misma grilla de iconos.
ViewMode::Icons | ViewMode::Gallery => icons_panel::<H>(state, theme, lift),
};
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.children(vec![crumb, body])
}
/// El menú "abrir con…" como overlay absoluto, si está abierto. El host lo
/// apila por encima de su chrome y le pasa su `viewport` (para que el menú no
/// se salga de pantalla). Devuelve `None` si no hay menú.
pub fn context_overlay<H: Clone + Send + Sync + 'static>(
state: &State,
theme: &Theme,
viewport: (f32, f32),
lift: impl Fn(Msg) -> H + Clone + Send + Sync + 'static,
) -> Option<View<H>> {
use std::sync::Arc;
let menu = state.menu.as_ref()?;
// Items + el Msg paralelo por índice (el on_pick mapea índice → Msg).
let (items, msgs): (Vec<ContextMenuItem>, Vec<H>) = if menu.options.is_empty() {
(
vec![ContextMenuItem::action("(sin apps para este tipo)").disabled()],
vec![lift(Msg::CloseMenu)],
)
} else {
menu.options
.iter()
.map(|(id, label)| {
(ContextMenuItem::action(format!("Abrir con {label}")), lift(Msg::OpenWith(id.clone())))
})
.unzip()
};
let dismiss = lift(Msg::CloseMenu);
let on_pick: Arc<dyn Fn(usize) -> H + Send + Sync> =
Arc::new(move |i: usize| msgs.get(i).cloned().unwrap_or_else(|| dismiss.clone()));
Some(context_menu_view(ContextMenuSpec {
anchor: menu.at,
viewport,
header: Some("Abrir con…".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss: lift(Msg::CloseMenu),
palette: ContextMenuPalette::from_theme(theme),
}))
}
fn breadcrumb<H: Clone + 'static>(
state: &State,
theme: &Theme,
lift: impl Fn(Msg) -> H + Clone + 'static,
) -> View<H> {
let mut segs: Vec<String> = state.cur().ancestors().iter().map(|n| n.name.clone()).collect();
if state.is_foreign() && !segs.is_empty() {
segs[0] = format!("{}", state.cur().label());
}
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
let crumbs = breadcrumb_view(&refs, move |d| lift(Msg::BreadcrumbTo(d)), &BreadcrumbPalette::from_theme(theme));
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
padding: pad_h(12.0),
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![crumbs])
}
fn caption(state: &State) -> String {
let nav = state.cur();
let f = nav.filter();
if state.filtering || !f.is_empty() {
let cur = if state.filtering { "_" } else { "" };
format!("{} de {} · filtro: {f}{cur}", nav.visible_count(), nav.children().len())
} else {
format!("{} entradas · ↑↓ · ⏎ abre · ⌫ vuelve · v vista · / filtra", nav.children().len())
}
}
fn list_panel<H: Clone + 'static>(
state: &State,
theme: &Theme,
lift: impl Fn(Msg) -> H + Clone + 'static,
) -> View<H> {
let nav = state.cur();
let visibles = nav.visible();
let start = nav.visible_offset.min(visibles.len());
let end = (start + nav.visible_rows).min(visibles.len());
let rows: Vec<ListRow<H>> = visibles[start..end]
.iter()
.map(|(idx, n)| {
let mark = if state.marked.contains(&n.id) { "" } else { " " };
let icon = if n.is_container { "" } else { " " };
let label = if n.is_container {
format!("{mark}{icon}{}/", n.name)
} else {
format!("{mark}{icon}{}", n.name)
};
let i = *idx;
ListRow { label, selected: *idx == nav.selected, on_click: lift(Msg::Select(i)) }
})
.collect();
let truncated_hint =
(visibles.len() > end).then(|| format!("… y {} más", visibles.len() - end));
list_view(ListSpec {
rows,
total: visibles.len(),
caption: Some(caption(state)),
truncated_hint,
row_height: 22.0,
palette: ListPalette::from_theme(theme),
})
}
fn detail_panel<H: Clone + 'static>(
state: &State,
theme: &Theme,
lift: impl Fn(Msg) -> H + Clone + 'static,
) -> View<H> {
let nav = state.cur();
let (skey, sdir) = nav.sort();
let sort_col = sortkey_to_col(skey);
let dir = if matches!(sdir, SortDir::Asc) { DtDir::Asc } else { DtDir::Desc };
let visibles = nav.visible();
let start = nav.visible_offset.min(visibles.len());
let end = (start + nav.visible_rows).min(visibles.len());
let rows: Vec<DetailRow<H>> = visibles[start..end]
.iter()
.map(|(idx, n)| {
let mark = if state.marked.contains(&n.id) { "" } else { " " };
let name = if n.is_container { format!("{mark}{}/", n.name) } else { format!("{mark}{}", n.name) };
DetailRow {
cells: vec![name, human_size(n.size), human_mtime(n.mtime), kind_label(n.kind).to_string()],
selected: *idx == nav.selected,
accent: None,
icon: None,
on_click: lift(Msg::Select(*idx)),
}
})
.collect();
let columns = [
Column::flex("Nombre", 1.0),
Column::fixed("Tamaño", 88.0).right(),
Column::fixed("Modificado", 140.0),
Column::fixed("Tipo", 84.0),
];
let lift2 = lift.clone();
detail_table_view(
DetailSpec {
columns: &columns,
rows,
sort: Some((sort_col, dir)),
row_height: 22.0,
caption: Some(caption(state)),
palette: DetailPalette::from_theme(theme),
},
move |col| lift2(Msg::SortBy(col)),
)
}
fn icons_panel<H: Clone + 'static>(
state: &State,
theme: &Theme,
lift: impl Fn(Msg) -> H + Clone + 'static,
) -> View<H> {
let nav = state.cur();
let metrics = GridMetrics::default();
let total = nav.visible_count();
// Sin dims del panel: estimamos 4 columnas (el host puede re-derivar luego).
let win = ventana_visible(total, metrics.tile_w * 4.2, 600.0, 0, &metrics);
let visibles = nav.visible();
let start = nav.visible_offset.min(visibles.len());
let end = (start + MAX_ICON_TILES).min(visibles.len());
let cells: Vec<GridCell<H>> = visibles[start..end]
.iter()
.map(|(idx, n)| {
let mark = if state.marked.contains(&n.id) { "" } else { "" };
GridCell {
content: tile_content::<H>(state, n, theme, metrics.tile_w - 12.0),
label: Some(format!("{mark}{}", n.name)),
selected: *idx == nav.selected,
on_click: lift(Msg::Select(*idx)),
}
})
.collect();
let mostrados = start + cells.len();
let truncated_hint = (mostrados < total).then(|| format!("… y {} más", total - mostrados));
grid_view(GridSpec {
cells,
cols: win.cols,
metrics,
caption: Some(caption(state)),
truncated_hint,
palette: GridPalette::from_theme(theme),
})
}
fn tile_content<H: Clone + 'static>(state: &State, node: &Node, theme: &Theme, lado: f32) -> View<H> {
let base = || Style {
size: Size { width: length(lado), height: length(lado) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
};
if node.is_container {
let g = match node.kind {
NodeKind::Archive => "",
NodeKind::Synthetic => "",
_ => "",
};
return View::new(base()).fill(theme.bg_panel_alt).text(g, 44.0, theme.fg_text);
}
let path = PathBuf::from(&node.id);
if let Some(img) = state.thumbs.get(&path) {
return View::new(base()).image(img.clone());
}
if state.thumbs_failed.contains(&path) {
return View::new(base()).fill(theme.bg_panel_alt).text("", 24.0, theme.fg_muted);
}
let g = if es_imagen(&path) { "" } else { "" };
View::new(base()).fill(theme.bg_panel_alt).text(g, 36.0, theme.fg_muted)
}
/// Traducción opcional de un evento de teclado a un [`Msg`]. El host puede
/// usarla cuando el módulo tiene el foco; devuelve `None` si la tecla no le
/// concierne (el host la procesa).
pub fn on_key(state: &State, e: &KeyEvent) -> Option<Msg> {
if e.state != KeyState::Pressed {
return None;
}
if state.filtering {
return match &e.key {
Key::Named(NamedKey::Escape) | Key::Named(NamedKey::Enter) => Some(Msg::FilterEnd),
Key::Named(NamedKey::Backspace) => Some(Msg::FilterBackspace),
Key::Character(s) => Some(Msg::FilterInput(s.to_string())),
_ => None,
};
}
match &e.key {
Key::Named(NamedKey::ArrowUp) => Some(Msg::Up),
Key::Named(NamedKey::ArrowDown) => Some(Msg::Down),
Key::Named(NamedKey::Enter) => Some(Msg::Open),
Key::Named(NamedKey::Backspace) => Some(Msg::Parent),
Key::Named(NamedKey::Space) => Some(Msg::ToggleMark),
Key::Character(s) if s.eq_ignore_ascii_case("h") && e.modifiers.ctrl => {
Some(Msg::ToggleHidden)
}
Key::Character(s) if s == "v" => Some(Msg::ToggleView),
Key::Character(s) if s == "/" => Some(Msg::FilterStart),
_ => None,
}
}
// =====================================================================
// Helpers
// =====================================================================
fn posix_nav(cwd: &Path) -> Navigator {
use std::path::Component;
let mut stack = vec![Node::new("/", "/", true).with_kind(NodeKind::Dir)];
let mut acc = PathBuf::from("/");
for comp in cwd.components() {
if let Component::Normal(c) = comp {
acc.push(c);
stack.push(
Node::new(acc.to_string_lossy().into_owned(), c.to_string_lossy().into_owned(), true)
.with_kind(NodeKind::Dir),
);
}
}
Navigator::open_at(Box::new(PosixSource::new("/")), stack)
.or_else(|_| Navigator::open(Box::new(PosixSource::new("/"))))
.expect("la raíz / siempre se puede listar")
}
fn try_mount(path: &Path) -> Option<Navigator> {
if let Ok(src) = WawaImgSource::abrir(path) {
return Navigator::open(Box::new(src)).ok();
}
if ArchiveSource::es_archivo(path) {
if let Ok(src) = ArchiveSource::abrir(path) {
return Navigator::open(Box::new(src)).ok();
}
}
None
}
fn es_imagen(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
Some("png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "ico" | "avif" | "qoi" | "tga")
)
}
/// MIME mínimo por extensión para rankear handlers en "abrir con…". No es
/// `shuma-discern` (eso vive en el shell): un mapa chico alcanza para el menú.
fn mime_for(path: &Path) -> String {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "ico" | "avif" => {
format!("image/{}", if ext == "jpg" { "jpeg" } else { ext.as_str() })
}
"mp3" | "flac" | "ogg" | "wav" | "opus" | "m4a" => format!("audio/{ext}"),
"mp4" | "mkv" | "webm" | "mov" | "avi" => format!("video/{ext}"),
"md" | "markdown" => "text/markdown".to_string(),
"html" | "htm" => "text/html".to_string(),
"csv" => "text/csv".to_string(),
"" => "application/octet-stream".to_string(),
other => format!("text/x-{other}"),
}
}
fn col_to_sortkey(col: usize) -> SortKey {
match col {
1 => SortKey::Size,
2 => SortKey::Mtime,
3 => SortKey::Kind,
_ => SortKey::Name,
}
}
fn sortkey_to_col(key: SortKey) -> usize {
match key {
SortKey::Name => 0,
SortKey::Size => 1,
SortKey::Mtime => 2,
SortKey::Kind => 3,
}
}
fn kind_label(k: NodeKind) -> &'static str {
match k {
NodeKind::Dir => "carpeta",
NodeKind::File => "archivo",
NodeKind::Symlink => "enlace",
NodeKind::Archive => "archivo comp.",
NodeKind::Synthetic => "",
}
}
fn human_size(size: Option<u64>) -> String {
let Some(n) = size else { return "".into() };
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")
}
}
fn human_mtime(mtime_ms: Option<u64>) -> String {
let Some(ms) = mtime_ms else { return "".into() };
// Fecha civil sin deps (UTC): suficiente para la columna.
let secs = (ms / 1000) as i64;
let days = secs.div_euclid(86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}")
}
/// Algoritmo de Howard Hinnant: días desde epoch → (año, mes, día). Sin deps.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
fn pad_h(v: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::LengthPercentage> {
Rect { left: length(v), right: length(v), top: length(0.0_f32), bottom: length(0.0_f32) }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn arbol() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("a.txt"), b"hola").unwrap();
fs::write(dir.path().join("sub/b.txt"), b"chau").unwrap();
dir
}
#[test]
fn navega_posix_y_abre_default() {
let dir = arbol();
let mut st = State::posix(dir.path());
// Seleccionar el archivo a.txt y abrir → Effect::OpenDefault.
let idx = st.cur().children().iter().position(|n| n.name == "a.txt").unwrap();
st = update(st, Msg::Select(idx)).0;
let (st, fx) = update(st, Msg::Open);
assert!(matches!(fx.as_slice(), [Effect::OpenDefault(p)] if p.ends_with("a.txt")));
let _ = st;
}
#[test]
fn descender_y_subir() {
let dir = arbol();
let mut st = State::posix(dir.path());
let idx = st.cur().children().iter().position(|n| n.name == "sub").unwrap();
st = update(st, Msg::Select(idx)).0;
st = update(st, Msg::Open).0;
assert!(st.cur().children().iter().any(|n| n.name == "b.txt"));
st = update(st, Msg::Parent).0;
assert!(st.cur().children().iter().any(|n| n.name == "a.txt"));
}
#[test]
fn toggle_view_cicla() {
let dir = arbol();
let mut st = State::posix(dir.path());
assert_eq!(st.cur().view, ViewMode::List);
st = update(st, Msg::ToggleView).0;
assert_eq!(st.cur().view, ViewMode::Details);
st = update(st, Msg::ToggleView).0;
assert_eq!(st.cur().view, ViewMode::Icons);
}
#[test]
fn open_context_arma_menu_y_open_with_lanza() {
let dir = arbol();
let mut st = State::posix(dir.path());
let idx = st.cur().children().iter().position(|n| n.name == "a.txt").unwrap();
st = update(st, Msg::Select(idx)).0;
st = update(st, Msg::OpenContextAt(10.0, 10.0)).0;
assert!(st.menu.is_some());
let (st, fx) = update(st, Msg::OpenWith("nada".into()));
assert!(matches!(fx.as_slice(), [Effect::Launch { app_id, .. }] if app_id == "nada"));
assert!(st.menu.is_none());
}
#[test]
fn mount_navigator_empuja_y_parent_desmonta() {
let base = arbol();
let otra = tempfile::tempdir().unwrap();
fs::write(otra.path().join("solo.txt"), b"z").unwrap();
let mut st = State::posix(base.path());
assert!(!st.is_foreign());
// Montar una 2da fuente ya construida (como el daemon, off-thread).
let nav = Navigator::open(Box::new(PosixSource::new(otra.path()))).unwrap();
st.mount_navigator(nav);
assert!(st.is_foreign(), "montar una fuente vuelve foreign");
assert!(st.cur().children().iter().any(|n| n.name == "solo.txt"));
// Subir desde la raíz de la fuente montada la desmonta (vuelve a POSIX).
let (st, _) = update(st, Msg::Parent);
assert!(!st.is_foreign());
assert!(st.cur().children().iter().any(|n| n.name == "a.txt"));
}
#[test]
fn mime_for_basico() {
assert_eq!(mime_for(Path::new("x.png")), "image/png");
assert_eq!(mime_for(Path::new("x.jpg")), "image/jpeg");
assert_eq!(mime_for(Path::new("x.md")), "text/markdown");
}
}
@@ -0,0 +1,22 @@
[package]
name = "nahual-pdf-viewer-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "nahual-pdf-viewer-llimphi — visor de PDF sobre Llimphi. Rasteriza páginas con foreign-pdf (hayro, CPU) a Rgba8, arma un peniko::Image por página y navega con . Render lazy por página con caché."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-widget-empty = { workspace = true }
llimphi-image = { workspace = true }
foreign-pdf = { workspace = true }
rimay-localize = { workspace = true }
[[example]]
name = "pdf_viewer_demo"
path = "examples/pdf_viewer_demo.rs"
@@ -0,0 +1,25 @@
# nahual-pdf-viewer-llimphi
*Read this in English: [README.md](README.md).*
Visor de PDF sobre Llimphi.
Miembro de la familia de visores de nahual (uno por naturaleza de dato).
La lógica de rasterizado NO vive aquí: entra por el puente
`foreign_pdf` (Regla #4`hayro`, rasterizador PDF puro-Rust sobre
`vello_cpu`, corre en **CPU**, no toca la GPU de mirada/llimphi). Este
crate es la costura UI: mantiene la página visible como `peniko::Image`
(vía `llimphi_image::from_rgba8`) y navega con .
**Render lazy con caché**: al abrir se rasteriza sólo la página 0; cada
salto de página rasteriza la nueva (si no está cacheada) en el `update`
del caller — nunca en `view`, que es puro (`&state`). El `Documento` de
hayro se mantiene vivo dentro del estado (`PreviewPane` no es `Clone` en
el shell, así que no hace falta que este estado lo sea).
La carga es **sync**, como el resto de la familia; para PDFs pesados
conviene envolver `load_pdf` en `Handle::spawn` y reentrar con un Msg.
---
Parte de **nahual** — ver [nahual](../LEEME.md).
@@ -0,0 +1,19 @@
# nahual-pdf-viewer-llimphi
PDF viewer over Llimphi.
A member of nahual's viewer family (one per nature of data). The rasterizing logic
does NOT live here: it comes through the `foreign_pdf` bridge (Rule #4`hayro`,
a pure-Rust PDF rasterizer over `vello_cpu`, running on **CPU**, never touching
mirada/llimphi's GPU). This crate is the UI seam: it keeps the visible page as a
`peniko::Image` (through `llimphi_image::from_rgba8`) and navigates with .
**Lazy render with a cache**: on open only page 0 is rasterized; each page jump
rasterizes the new one (if not cached) in the caller's `update` — never in `view`,
which is pure (`&state`). hayro's `Documento` is kept alive inside the state.
Loading is **sync**, like the rest of the family.
---
Part of **nahual** — see [nahual](../README.md).

Some files were not shown because too many files have changed in this diff Show More