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:
+44
-14
@@ -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.
|
||||
|
||||
@@ -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,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,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)]
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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, ¶ms)?;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# meta-schema
|
||||
# nahual-meta-schema
|
||||
|
||||
> Schema declarativo de viewers de [nahual](../../README.md).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# meta-schema
|
||||
# nahual-meta-schema
|
||||
|
||||
> Declarative viewer schema for [nahual](../../README.md).
|
||||
|
||||
|
||||
@@ -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 acá.
|
||||
//! 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 acá.
|
||||
/// 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 — acá 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; acá 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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 `a–b`, 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 }
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user